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

@ -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,
})
}