feat(stream): cache scan-time thumbnail frames to the .unarr sidecar

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/<name>.t<sec>w<width>.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.
This commit is contained in:
Deivid Soto 2026-06-02 09:20:00 +02:00
parent 178c16f458
commit 1e5de874cf
6 changed files with 237 additions and 52 deletions

View file

@ -322,6 +322,14 @@ func configLibrary(cfg *config.Config) error {
Title("Allow file deletion from web UI?"). 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."). 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), 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() ).Run()
} }

View file

@ -350,6 +350,7 @@ func runDaemonStart() error {
// /sub serves instantly (and giant remuxes that exceed the on-demand timeout // /sub serves instantly (and giant remuxes that exceed the on-demand timeout
// work once the scan prewarm has filled the cache). Default true. // work once the scan prewarm has filled the cache). Default true.
streamSrv.SetCacheSubtitles(cfg.Library.CacheSubtitles) streamSrv.SetCacheSubtitles(cfg.Library.CacheSubtitles)
streamSrv.SetCacheThumbnails(cfg.Library.CacheThumbnails)
streamSrv.SetRequireStreamToken(cfg.Download.RequireStreamToken) streamSrv.SetRequireStreamToken(cfg.Download.RequireStreamToken)
// Report the stream-token signing key ONLY when enforcing, so the web's // Report the stream-token signing key ONLY when enforcing, so the web's
// "secret present → mint HLS token" signal accurately means "this agent // "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, Incremental: existing != nil,
} }
// Resolve ffmpeg once for the subtitle-sidecar prewarm (extracts text subs // Resolve ffmpeg once for the sidecar prewarm (extracts text subs → WebVTT
// to the hidden ".unarr" cache so /sub is instant + huge remuxes work). // and panel thumbnail frames → JPEG into the hidden ".unarr" cache so /sub
// Empty/err = prewarm is skipped silently (on-demand extraction still runs). // and /thumbnail are instant + huge remuxes work). Empty/err = prewarm is
// skipped silently (on-demand extraction still runs).
prewarmFFmpeg := "" prewarmFFmpeg := ""
if cfg.Library.CacheSubtitles { if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails {
if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil {
prewarmFFmpeg = ff prewarmFFmpeg = ff
} else { } else {
log.Printf("[auto-scan] subtitle prewarm disabled: ffmpeg unavailable: %v", err) log.Printf("[auto-scan] sidecar prewarm disabled: ffmpeg unavailable: %v", err)
} }
} }
@ -1028,7 +1030,8 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
if prewarmFFmpeg != "" { if prewarmFFmpeg != "" {
library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{ library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{
FFmpegPath: prewarmFFmpeg, FFmpegPath: prewarmFFmpeg,
CacheSubtitles: true, CacheSubtitles: cfg.Library.CacheSubtitles,
CacheThumbnails: cfg.Library.CacheThumbnails,
Workers: 2, Workers: 2,
}) })
} }

View file

@ -140,15 +140,17 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error
return enc.Encode(cache) return enc.Encode(cache)
} }
// Pre-extract subtitle sidecars (text subs → WebVTT in a hidden ".unarr" dir) // Pre-extract sidecars (text subs → WebVTT, panel frames → JPEG) into a hidden
// so playback gets instant subtitles and huge remuxes never hit the on-demand // ".unarr" dir so playback gets instant subtitles/thumbnails and huge remuxes
// timeout. Best-effort + Ctrl-C interruptible (the scan itself is already saved). // never hit the on-demand timeout. Best-effort + Ctrl-C interruptible (the scan
if cfg.Library.CacheSubtitles { // itself is already saved).
if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails {
if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil { 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{ library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{
FFmpegPath: ff, FFmpegPath: ff,
CacheSubtitles: true, CacheSubtitles: cfg.Library.CacheSubtitles,
CacheThumbnails: cfg.Library.CacheThumbnails,
Workers: 2, Workers: 2,
}) })
} }

View file

@ -106,11 +106,12 @@ type StreamServer struct {
// Listen() via SetFFmpegPath; read-only thereafter so the handler needs no lock. // Listen() via SetFFmpegPath; read-only thereafter so the handler needs no lock.
ffmpegPath string ffmpegPath string
// cacheSubtitles enables write-through caching of extracted WebVTT to the // cacheSubtitles / cacheThumbnails enable write-through caching of extracted
// hidden ".unarr" sidecar dir next to the media (mirrors the scan-time // WebVTT / JPEG frames into the hidden ".unarr" sidecar dir next to the media
// prewarm). Set once before Listen() via SetCacheSubtitles; default false here, // (mirrors the scan-time prewarm). Set once before Listen() via the setters;
// flipped on from config (default true) by the daemon. read-only thereafter. // default false here, flipped on from config (default true) by the daemon.
cacheSubtitles bool cacheSubtitles bool
cacheThumbnails bool
lastActivity atomic.Int64 lastActivity atomic.Int64
maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
@ -218,6 +219,13 @@ func (ss *StreamServer) SetCacheSubtitles(on bool) {
ss.cacheSubtitles = on 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 // SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev // default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
// ports) is always merged in. Call before Listen(). // 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")) pos := parseThumbPos(q.Get("pos"))
width := parseThumbWidth(q.Get("w")) 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 // 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. // a seek past EOF could hang ffmpeg. 20s is generous for a keyframe seek.
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) 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) http.Error(w, "thumbnail failed", http.StatusInternalServerError)
return 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") w.Header().Set("Content-Type", "image/jpeg")
// path+pos is stable content; let the browser cache so re-opening the panel // 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("Cache-Control", "private, max-age=3600")
w.Header().Set("Content-Length", strconv.Itoa(len(out))) w.Header().Set("Content-Length", strconv.Itoa(len(jpeg)))
if _, err := w.Write(out); err != nil { if _, err := w.Write(jpeg); err != nil {
log.Printf("[thumbnail] write failed: %v", err) log.Printf("[thumbnail] write failed: %v", err)
} }
} }

