unarr/internal/library/prewarm.go
Deivid Soto 8e37293b7d feat(trickplay): scan-time montage sprite for the web scrubber
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:<sha256> 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.
2026-06-03 20:30:29 +02:00

267 lines
8.3 KiB
Go

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
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 (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
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
// 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: 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 && !opts.CacheThumbnails && !opts.Trickplay) {
return
}
workers := opts.Workers
if workers < 1 {
workers = 2
}
jobs := make(chan prewarmJob)
var wg sync.WaitGroup
var mu sync.Mutex
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".
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
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
mu.Lock()
failed++
if sampleErr == "" {
sampleErr = err.Error()
}
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 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.
todo := make([]int, 0, len(j.subIdx))
for _, idx := range j.subIdx {
if _, ok := mediainfo.ReadCachedSubtitle(j.path, idx); !ok {
todo = append(todo, idx)
}
}
if len(todo) == 0 {
continue
}
// Generous per-file deadline. Subtitle packets are interleaved across
// the whole container, so extraction is I/O-bound: it must read the
// entire file once (all text tracks share that single pass). A 60GB
// remux over ~75 MB/s NFS is ~14 min, so 45 min covers files up to
// ~200GB; bounded so one corrupt/stalled file can't wedge a worker.
// This is background + idempotent — it only runs until the cache fills.
jctx, cancel := context.WithTimeout(ctx, 45*time.Minute)
res, err := mediainfo.ExtractSubtitlesVTTMulti(jctx, opts.FFmpegPath, j.path, todo)
cancel()
if err != nil {
mu.Lock()
failed += len(todo)
if sampleErr == "" {
sampleErr = err.Error()
}
mu.Unlock()
continue
}
for idx, vtt := range res {
if werr := mediainfo.WriteCachedSubtitle(j.path, idx, vtt); werr != nil {
log.Printf("[prewarm] sidecar write skipped (i=%d path=%q): %v", idx, j.path, werr)
mu.Lock()
failed++
mu.Unlock()
continue
}
mu.Lock()
subCached++
mu.Unlock()
}
}
}()
}
go func() {
defer close(jobs)
for _, item := range cache.Items {
if item.MediaInfo == nil || item.FilePath == "" {
continue
}
if opts.CacheSubtitles {
var subIdx []int
for idx, sub := range item.MediaInfo.Subtitles {
if mediainfo.IsTextSubtitleCodec(sub.Codec) {
subIdx = append(subIdx, idx) // bitmap → burned in, skipped
}
}
if len(subIdx) > 0 {
select {
case jobs <- prewarmJob{path: item.FilePath, subIdx: subIdx}:
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
}
}
}
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 || trickCached > 0 || failed > 0 {
if failed > 0 && 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, %d trickplay cached, %d failed", subCached, thumbCached, trickCached, 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
}