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:
parent
178c16f458
commit
1e5de874cf
6 changed files with 237 additions and 52 deletions
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1027,9 +1029,10 @@ 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,
|
||||||
Workers: 2,
|
CacheThumbnails: cfg.Library.CacheThumbnails,
|
||||||
|
Workers: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,16 +140,18 @@ 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,
|
||||||
Workers: 2,
|
CacheThumbnails: cfg.Library.CacheThumbnails,
|
||||||
|
Workers: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Workers int // concurrent ffmpeg jobs (each is heavy); default 2
|
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
|
// 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
|
||||||
}
|
}
|
||||||
for idx, sub := range item.MediaInfo.Subtitles {
|
if opts.CacheSubtitles {
|
||||||
if !mediainfo.IsTextSubtitleCodec(sub.Codec) {
|
for idx, sub := range item.MediaInfo.Subtitles {
|
||||||
continue // bitmap → burned in, not extractable to WebVTT
|
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}:
|
if opts.CacheThumbnails {
|
||||||
case <-ctx.Done():
|
for _, pos := range thumbPositions(item) {
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue