fix(stream): /critico review fixes for the sidecar cache

- ExtractSubtitlesVTTMulti: distrust output when ffmpeg is killed by signal
  (45-min timeout on a too-big remux) — a truncated WebVTT passed the len>0
  check and got cached as a silently-incomplete track until the media mtime
  changed. Skip all output on signal-kill; keep it on a clean non-zero exit.
- stream handlers: read the sidecar cache BEFORE the ffmpegPath guard so a
  pre-warmed sub/thumbnail still serves if ffmpeg was removed after the cache
  was filled.
- scan: log when the prewarm is skipped because ffmpeg is unavailable (matches
  the daemon; CLAUDE.md wants bootstrap to log on every branch).
- unexport sidecarDir/subtitleCachePath/thumbnailCachePath (no external callers).
- prewarm: surface a sample error in the summary so a systemic ffmpeg failure
  is distinguishable from one corrupt file.
- add unit tests: codec whitelist, cache paths, mtime freshness, atomic write,
  thumb-position dedup.
This commit is contained in:
Deivid Soto 2026-06-02 13:46:07 +02:00
parent 1c8cc1c409
commit bc6f85bf39
6 changed files with 228 additions and 37 deletions

View file

@ -945,22 +945,24 @@ func (ss *StreamServer) thumbnailHandler(w http.ResponseWriter, r *http.Request)
http.Error(w, "not found", http.StatusNotFound)
return
}
if ss.ffmpegPath == "" {
http.Error(w, "thumbnails unavailable", http.StatusServiceUnavailable)
return
}
pos := parseThumbPos(q.Get("pos"))
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.
// skipping ffmpeg. Checked BEFORE the ffmpeg guard so a pre-warmed frame is
// still serveable even if ffmpeg was removed after the cache was filled.
if jpeg, ok := mediainfo.ReadCachedThumbnail(rawPath, pos, width); ok {
ss.writeJPEG(w, jpeg)
return
}
// Beyond here we must extract on demand, which needs ffmpeg.
if ss.ffmpegPath == "" {
http.Error(w, "thumbnails unavailable", http.StatusServiceUnavailable)
return
}
// 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.
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
@ -1039,21 +1041,24 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request)
http.Error(w, "not found", http.StatusNotFound)
return
}
if ss.ffmpegPath == "" {
http.Error(w, "subtitles unavailable", http.StatusServiceUnavailable)
return
}
// Cache hit: serve a fresh sidecar (written by the scan-time prewarm or a
// prior request) instantly, skipping ffmpeg. This is also what makes huge
// remuxes work — the prewarm extracts without the on-demand HTTP timeout
// below, so by play time the hit avoids the 60s ceiling that was returning
// 500s on 50GB+ files.
// 500s on 50GB+ files. Checked BEFORE the ffmpeg guard so a pre-warmed track
// is still serveable even if ffmpeg was removed after the cache was filled.
if vtt, ok := mediainfo.ReadCachedSubtitle(rawPath, index); ok {
ss.writeVTT(w, vtt)
return
}
// Beyond here we must extract on demand, which needs ffmpeg.
if ss.ffmpegPath == "" {
http.Error(w, "subtitles unavailable", http.StatusServiceUnavailable)
return
}
// A full subtitle track is small (KBslow MBs); 60s is ample for a normal
// movie's text track and bounds a hung/corrupt ffmpeg. Giant remuxes can
// exceed this on first play — the prewarm pre-fills the cache so this