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 18:28:03 +08:00
committed by GitHub
co-authored by pennyucloud Claude Opus 4.8
parent fc11ca775f
commit 52f880f0f2
+43 -19
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"os"
"time" "time"
"github.com/celestix/gotgproto/ext" "github.com/celestix/gotgproto/ext"
@@ -14,6 +15,34 @@ import (
"github.com/yapingcat/gomedia/go-mp4" "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 { type VideoMetadata struct {
Duration int Duration int
Width int Width int
@@ -52,16 +81,14 @@ func getMP4Meta(rs io.ReadSeeker) (metadata *VideoMetadata, err error) {
// getVideoMetadata uses ffprobe to get video metadata // getVideoMetadata uses ffprobe to get video metadata
func getVideoMetadata(rs io.ReadSeeker) (*VideoMetadata, error) { 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() { result, err := ffmpeg.ProbeWithTimeout(
defer pipeWriter.Close() path,
rs.Seek(0, io.SeekStart)
io.Copy(pipeWriter, rs)
}()
result, err := ffmpeg.ProbeReaderWithTimeout(
pipeReader,
time.Second*10, time.Second*10,
ffmpeg.KwArgs{ ffmpeg.KwArgs{
"select_streams": "v:0", "select_streams": "v:0",
@@ -114,25 +141,22 @@ func extractThumbFrame(rs io.ReadSeeker) ([]byte, error) {
} }
func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) { func extractFrameAt(rs io.ReadSeeker, timestamp float64) ([]byte, error) {
pipeReader, pipeWriter := io.Pipe() path, cleanup, err := sourceFile(rs)
if err != nil {
go func() { return nil, err
defer pipeWriter.Close() }
rs.Seek(0, io.SeekStart) defer cleanup()
io.Copy(pipeWriter, rs)
}()
var out bytes.Buffer var out bytes.Buffer
err := ffmpeg. err = ffmpeg.
Input("pipe:0", ffmpeg.KwArgs{ Input(path, ffmpeg.KwArgs{
"ss": fmt.Sprintf("%.3f", timestamp), "ss": fmt.Sprintf("%.3f", timestamp),
}). }).
Output("pipe:1", ffmpeg.KwArgs{ Output("pipe:1", ffmpeg.KwArgs{
"vframes": 1, "vframes": 1,
"f": "mjpeg", "f": "mjpeg",
}). }).
WithInput(pipeReader).
WithOutput(&out). WithOutput(&out).
OverWriteOutput(). OverWriteOutput().
Run() Run()