fix(telegram): use seekable file path for thumbnail & metadata extraction (#225)

extractFrameAt() and getVideoMetadata() piped the media into ffmpeg/ffprobe
via pipe:0. A pipe is not seekable, so ffmpeg cannot decode non-faststart MP4s
whose moov atom sits at the END of the file (very common for yt-dlp / HLS-merged
downloads): the frame grab and probe silently fail, and the video is uploaded
with no thumbnail (and, for non-mp4 containers, no dimensions).

Hand ffmpeg/ffprobe a seekable file path instead. When the reader already is an
*os.File we use it directly; otherwise we spool to a temp file and clean it up.

Verified with an A/B upload of a 56 MB non-faststart clip: stock build produced
no thumbnail, patched build produced a correct thumbnail.

Co-authored-by: pennyucloud <valentino@pennyu.co.id>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
irudisca
2026-07-23 17:28:03 +07:00
committed by GitHub
parent fc11ca775f
commit 52f880f0f2

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/celestix/gotgproto/ext"
@@ -14,6 +15,34 @@ import (
"github.com/yapingcat/gomedia/go-mp4"
)
// sourceFile returns a filesystem path to the media for ffmpeg/ffprobe. Those
// tools need a SEEKABLE input: feeding them a pipe (pipe:0) fails for
// non-faststart MP4s whose moov atom is at the END of the file, because ffmpeg
// cannot seek backwards on a stream to read it. That silently broke thumbnail
// and metadata extraction for a large share of downloaded videos. If rs is
// already an *os.File we use it directly; otherwise we spool it to a temp file
// and return a cleanup func.
func sourceFile(rs io.ReadSeeker) (path string, cleanup func(), err error) {
noop := func() {}
if f, ok := rs.(*os.File); ok {
return f.Name(), noop, nil
}
if _, err = rs.Seek(0, io.SeekStart); err != nil {
return "", noop, err
}
tf, err := os.CreateTemp("", "saveany-media-*.tmp")
if err != nil {
return "", noop, err
}
if _, err = io.Copy(tf, rs); err != nil {
tf.Close()
os.Remove(tf.Name())
return "", noop, err
}
tf.Close()
return tf.Name(), func() { os.Remove(tf.Name()) }, nil
}
type VideoMetadata struct {
Duration int
Width int
@@ -52,16 +81,14 @@ func getMP4Meta(rs io.ReadSeeker) (metadata *VideoMetadata, err error) {
// getVideoMetadata uses ffprobe to get video metadata
func getVideoMetadata(rs io.ReadSeeker) (*VideoMetadata, error) {
pipeReader, pipeWriter := io.Pipe()
path, cleanup, err := sourceFile(rs)
if err != nil {
return nil, err
}
defer cleanup()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
result, err := ffmpeg.ProbeReaderWithTimeout(
pipeReader,
result, err := ffmpeg.ProbeWithTimeout(
path,
time.Second*10,
ffmpeg.KwArgs{
"select_streams": "v:0",
@@ -114,25 +141,22 @@ func extractThumbFrame(rs io.ReadSeeker) ([]byte, error) {
}
func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) {
pipeReader, pipeWriter := io.Pipe()
go func() {
defer pipeWriter.Close()
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
path, cleanup, err := sourceFile(rs)
if err != nil {
return nil, err
}
defer cleanup()
var out bytes.Buffer
err := ffmpeg.
Input("pipe:0", ffmpeg.KwArgs{
err = ffmpeg.
Input(path, ffmpeg.KwArgs{
"ss": fmt.Sprintf("%.3f", timestamp),
}).
Output("pipe:1", ffmpeg.KwArgs{
"vframes": 1,
"f": "mjpeg",
}).
WithInput(pipeReader).
WithOutput(&out).
OverWriteOutput().
Run()