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?").
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()
}

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

View file

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