On-demand WebVTT extraction re-ran ffmpeg on every /sub request and, for 50GB+ remuxes, couldn't finish a full text track within the 60s HTTP timeout → the web player got a 500 and no subtitles. Extract each text subtitle ONCE — during the library scan (no HTTP deadline, generous per-file timeout) and write-through on the first on-demand request — into a hidden ".unarr/<name>.s<index>.vtt" sidecar next to the media file. The /sub handler serves a fresh sidecar instantly (mtime-invalidated when the media is replaced), so playback subtitles are instant and huge files work. - mediainfo.sidecar: cache paths, mtime freshness, atomic write, ExtractSubtitleVTT, IsTextSubtitleCodec (shared classifier, mirrors engine + web whitelists). - library.PrewarmSidecars: bounded, idempotent, ctx-cancellable background pass run after every scan (manual + daemon auto-scan). - subtitleHandler: cache-read → hit; miss → extract → write-through. - config: library.cache_subtitles (default true), wired via SetCacheSubtitles. Local-only by design: nothing extracted is uploaded — the sidecar is the user's own content, private to their disk.
106 lines
2.9 KiB
Go
106 lines
2.9 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
|
)
|
|
|
|
// PrewarmOptions controls scan-time sidecar extraction.
|
|
type PrewarmOptions struct {
|
|
FFmpegPath string // resolved ffmpeg binary; empty disables prewarm
|
|
CacheSubtitles bool // library.cache_subtitles
|
|
Workers int // concurrent ffmpeg jobs (each is heavy); default 2
|
|
}
|
|
|
|
// PrewarmSidecars extracts every text subtitle of every scanned item into the
|
|
// hidden ".unarr" sidecar dir next to the media file, so the /sub handler serves
|
|
// it instantly at play time (instead of re-running ffmpeg, which on a 50GB+
|
|
// remux exceeds the on-demand HTTP timeout). Without the per-request 60s ceiling
|
|
// here, even huge files complete (generous per-file timeout).
|
|
//
|
|
// Best-effort and idempotent: an already-fresh sidecar is 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 {
|
|
return
|
|
}
|
|
workers := opts.Workers
|
|
if workers < 1 {
|
|
workers = 2
|
|
}
|
|
|
|
type job struct {
|
|
path string
|
|
index int
|
|
}
|
|
jobs := make(chan job)
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
cached, failed := 0, 0
|
|
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := range jobs {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if _, ok := mediainfo.ReadCachedSubtitle(j.path, j.index); ok {
|
|
continue // already fresh
|
|
}
|
|
// Generous per-file deadline: a full text track on a multi-GB
|
|
// remux can take minutes to demux. Bounded so one corrupt file
|
|
// can't wedge a worker forever.
|
|
jctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
|
vtt, err := mediainfo.ExtractSubtitleVTT(jctx, opts.FFmpegPath, j.path, j.index)
|
|
cancel()
|
|
if err != nil {
|
|
mu.Lock()
|
|
failed++
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
if werr := mediainfo.WriteCachedSubtitle(j.path, j.index, vtt); werr != nil {
|
|
log.Printf("[prewarm] sidecar write skipped (i=%d path=%q): %v", j.index, j.path, werr)
|
|
mu.Lock()
|
|
failed++
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
mu.Lock()
|
|
cached++
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
defer close(jobs)
|
|
for _, item := range cache.Items {
|
|
if item.MediaInfo == nil || item.FilePath == "" {
|
|
continue
|
|
}
|
|
for idx, sub := range item.MediaInfo.Subtitles {
|
|
if !mediainfo.IsTextSubtitleCodec(sub.Codec) {
|
|
continue // bitmap → burned in, not extractable to WebVTT
|
|
}
|
|
select {
|
|
case jobs <- job{path: item.FilePath, index: idx}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
wg.Wait()
|
|
if cached > 0 || failed > 0 {
|
|
log.Printf("[prewarm] subtitles: %d cached, %d failed", cached, failed)
|
|
}
|
|
}
|