From 1e5de874cf8fdfccef6fed6e39a4351e7e1d6dda Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Tue, 2 Jun 2026 09:20:00 +0200 Subject: [PATCH] feat(stream): cache scan-time thumbnail frames to the .unarr sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-extract the file panel's sample frames (10/30/50/70/90% of runtime, w=320) during the library scan and write-through any on-demand /thumbnail request into the hidden ".unarr/.tw.jpg" sidecar. The /thumbnail handler serves a fresh sidecar instantly, so the characteristics panel and seekbar previews stop re-running ffmpeg per request. - mediainfo.sidecar: ThumbnailCachePath, ReadCachedThumbnail, WriteCachedThumbnail, ExtractThumbnailJPEG (mirrors engine.buildThumbnailArgs). - library.PrewarmSidecars: also enqueues the panel frame positions (kept in lockstep with the web's THUMB_FRACTIONS / THUMB_WIDTH) per item with a duration. - thumbnailHandler: cache-read → hit; miss → extract → write-through. - config: library.cache_thumbnails (default true) + both cache toggles exposed in the interactive 'unarr config' library menu. Local only by design — frames are the user's own content, never uploaded. --- internal/cmd/config_menu.go | 8 ++ internal/cmd/daemon.go | 19 ++-- internal/cmd/scan.go | 18 ++-- internal/engine/stream_server.go | 43 ++++++-- internal/library/mediainfo/sidecar.go | 62 ++++++++++++ internal/library/prewarm.go | 139 ++++++++++++++++++++------ 6 files changed, 237 insertions(+), 52 deletions(-) diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go index 334d815..ecd81dc 100644 --- a/internal/cmd/config_menu.go +++ b/internal/cmd/config_menu.go @@ -322,6 +322,14 @@ func configLibrary(cfg *config.Config) error { Title("Allow file deletion from web UI?"). Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered."). Value(&cfg.Library.AllowDelete), + huh.NewConfirm(). + Title("Cache subtitles during scan?"). + Description("Extract embedded text subtitles to WebVTT once during the scan and store them\nbeside the media (hidden .unarr dir) so playback subtitles are instant — and huge\nremuxes don't time out extracting on demand. Local only; nothing is uploaded."). + Value(&cfg.Library.CacheSubtitles), + huh.NewConfirm(). + Title("Cache thumbnails during scan?"). + Description("Pre-extract a few preview frames per file (hidden .unarr dir) so the file panel\nand seekbar previews load instantly. Small optimized JPEGs; local only."). + Value(&cfg.Library.CacheThumbnails), ), ).Run() } diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index bad8ece..f27fbc9 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -350,6 +350,7 @@ func runDaemonStart() error { // /sub serves instantly (and giant remuxes that exceed the on-demand timeout // work once the scan prewarm has filled the cache). Default true. streamSrv.SetCacheSubtitles(cfg.Library.CacheSubtitles) + streamSrv.SetCacheThumbnails(cfg.Library.CacheThumbnails) 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 @@ -999,15 +1000,16 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, Incremental: existing != nil, } - // Resolve ffmpeg once for the subtitle-sidecar prewarm (extracts text subs - // to the hidden ".unarr" cache so /sub is instant + huge remuxes work). - // Empty/err = prewarm is skipped silently (on-demand extraction still runs). + // Resolve ffmpeg once for the sidecar prewarm (extracts text subs → WebVTT + // and panel thumbnail frames → JPEG into the hidden ".unarr" cache so /sub + // and /thumbnail are instant + huge remuxes work). Empty/err = prewarm is + // skipped silently (on-demand extraction still runs). prewarmFFmpeg := "" - if cfg.Library.CacheSubtitles { + if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails { if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { prewarmFFmpeg = ff } else { - log.Printf("[auto-scan] subtitle prewarm disabled: ffmpeg unavailable: %v", err) + log.Printf("[auto-scan] sidecar prewarm disabled: ffmpeg unavailable: %v", err) } } @@ -1027,9 +1029,10 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, if prewarmFFmpeg != "" { library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{ - FFmpegPath: prewarmFFmpeg, - CacheSubtitles: true, - Workers: 2, + FFmpegPath: prewarmFFmpeg, + CacheSubtitles: cfg.Library.CacheSubtitles, + CacheThumbnails: cfg.Library.CacheThumbnails, + Workers: 2, }) } diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index 4ef58aa..f0af7bc 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -140,16 +140,18 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error return enc.Encode(cache) } - // Pre-extract subtitle sidecars (text subs → WebVTT in a hidden ".unarr" dir) - // so playback gets instant subtitles and huge remuxes never hit the on-demand - // timeout. Best-effort + Ctrl-C interruptible (the scan itself is already saved). - if cfg.Library.CacheSubtitles { + // Pre-extract sidecars (text subs → WebVTT, panel frames → JPEG) into a hidden + // ".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 ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { - fmt.Fprintf(os.Stderr, " Pre-extracting subtitles to cache… (Ctrl-C to skip)\n") + fmt.Fprintf(os.Stderr, " Pre-extracting subtitles + thumbnails to cache… (Ctrl-C to skip)\n") library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{ - FFmpegPath: ff, - CacheSubtitles: true, - Workers: 2, + FFmpegPath: ff, + CacheSubtitles: cfg.Library.CacheSubtitles, + CacheThumbnails: cfg.Library.CacheThumbnails, + Workers: 2, }) } } diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index f21cacd..12eda00 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -106,11 +106,12 @@ type StreamServer struct { // Listen() via SetFFmpegPath; read-only thereafter so the handler needs no lock. ffmpegPath string - // cacheSubtitles enables write-through caching of extracted WebVTT to the - // hidden ".unarr" sidecar dir next to the media (mirrors the scan-time - // prewarm). Set once before Listen() via SetCacheSubtitles; default false here, - // flipped on from config (default true) by the daemon. read-only thereafter. - cacheSubtitles bool + // cacheSubtitles / cacheThumbnails enable write-through caching of extracted + // WebVTT / JPEG frames into the hidden ".unarr" sidecar dir next to the media + // (mirrors the scan-time prewarm). Set once before Listen() via the setters; + // default false here, flipped on from config (default true) by the daemon. + cacheSubtitles bool + cacheThumbnails bool lastActivity atomic.Int64 maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) @@ -218,6 +219,13 @@ func (ss *StreamServer) SetCacheSubtitles(on bool) { ss.cacheSubtitles = on } +// SetCacheThumbnails toggles write-through caching of extracted JPEG frames into +// the hidden ".unarr" sidecar dir next to the media file (library.cache_thumbnails, +// default true). Call before Listen(); read-only thereafter. +func (ss *StreamServer) SetCacheThumbnails(on bool) { + ss.cacheThumbnails = on +} + // 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(). @@ -945,6 +953,14 @@ func (ss *StreamServer) thumbnailHandler(w http.ResponseWriter, r *http.Request) pos := parseThumbPos(q.Get("pos")) width := parseThumbWidth(q.Get("w")) + // Cache hit: serve a fresh sidecar (written by the scan-time prewarm — which + // pre-extracts the 10/30/50/70/90% panel frames — or a prior request), + // skipping ffmpeg. + if jpeg, ok := mediainfo.ReadCachedThumbnail(rawPath, pos, width); ok { + ss.writeJPEG(w, jpeg) + return + } + // Cap the work: a single keyframe decode is fast, but a corrupt/huge file or // a seek past EOF could hang ffmpeg. 20s is generous for a keyframe seek. ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) @@ -962,13 +978,24 @@ func (ss *StreamServer) thumbnailHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "thumbnail failed", http.StatusInternalServerError) return } + // Write-through so the next request (and trickplay re-hover) is a cache hit. + if ss.cacheThumbnails { + if werr := mediainfo.WriteCachedThumbnail(rawPath, pos, width, out); werr != nil { + log.Printf("[thumbnail] cache write skipped (pos=%.1f w=%d path=%q): %v", pos, width, rawPath, werr) + } + } + ss.writeJPEG(w, out) +} +// writeJPEG writes the standard single-frame response headers + body for both +// the cache-hit and freshly-extracted paths of thumbnailHandler. +func (ss *StreamServer) writeJPEG(w http.ResponseWriter, jpeg []byte) { w.Header().Set("Content-Type", "image/jpeg") // path+pos is stable content; let the browser cache so re-opening the panel - // doesn't re-run ffmpeg. private — it's a frame of the user's own file. + // doesn't re-fetch. private — it's a frame of the user's own file. w.Header().Set("Cache-Control", "private, max-age=3600") - w.Header().Set("Content-Length", strconv.Itoa(len(out))) - if _, err := w.Write(out); err != nil { + w.Header().Set("Content-Length", strconv.Itoa(len(jpeg))) + if _, err := w.Write(jpeg); err != nil { log.Printf("[thumbnail] write failed: %v", err) } } diff --git a/internal/library/mediainfo/sidecar.go b/internal/library/mediainfo/sidecar.go index 0574ac9..8aaabbd 100644 --- a/internal/library/mediainfo/sidecar.go +++ b/internal/library/mediainfo/sidecar.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "math" "os" "os/exec" "path/filepath" + "strconv" "strings" ) @@ -49,6 +51,17 @@ func SubtitleCachePath(mediaPath string, index int) string { return filepath.Join(SidecarDir(mediaPath), fmt.Sprintf("%s.s%d.vtt", filepath.Base(mediaPath), index)) } +// ThumbnailCachePath is the cached JPEG path for a single frame at posSec +// (rounded to whole seconds) and the given width. The handler and the scan +// prewarm round identically so the same logical frame maps to one cache file. +func ThumbnailCachePath(mediaPath string, posSec float64, width int) string { + sec := int(math.Round(posSec)) + if sec < 0 { + sec = 0 + } + return filepath.Join(SidecarDir(mediaPath), fmt.Sprintf("%s.t%dw%d.jpg", filepath.Base(mediaPath), sec, width)) +} + // sidecarFresh reports whether a cache file exists and is at least as new as the // media file. A re-download/replace bumps the media mtime and invalidates the // stale sidecar so we re-extract. @@ -133,3 +146,52 @@ func ExtractSubtitleVTT(ctx context.Context, ffmpegPath, mediaPath string, index } return out, nil } + +// ReadCachedThumbnail returns the cached JPEG for (mediaPath, posSec, width) when +// a fresh sidecar exists. ok=false means extract on demand. +func ReadCachedThumbnail(mediaPath string, posSec float64, width int) ([]byte, bool) { + p := ThumbnailCachePath(mediaPath, posSec, width) + if !sidecarFresh(p, mediaPath) { + return nil, false + } + b, err := os.ReadFile(p) + if err != nil || len(b) == 0 { + return nil, false + } + return b, true +} + +// WriteCachedThumbnail stores an extracted JPEG frame next to the media. Best-effort. +func WriteCachedThumbnail(mediaPath string, posSec float64, width int, jpeg []byte) error { + return writeSidecar(ThumbnailCachePath(mediaPath, posSec, width), jpeg) +} + +// ExtractThumbnailJPEG decodes ONE frame at posSec, scaled to `width`, as JPEG +// bytes. Mirrors engine.buildThumbnailArgs so the scan-time prewarm produces +// frames byte-identical to the on-demand handler (`-ss` before `-i` = fast +// input/keyframe seek). Shared by the prewarm; the handler keeps its own inline +// extraction (covered by thumbnail_test.go) and only reuses the cache helpers. +func ExtractThumbnailJPEG(ctx context.Context, ffmpegPath, mediaPath string, posSec float64, width int) ([]byte, error) { + args := []string{ + "-nostdin", + "-loglevel", "error", + "-ss", strconv.FormatFloat(posSec, 'f', 3, 64), + "-i", mediaPath, + "-frames:v", "1", + "-vf", fmt.Sprintf("scale=%d:-2", width), + "-an", "-sn", + "-f", "mjpeg", + "pipe:1", + } + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ffmpeg thumbnail extract: %w: %s", err, strings.TrimSpace(stderr.String())) + } + if len(out) == 0 { + return nil, errors.New("ffmpeg produced no thumbnail (seek past EOF?)") + } + return out, nil +} diff --git a/internal/library/prewarm.go b/internal/library/prewarm.go index 3d9ee6d..cac44a1 100644 --- a/internal/library/prewarm.go +++ b/internal/library/prewarm.go @@ -3,30 +3,52 @@ package library import ( "context" "log" + "math" "sync" "time" "github.com/torrentclaw/unarr/internal/library/mediainfo" ) +// Thumbnail sampling — kept in lockstep with the web's src/lib/stream/thumbnails.ts +// (THUMB_FRACTIONS / THUMB_FALLBACK_SECS / THUMB_WIDTH) so the frames the scan +// pre-extracts are the exact ones the "file characteristics" panel requests. +var ( + thumbFractions = []float64{0.1, 0.3, 0.5, 0.7, 0.9} + thumbFallbackSec = []float64{30, 120, 300, 600, 1200} +) + +const thumbWidth = 320 + // PrewarmOptions controls scan-time sidecar extraction. type PrewarmOptions struct { - FFmpegPath string // resolved ffmpeg binary; empty disables prewarm - CacheSubtitles bool // library.cache_subtitles - Workers int // concurrent ffmpeg jobs (each is heavy); default 2 + FFmpegPath string // resolved ffmpeg binary; empty disables prewarm + CacheSubtitles bool // library.cache_subtitles + CacheThumbnails bool // library.cache_thumbnails + Workers int // concurrent ffmpeg jobs (each is heavy); default 2 } -// PrewarmSidecars extracts every text subtitle of every scanned item into the -// hidden ".unarr" sidecar dir next to the media file, so the /sub handler serves -// it instantly at play time (instead of re-running ffmpeg, which on a 50GB+ -// remux exceeds the on-demand HTTP timeout). Without the per-request 60s ceiling -// here, even huge files complete (generous per-file timeout). +// prewarmJob is one extraction unit: a text subtitle (thumb=false) or a single +// thumbnail frame (thumb=true). +type prewarmJob struct { + path string + thumb bool + index int // subtitle stream index (subtitle job) + posSec float64 // frame position in seconds (thumbnail job) + width int // frame width (thumbnail job) +} + +// PrewarmSidecars extracts text subtitles (→ WebVTT) and the panel's sample +// thumbnail frames (→ JPEG) for every scanned item into the hidden ".unarr" +// sidecar dir next to the media file, so the /sub and /thumbnail handlers serve +// them instantly. Subtitle extraction without the per-request HTTP timeout is +// what makes huge remuxes work. // -// Best-effort and idempotent: an already-fresh sidecar is skipped, errors are -// logged and 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. +// Best-effort and idempotent: fresh sidecars are skipped, errors are logged and +// 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 { + if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails) { return } workers := opts.Workers @@ -34,14 +56,10 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio workers = 2 } - type job struct { - path string - index int - } - jobs := make(chan job) + jobs := make(chan prewarmJob) var wg sync.WaitGroup var mu sync.Mutex - cached, failed := 0, 0 + subCached, thumbCached, failed := 0, 0, 0 for i := 0; i < workers; i++ { wg.Add(1) @@ -51,6 +69,33 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio if ctx.Err() != nil { return } + if j.thumb { + if _, ok := mediainfo.ReadCachedThumbnail(j.path, j.posSec, j.width); ok { + continue + } + // A single keyframe decode is fast; 60s bounds a corrupt file. + jctx, cancel := context.WithTimeout(ctx, 60*time.Second) + img, err := mediainfo.ExtractThumbnailJPEG(jctx, opts.FFmpegPath, j.path, j.posSec, j.width) + cancel() + if err != nil { // seek past EOF / corrupt → skip silently + mu.Lock() + failed++ + mu.Unlock() + continue + } + if werr := mediainfo.WriteCachedThumbnail(j.path, j.posSec, j.width, img); werr != nil { + log.Printf("[prewarm] thumbnail write skipped (pos=%.0f path=%q): %v", j.posSec, j.path, werr) + mu.Lock() + failed++ + mu.Unlock() + continue + } + mu.Lock() + thumbCached++ + mu.Unlock() + continue + } + if _, ok := mediainfo.ReadCachedSubtitle(j.path, j.index); ok { continue // already fresh } @@ -74,7 +119,7 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio continue } mu.Lock() - cached++ + subCached++ mu.Unlock() } }() @@ -86,21 +131,59 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio if item.MediaInfo == nil || item.FilePath == "" { continue } - for idx, sub := range item.MediaInfo.Subtitles { - if !mediainfo.IsTextSubtitleCodec(sub.Codec) { - continue // bitmap → burned in, not extractable to WebVTT + if opts.CacheSubtitles { + for idx, sub := range item.MediaInfo.Subtitles { + if !mediainfo.IsTextSubtitleCodec(sub.Codec) { + continue // bitmap → burned in, not extractable to WebVTT + } + select { + case jobs <- prewarmJob{path: item.FilePath, index: idx}: + case <-ctx.Done(): + return + } } - select { - case jobs <- job{path: item.FilePath, index: idx}: - case <-ctx.Done(): - return + } + if opts.CacheThumbnails { + for _, pos := range thumbPositions(item) { + select { + case jobs <- prewarmJob{path: item.FilePath, thumb: true, posSec: pos, width: thumbWidth}: + case <-ctx.Done(): + return + } } } } }() wg.Wait() - if cached > 0 || failed > 0 { - log.Printf("[prewarm] subtitles: %d cached, %d failed", cached, failed) + if subCached > 0 || thumbCached > 0 || failed > 0 { + log.Printf("[prewarm] %d subtitles, %d thumbnails cached, %d failed", subCached, thumbCached, failed) } } + +// thumbPositions returns the sample frame offsets (whole seconds) for an item, +// matching the web panel: fractions of a known runtime, else fixed fallbacks. +func thumbPositions(item LibraryItem) []float64 { + var dur float64 + if item.MediaInfo != nil && item.MediaInfo.Video != nil { + dur = item.MediaInfo.Video.Duration + } + src := thumbFallbackSec + if dur > 0 { + src = make([]float64, len(thumbFractions)) + for i, f := range thumbFractions { + src[i] = math.Round(dur * f) + } + } + // Dedup (short clips can round multiple fractions to the same second). + seen := make(map[float64]struct{}, len(src)) + out := make([]float64, 0, len(src)) + for _, p := range src { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return out +}