From 8e37293b7d6747b1b49bba54547f73e7abf51bd7 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 3 Jun 2026 20:30:29 +0200 Subject: [PATCH] feat(trickplay): scan-time montage sprite for the web scrubber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-generate ONE trickplay sprite (montage JPEG of frames sampled every library.trickplay.interval, default 10s) + a JSON manifest per file during the scan/auto-scan prewarm, cached in .unarr next to the media. The web scrubber shows tiles from it instead of extracting frames live — removing the ffmpeg contention with the active stream that broke seekbar previews (the original 'no thumbnail' report was the auto-scan prewarm decoding the same file the HLS transcode was reading, not a seek-index fault). - config: [library.trickplay] enabled/interval/width (default on, 10s, 240px), editable + a toggle; IntervalSeconds() with a 10s fallback. - mediainfo: GenerateTrickplay (one ffmpeg fps=1/interval,scale,tile pass; idle I/O priority; ceil() frame count so no black trailing tile; a 16.7M-px cap coarsens the interval for long media so a single sprite stays decodable on iOS/Safari) + sprite/manifest sidecar cache helpers. - engine: /trickplay endpoint (manifest JSON, ?kind=sprite JPEG); the agent owns the tile width so the web requests by path only; thumb: token reused. - prewarm: a trickplay job per item, gated; scan.go + daemon.go wire the config. Tests: parseDims; synthetic 3x2 / exact-multiple / 1x1; real-file e2e smoke (S02E08 → 143 tiles, 662KB sprite). Non-breaking: the existing 5-frame panel prewarm + on-demand /thumbnail stay until the web migrates to the sprite. --- internal/cmd/daemon.go | 22 +- internal/cmd/scan.go | 13 +- internal/config/config.go | 41 ++++ internal/engine/stream_server.go | 75 ++++++ internal/library/mediainfo/trickplay.go | 245 +++++++++++++++++++ internal/library/mediainfo/trickplay_test.go | 107 ++++++++ internal/library/prewarm.go | 71 +++++- 7 files changed, 553 insertions(+), 21 deletions(-) create mode 100644 internal/library/mediainfo/trickplay.go create mode 100644 internal/library/mediainfo/trickplay_test.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 8fd8938..97ce849 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -371,6 +371,15 @@ func runDaemonStart() error { // work once the scan prewarm has filled the cache). Default true. streamSrv.SetCacheSubtitles(cfg.Library.CacheSubtitles) streamSrv.SetCacheThumbnails(cfg.Library.CacheThumbnails) + // Tell /trickplay which tile width the scan prewarm built the sprite at (the + // agent owns the width; the web requests by path only). 0 = disabled → 404. + trickW := 0 + if cfg.Library.Trickplay.Enabled { + if trickW = cfg.Library.Trickplay.Width; trickW <= 0 { + trickW = 240 + } + } + streamSrv.SetTrickplayWidth(trickW) streamSrv.SetRequireStreamToken(cfg.Download.RequireStreamToken) // Report the stream-token signing key ONLY when enforcing, so the web's // "secret present → mint HLS token" signal accurately means "this agent @@ -1128,7 +1137,7 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, // and /thumbnail are instant + huge remuxes work). Empty/err = prewarm is // skipped silently (on-demand extraction still runs). prewarmFFmpeg := "" - if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails { + if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails || cfg.Library.Trickplay.Enabled { if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { prewarmFFmpeg = ff } else { @@ -1152,10 +1161,13 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, if prewarmFFmpeg != "" { library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{ - FFmpegPath: prewarmFFmpeg, - CacheSubtitles: cfg.Library.CacheSubtitles, - CacheThumbnails: cfg.Library.CacheThumbnails, - Workers: 2, + FFmpegPath: prewarmFFmpeg, + CacheSubtitles: cfg.Library.CacheSubtitles, + CacheThumbnails: cfg.Library.CacheThumbnails, + Workers: 2, + Trickplay: cfg.Library.Trickplay.Enabled, + TrickplayIntervalSec: cfg.Library.Trickplay.IntervalSeconds(), + TrickplayWidth: cfg.Library.Trickplay.Width, }) } diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index 644324e..23f320d 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -144,14 +144,17 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error // ".unarr" dir so playback gets instant subtitles/thumbnails and huge remuxes // never hit the on-demand timeout. Best-effort + Ctrl-C interruptible (the scan // itself is already saved). - if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails { + if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails || cfg.Library.Trickplay.Enabled { if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { fmt.Fprintf(os.Stderr, " Pre-extracting subtitles + thumbnails to cache… (Ctrl-C to skip)\n") library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{ - FFmpegPath: ff, - CacheSubtitles: cfg.Library.CacheSubtitles, - CacheThumbnails: cfg.Library.CacheThumbnails, - Workers: 2, + FFmpegPath: ff, + CacheSubtitles: cfg.Library.CacheSubtitles, + CacheThumbnails: cfg.Library.CacheThumbnails, + Workers: 2, + Trickplay: cfg.Library.Trickplay.Enabled, + TrickplayIntervalSec: cfg.Library.Trickplay.IntervalSeconds(), + TrickplayWidth: cfg.Library.Trickplay.Width, }) } else { fmt.Fprintf(os.Stderr, " Skipping sidecar prewarm: ffmpeg unavailable: %v\n", err) diff --git a/internal/config/config.go b/internal/config/config.go index cffe12a..325ebda 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/BurntSushi/toml" ) @@ -197,6 +198,29 @@ type LibraryConfig struct { // timeout). Both default true; disable to save the disk/CPU of pre-extraction. CacheSubtitles bool `toml:"cache_subtitles"` // default true CacheThumbnails bool `toml:"cache_thumbnails"` // default true + + // Trickplay: at scan time, build ONE montage JPEG of frames sampled every + // Interval seconds (+ a JSON manifest), cached in .unarr next to the media. + // The web scrubber shows tiles from it — no live ffmpeg during playback, so + // no contention with the active stream (the cause of broken seekbar previews) + // — and the file panel picks a few positions from the same grid. + Trickplay TrickplayConfig `toml:"trickplay"` +} + +// TrickplayConfig controls scan-time trickplay sprite generation. +type TrickplayConfig struct { + Enabled bool `toml:"enabled"` // generate the sprite during scan (default true) + Interval string `toml:"interval"` // one frame per Interval, e.g. "10s" (default) + Width int `toml:"width"` // tile width px; height keeps aspect (default 240) +} + +// IntervalSeconds parses Interval ("10s") to seconds, falling back to 10 on an +// empty/invalid value so a typo can't silently disable the sprite. +func (t TrickplayConfig) IntervalSeconds() float64 { + if d, err := time.ParseDuration(strings.TrimSpace(t.Interval)); err == nil && d > 0 { + return d.Seconds() + } + return 10 } // Default returns a Config with sensible defaults. Used both for fresh @@ -268,6 +292,11 @@ func Default() Config { Workers: 8, CacheSubtitles: true, CacheThumbnails: true, + Trickplay: TrickplayConfig{ + Enabled: true, + Interval: "10s", + Width: 240, + }, }, } } @@ -340,6 +369,18 @@ func applyDefaults(cfg *Config, meta toml.MetaData) { if !meta.IsDefined("library", "cache_thumbnails") { cfg.Library.CacheThumbnails = true } + // Trickplay defaults ON for configs predating these keys (small sidecar JPEG; + // makes the scrubber instant + contention-free). Explicit `enabled = false` + // is respected via meta.IsDefined. + if !meta.IsDefined("library", "trickplay", "enabled") { + cfg.Library.Trickplay.Enabled = true + } + if !meta.IsDefined("library", "trickplay", "interval") { + cfg.Library.Trickplay.Interval = "10s" + } + if !meta.IsDefined("library", "trickplay", "width") { + cfg.Library.Trickplay.Width = 240 + } if !meta.IsDefined("downloads", "transcode", "enabled") { cfg.Download.Transcode.Enabled = true diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index e3a7bd3..9b38c3a 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -113,6 +113,13 @@ type StreamServer struct { cacheSubtitles bool cacheThumbnails bool + // trickplayWidth is the tile width (px) the scan-time prewarm used to build + // the trickplay sprite (library.trickplay.width). The /trickplay handler keys + // the sidecar lookup on it so the agent owns the width — the web need not know + // it. 0 = trickplay disabled (the handler 404s and the web falls back to + // on-demand /thumbnail). Set once before Listen() via SetTrickplayWidth. + trickplayWidth int + lastActivity atomic.Int64 maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) totalFileSize atomic.Int64 @@ -227,6 +234,12 @@ func (ss *StreamServer) SetCacheThumbnails(on bool) { ss.cacheThumbnails = on } +// SetTrickplayWidth records the tile width used to build the trickplay sprite +// (library.trickplay.width). 0 leaves trickplay disabled. Call before Listen(). +func (ss *StreamServer) SetTrickplayWidth(width int) { + ss.trickplayWidth = width +} + // SetCORSAllowedOrigins replaces the operator-supplied extra origins. The // default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev // ports) is always merged in. Call before Listen(). @@ -285,6 +298,7 @@ func (ss *StreamServer) Listen(ctx context.Context) error { mux.HandleFunc("/playlist.m3u", ss.playlistHandler) mux.HandleFunc("/hls/", ss.hlsHandler) mux.HandleFunc("/thumbnail", ss.thumbnailHandler) + mux.HandleFunc("/trickplay", ss.trickplayHandler) mux.HandleFunc("/sub", ss.subtitleHandler) // SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart) @@ -1090,6 +1104,67 @@ func (ss *StreamServer) writeJPEG(w http.ResponseWriter, jpeg []byte) { } } +// trickplayHandler serves the pre-built trickplay montage sprite (kind=sprite → +// JPEG) or its manifest (default → JSON) for a file. The sprite is generated by +// the scan-time prewarm (library.trickplay) so playback does NO live extraction +// (no contention with the active stream — the cause of broken seekbar previews). +// The agent owns the tile width (its config), so the web requests by path only +// and reads geometry from the manifest. Auth mirrors /thumbnail (a +// thumb: token). 404 when no sprite exists yet → the web falls +// back to on-demand /thumbnail. +func (ss *StreamServer) trickplayHandler(w http.ResponseWriter, r *http.Request) { + ss.lastActivity.Store(time.Now().UnixNano()) + if ss.writeCORSHeaders(w, r, "") { + return + } + q := r.URL.Query() + rawPath := q.Get("p") + if rawPath == "" { + http.Error(w, "missing path", http.StatusBadRequest) + return + } + if !ss.checkStreamToken(streamScopeThumb(rawPath), q.Get("t")) { + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + log.Printf("[trickplay] rejected from %s — bad/absent token", clientIP) + http.Error(w, "not found", http.StatusNotFound) + return + } + if ss.trickplayWidth <= 0 { + http.Error(w, "trickplay disabled", http.StatusNotFound) + return + } + if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() { + http.Error(w, "not found", http.StatusNotFound) + return + } + manifest, ok := mediainfo.ReadCachedTrickplay(rawPath, ss.trickplayWidth) + if !ok { + http.Error(w, "trickplay not available", http.StatusNotFound) + return + } + if q.Get("kind") == "sprite" { + f, err := os.Open(mediainfo.TrickplaySpritePath(rawPath, ss.trickplayWidth)) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + defer f.Close() + mod := time.Time{} + if fi, serr := f.Stat(); serr == nil { + mod = fi.ModTime() + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "private, max-age=3600") + http.ServeContent(w, r, "trickplay.jpg", mod, f) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "private, max-age=3600") + if err := json.NewEncoder(w).Encode(manifest); err != nil { + log.Printf("[trickplay] manifest encode failed: %v", err) + } +} + // subtitleHandler extracts ONE embedded TEXT subtitle stream from a file and // serves it as WebVTT, on demand. It's the single subtitle source the web // player uses for BOTH direct-play and HLS (attached as an external ), diff --git a/internal/library/mediainfo/trickplay.go b/internal/library/mediainfo/trickplay.go new file mode 100644 index 0000000..67fa762 --- /dev/null +++ b/internal/library/mediainfo/trickplay.go @@ -0,0 +1,245 @@ +package mediainfo + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +// TrickplayManifest describes the montage sprite layout so a client can map a +// playback time to one tile: tileIndex = floor(timeSec / IntervalSec), then +// col = tileIndex % Cols, row = tileIndex / Cols, and the tile's pixel box is +// (col*TileWidth, row*TileHeight, TileWidth, TileHeight). +type TrickplayManifest struct { + Version int `json:"version"` // schema version (1) + IntervalSec float64 `json:"intervalSec"` + TileWidth int `json:"tileWidth"` + TileHeight int `json:"tileHeight"` + Cols int `json:"cols"` + Rows int `json:"rows"` + Count int `json:"count"` // number of REAL frames (≤ Cols*Rows; the rest are padding) + DurationSec float64 `json:"durationSec"` +} + +// trickplaySpritePath / trickplayManifestPath include the tile width so changing +// library.trickplay.width regenerates cleanly instead of serving a stale sprite. +func trickplaySpritePath(mediaPath string, width int) string { + return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.trickplay.w%d.jpg", filepath.Base(mediaPath), width)) +} + +func trickplayManifestPath(mediaPath string, width int) string { + return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.trickplay.w%d.json", filepath.Base(mediaPath), width)) +} + +// TrickplaySpritePath is the public accessor the stream server uses to locate the +// cached sprite JPEG for serving. +func TrickplaySpritePath(mediaPath string, width int) string { + return trickplaySpritePath(mediaPath, width) +} + +// ReadCachedTrickplay returns the manifest when a fresh sprite + manifest exist +// for (mediaPath, width). ok=false means the caller should (re)generate. Both +// the sprite and the manifest must be at least as new as the media file. +func ReadCachedTrickplay(mediaPath string, width int) (TrickplayManifest, bool) { + sprite := trickplaySpritePath(mediaPath, width) + manifest := trickplayManifestPath(mediaPath, width) + if !sidecarFresh(sprite, mediaPath) || !sidecarFresh(manifest, mediaPath) { + return TrickplayManifest{}, false + } + b, err := os.ReadFile(manifest) + if err != nil || len(b) == 0 { + return TrickplayManifest{}, false + } + var m TrickplayManifest + if err := json.Unmarshal(b, &m); err != nil || m.Cols <= 0 || m.TileWidth <= 0 { + return TrickplayManifest{}, false + } + return m, true +} + +// GenerateTrickplay builds the montage sprite + manifest for mediaPath and caches +// them in the sidecar dir. ONE ffmpeg pass samples a frame every intervalSec +// (fps=1/interval), scales each to width (even height), and tiles them into a +// single JPEG. The whole file is decoded once — slow but a one-time, cached, +// scan-time cost (run with idle I/O priority by the prewarm), and it removes ALL +// live extraction during playback (no contention with the active stream). +// +// durationSec drives the grid size; pass the probed duration (0 → error, nothing +// to sample). The caller owns the ctx deadline (generous at scan time). +func GenerateTrickplay(ctx context.Context, ffmpegPath, mediaPath string, intervalSec float64, width int, durationSec float64) (TrickplayManifest, error) { + if ffmpegPath == "" { + return TrickplayManifest{}, fmt.Errorf("trickplay: no ffmpeg") + } + if intervalSec <= 0 || width <= 0 { + return TrickplayManifest{}, fmt.Errorf("trickplay: invalid interval=%v width=%d", intervalSec, width) + } + if durationSec <= 0 { + return TrickplayManifest{}, fmt.Errorf("trickplay: unknown duration") + } + + // fps=1/interval emits a frame at t=0, interval, 2*interval, … while t < + // duration → ceil(duration/interval) frames. (An earlier floor(...)+1 put a + // black padding tile at the very end of the scrubber on round-duration media.) + effInterval := intervalSec + count := int(math.Ceil(durationSec / effInterval)) + if count < 1 { + count = 1 + } + + // Mobile decode cap: a single JPEG above ~16.7M px (4096²) fails to decode on + // iOS/Safari. For long media, sample fewer frames (coarser effective interval) + // so ONE sprite stays renderable everywhere. tileH is unknown until probe, so + // estimate from 16:9 for the budget; the manifest reports effInterval so the + // client maps time→tile correctly. + const maxSpritePixels = 16_000_000 + estTileH := width * 9 / 16 + if estTileH < 1 { + estTileH = 1 + } + if maxTiles := maxSpritePixels / (width * estTileH); maxTiles >= 1 && count > maxTiles { + effInterval = durationSec / float64(maxTiles) + count = int(math.Ceil(durationSec / effInterval)) + if count > maxTiles { + count = maxTiles // guard ceil rounding + } + } + + // Roughly-square grid. Cols*Rows ≥ count; trailing cells are ffmpeg padding, + // and Count tells the client how many are real. + cols := int(math.Ceil(math.Sqrt(float64(count)))) + if cols < 1 { + cols = 1 + } + rows := int(math.Ceil(float64(count) / float64(cols))) + if rows < 1 { + rows = 1 + } + + spritePath := trickplaySpritePath(mediaPath, width) + manifestPath := trickplayManifestPath(mediaPath, width) + if err := os.MkdirAll(filepath.Dir(spritePath), 0o755); err != nil { + return TrickplayManifest{}, err + } + tmpSprite := spritePath + ".tmp" + + // fps filter wants a rational; format 1/effInterval with enough precision. + fps := fmt.Sprintf("1/%s", strconv.FormatFloat(effInterval, 'f', 3, 64)) + vf := fmt.Sprintf("fps=%s,scale=%d:-2,tile=%dx%d", fps, width, cols, rows) + args := []string{ + "-nostdin", "-loglevel", "error", "-y", + "-i", mediaPath, + "-frames:v", "1", + "-vf", vf, + "-an", "-sn", + "-q:v", "5", + // Force the muxer: the temp output ends in ".tmp", so ffmpeg can't infer + // the format from the extension (it would error "Unable to choose an + // output format"). mjpeg writes the single montage frame as a JPEG. + "-f", "mjpeg", + tmpSprite, + } + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + var stderr strings.Builder + cmd.Stderr = &stderr + // Start + idle I/O priority + Wait (matches the subtitle/thumbnail extractors): + // this full-decode pass is the heaviest sidecar job and runs in the background + // alongside live streaming on the same disk/NFS, so it must yield I/O. + if err := cmd.Start(); err != nil { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, fmt.Errorf("ffmpeg tile start: %w", err) + } + setIdleIOPriority(cmd.Process.Pid) + if err := cmd.Wait(); err != nil { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, fmt.Errorf("ffmpeg tile: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if fi, err := os.Stat(tmpSprite); err != nil || fi.Size() == 0 { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, fmt.Errorf("trickplay: empty sprite") + } + + // Probe the produced sprite for EXACT dimensions, so tile geometry is precise + // (avoids ±1px aspect-rounding drift between our math and ffmpeg's scale=-2). + spriteW, spriteH, err := probeImageDims(ctx, ffmpegPath, tmpSprite) + if err != nil || spriteW < cols || spriteH < rows { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, fmt.Errorf("trickplay: probe sprite dims: %w", err) + } + m := TrickplayManifest{ + Version: 1, + IntervalSec: effInterval, + TileWidth: spriteW / cols, + TileHeight: spriteH / rows, + Cols: cols, + Rows: rows, + Count: count, + DurationSec: durationSec, + } + mb, err := json.Marshal(m) + if err != nil { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, err + } + // Publish sprite (rename) then manifest (atomic write). Order: sprite first so + // a reader that sees a fresh manifest always finds the matching sprite. + if err := os.Rename(tmpSprite, spritePath); err != nil { + _ = os.Remove(tmpSprite) + return TrickplayManifest{}, err + } + if err := writeSidecar(manifestPath, mb); err != nil { + return TrickplayManifest{}, err + } + return m, nil +} + +// probeImageDims returns the pixel width/height of an image file via ffmpeg's +// bundled ffprobe-less path: we reuse ffmpeg with -hide_banner and parse the +// "Stream ... WxH" line from stderr. Using ffmpeg (already resolved) avoids a +// hard dependency on a separate ffprobe binary here. +func probeImageDims(ctx context.Context, ffmpegPath, path string) (int, int, error) { + cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-i", path) + var stderr strings.Builder + cmd.Stderr = &stderr + _ = cmd.Run() // ffmpeg exits non-zero with no output file; we only want the probe stderr + return parseDims(stderr.String()) +} + +// parseDims extracts the first WxH (e.g. "3840x2160") from ffmpeg's stream info. +func parseDims(s string) (int, int, error) { + idx := strings.Index(s, "Video:") + if idx < 0 { + return 0, 0, fmt.Errorf("no video stream in probe output") + } + // Scan for the first "x" token after "Video:". + rest := s[idx:] + for i := 0; i < len(rest); i++ { + if rest[i] < '0' || rest[i] > '9' { + continue + } + j := i + for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' { + j++ + } + if j < len(rest) && rest[j] == 'x' { + k := j + 1 + for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' { + k++ + } + if k > j+1 { + w, _ := strconv.Atoi(rest[i:j]) + h, _ := strconv.Atoi(rest[j+1 : k]) + if w > 0 && h > 0 { + return w, h, nil + } + } + } + i = j + } + return 0, 0, fmt.Errorf("no WxH token in probe output") +} diff --git a/internal/library/mediainfo/trickplay_test.go b/internal/library/mediainfo/trickplay_test.go new file mode 100644 index 0000000..754022d --- /dev/null +++ b/internal/library/mediainfo/trickplay_test.go @@ -0,0 +1,107 @@ +package mediainfo + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestParseDims(t *testing.T) { + cases := []struct { + in string + w, h int + ok bool + }{ + {"Stream #0:0: Video: mjpeg, yuvj420p(pc), 720x270 [SAR 1:1 DAR 8:3]", 720, 270, true}, + {" Stream #0:0: Video: h264 (High), yuv420p, 3840x2160, 23.98 fps", 3840, 2160, true}, + {"Stream #0:1: Audio: aac, 48000 Hz, stereo", 0, 0, false}, // no Video: + {"", 0, 0, false}, + } + for _, c := range cases { + w, h, err := parseDims(c.in) + if c.ok { + if err != nil || w != c.w || h != c.h { + t.Errorf("parseDims(%q) = %d,%d,%v; want %d,%d,nil", c.in, w, h, err, c.w, c.h) + } + } else if err == nil { + t.Errorf("parseDims(%q) expected error, got %dx%d", c.in, w, h) + } + } +} + +// makeClip writes a synthetic 16:9 test clip of the given duration (seconds). +func makeClip(t *testing.T, ff, path string, durSec int) { + t.Helper() + mk := exec.Command(ff, "-nostdin", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=640x360:rate=10", durSec), + "-pix_fmt", "yuv420p", path) + if out, err := mk.CombinedOutput(); err != nil { + t.Fatalf("make test clip: %v: %s", err, out) + } +} + +// TestGenerateTrickplay builds synthetic clips and asserts the sprite grid + +// manifest. ffmpeg-gated (skips without it, like the encode benchmark). +func TestGenerateTrickplay(t *testing.T) { + ff, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg not on PATH") + } + + cases := []struct { + name string + durSec int + wantCount int + wantCols, wantRows int + }{ + // fps=1/10 emits a frame at 0,10,20,… while t 140 { + t.Errorf("tileHeight=%d; want ~135 (16:9)", m.TileHeight) + } + if m.IntervalSec != 10 { + t.Errorf("intervalSec=%v; want 10 (no cap at this size)", m.IntervalSec) + } + if fi, err := os.Stat(TrickplaySpritePath(clip, 240)); err != nil || fi.Size() == 0 { + t.Errorf("sprite not written: %v", err) + } + m2, ok := ReadCachedTrickplay(clip, 240) + if !ok || m2.Count != m.Count || m2.TileHeight != m.TileHeight || m2.Cols != m.Cols { + t.Errorf("ReadCachedTrickplay mismatch: ok=%v got=%+v want=%+v", ok, m2, m) + } + // Stale media (newer mtime) must invalidate the cache. + future := time.Now().Add(2 * time.Hour) + if err := os.Chtimes(clip, future, future); err == nil { + if _, ok := ReadCachedTrickplay(clip, 240); ok { + t.Error("ReadCachedTrickplay returned stale sprite after media mtime bumped") + } + } + }) + } +} diff --git a/internal/library/prewarm.go b/internal/library/prewarm.go index 963eb41..5d5162a 100644 --- a/internal/library/prewarm.go +++ b/internal/library/prewarm.go @@ -26,16 +26,26 @@ type PrewarmOptions struct { CacheSubtitles bool // library.cache_subtitles CacheThumbnails bool // library.cache_thumbnails Workers int // concurrent ffmpeg jobs (each is heavy); default 2 + + // Trickplay (library.trickplay): generate ONE montage sprite per file sampled + // every TrickplayIntervalSec at TrickplayWidth. Replaces live scrubber + // extraction during playback (no contention with the active stream). + Trickplay bool + TrickplayIntervalSec float64 + TrickplayWidth int } // prewarmJob is one extraction unit: all text subtitles of a file in one ffmpeg -// pass (thumb=false) or a single thumbnail frame (thumb=true). +// pass (subtitle job), a single thumbnail frame (thumb=true), or the trickplay +// montage sprite for a file (trick=true). type prewarmJob struct { - path string - thumb bool - subIdx []int // subtitle stream indices to extract in ONE pass (subtitle job) - posSec float64 // frame position in seconds (thumbnail job) - width int // frame width (thumbnail job) + path string + thumb bool + trick bool // trickplay sprite job + subIdx []int // subtitle stream indices to extract in ONE pass (subtitle job) + posSec float64 // frame position in seconds (thumbnail job) + width int // frame/tile width (thumbnail + trickplay jobs) + duration float64 // runtime seconds (trickplay job) } // PrewarmSidecars extracts text subtitles (→ WebVTT) and the panel's sample @@ -48,7 +58,7 @@ type prewarmJob struct { // the item moves on, and ctx cancellation (Ctrl-C / daemon shutdown) stops // cleanly. Safe to call after every scan — only missing/stale caches do work. func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptions) { - if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails) { + if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails && !opts.Trickplay) { return } workers := opts.Workers @@ -59,7 +69,7 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio jobs := make(chan prewarmJob) var wg sync.WaitGroup var mu sync.Mutex - subCached, thumbCached, failed := 0, 0, 0 + subCached, thumbCached, trickCached, failed := 0, 0, 0, 0 var sampleErr string // first extraction error, surfaced in the summary so a // systemic ffmpeg failure (vs one corrupt file) is diagnosable from "N failed". @@ -101,6 +111,28 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio continue } + if j.trick { + if _, ok := mediainfo.ReadCachedTrickplay(j.path, j.width); ok { + continue + } + // Full-decode pass (samples 1 frame per interval over the whole + // file) — generous deadline like subtitles; idempotent + cached. + jctx, cancel := context.WithTimeout(ctx, 45*time.Minute) + _, err := mediainfo.GenerateTrickplay(jctx, opts.FFmpegPath, j.path, opts.TrickplayIntervalSec, j.width, j.duration) + cancel() + mu.Lock() + if err != nil { + failed++ + if sampleErr == "" { + sampleErr = err.Error() + } + } else { + trickCached++ + } + mu.Unlock() + continue + } + // Extract only the indices not already fresh, and do them in ONE // ffmpeg pass — a multi-GB remux is demuxed once for all its text // tracks instead of once per track. @@ -177,15 +209,32 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio } } } + if opts.Trickplay && opts.TrickplayIntervalSec > 0 { + dur := 0.0 + if item.MediaInfo.Video != nil { + dur = item.MediaInfo.Video.Duration + } + if dur > 0 { + w := opts.TrickplayWidth + if w <= 0 { + w = 240 + } + select { + case jobs <- prewarmJob{path: item.FilePath, trick: true, width: w, duration: dur}: + case <-ctx.Done(): + return + } + } + } } }() wg.Wait() - if subCached > 0 || thumbCached > 0 || failed > 0 { + if subCached > 0 || thumbCached > 0 || trickCached > 0 || failed > 0 { if failed > 0 && sampleErr != "" { - log.Printf("[prewarm] %d subtitles, %d thumbnails cached, %d failed (e.g. %s)", subCached, thumbCached, failed, sampleErr) + log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed (e.g. %s)", subCached, thumbCached, trickCached, failed, sampleErr) } else { - log.Printf("[prewarm] %d subtitles, %d thumbnails cached, %d failed", subCached, thumbCached, failed) + log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed", subCached, thumbCached, trickCached, failed) } } }