View file

@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "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)) 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 // 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 // media file. A re-download/replace bumps the media mtime and invalidates the
// stale sidecar so we re-extract. // stale sidecar so we re-extract.
@ -133,3 +146,52 @@ func ExtractSubtitleVTT(ctx context.Context, ffmpegPath, mediaPath string, index
} }
return out, nil 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
}

View file

@ -3,30 +3,52 @@ package library
import ( import (
"context" "context"
"log" "log"
"math"
"sync" "sync"
"time" "time"
"github.com/torrentclaw/unarr/internal/library/mediainfo" "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. // PrewarmOptions controls scan-time sidecar extraction.
type PrewarmOptions struct { type PrewarmOptions struct {
FFmpegPath string // resolved ffmpeg binary; empty disables prewarm FFmpegPath string // resolved ffmpeg binary; empty disables prewarm
CacheSubtitles bool // library.cache_subtitles CacheSubtitles bool // library.cache_subtitles
CacheThumbnails bool // library.cache_thumbnails
Workers int // concurrent ffmpeg jobs (each is heavy); default 2 Workers int // concurrent ffmpeg jobs (each is heavy); default 2
} }
// PrewarmSidecars extracts every text subtitle of every scanned item into the // prewarmJob is one extraction unit: a text subtitle (thumb=false) or a single
// hidden ".unarr" sidecar dir next to the media file, so the /sub handler serves // thumbnail frame (thumb=true).
// it instantly at play time (instead of re-running ffmpeg, which on a 50GB+ type prewarmJob struct {
// remux exceeds the on-demand HTTP timeout). Without the per-request 60s ceiling path string
// here, even huge files complete (generous per-file timeout). 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 // Best-effort and idempotent: fresh sidecars are skipped, errors are logged and
// logged and the item moves on, and ctx cancellation (Ctrl-C / daemon shutdown) // the item moves on, and ctx cancellation (Ctrl-C / daemon shutdown) stops
// stops cleanly. Safe to call after every scan — only missing/stale caches do work. // cleanly. Safe to call after every scan — only missing/stale caches do work.
func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptions) { 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 return
} }
workers := opts.Workers workers := opts.Workers
@ -34,14 +56,10 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
workers = 2 workers = 2
} }
type job struct { jobs := make(chan prewarmJob)
path string
index int
}
jobs := make(chan job)
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex var mu sync.Mutex
cached, failed := 0, 0 subCached, thumbCached, failed := 0, 0, 0
for i := 0; i < workers; i++ { for i := 0; i < workers; i++ {
wg.Add(1) wg.Add(1)
@ -51,6 +69,33 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
if ctx.Err() != nil { if ctx.Err() != nil {
return 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 { if _, ok := mediainfo.ReadCachedSubtitle(j.path, j.index); ok {
continue // already fresh continue // already fresh
} }
@ -74,7 +119,7 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
continue continue
} }
mu.Lock() mu.Lock()
cached++ subCached++
mu.Unlock() mu.Unlock()
} }
}() }()
@ -86,21 +131,59 @@ func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptio
if item.MediaInfo == nil || item.FilePath == "" { if item.MediaInfo == nil || item.FilePath == "" {
continue continue
} }
if opts.CacheSubtitles {
for idx, sub := range item.MediaInfo.Subtitles { for idx, sub := range item.MediaInfo.Subtitles {
if !mediainfo.IsTextSubtitleCodec(sub.Codec) { if !mediainfo.IsTextSubtitleCodec(sub.Codec) {
continue // bitmap → burned in, not extractable to WebVTT continue // bitmap → burned in, not extractable to WebVTT
} }
select { select {
case jobs <- job{path: item.FilePath, index: idx}: case jobs <- prewarmJob{path: item.FilePath, index: idx}:
case <-ctx.Done(): case <-ctx.Done():
return 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() wg.Wait()
if cached > 0 || failed > 0 { if subCached > 0 || thumbCached > 0 || failed > 0 {
log.Printf("[prewarm] subtitles: %d cached, %d failed", cached, failed) 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
}