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

@ -41,26 +41,26 @@ func IsTextSubtitleCodec(codec string) bool {
}
}
// SidecarDir returns the hidden per-folder cache directory for a media file.
func SidecarDir(mediaPath string) string {
// sidecarDir returns the hidden per-folder cache directory for a media file.
func sidecarDir(mediaPath string) string {
return filepath.Join(filepath.Dir(mediaPath), sidecarDirName)
}
// SubtitleCachePath is the cached WebVTT path for subtitle stream `index`
// subtitleCachePath is the cached WebVTT path for subtitle stream `index`
// (0-based, matching ffmpeg's 0:s:N ordering) of mediaPath.
func SubtitleCachePath(mediaPath string, index int) string {
return filepath.Join(SidecarDir(mediaPath), fmt.Sprintf("%s.s%d.vtt", filepath.Base(mediaPath), index))
func subtitleCachePath(mediaPath string, index int) string {
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
// 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 {
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))
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
@ -102,7 +102,7 @@ func writeSidecar(path string, data []byte) error {
// ReadCachedSubtitle returns the cached WebVTT for (mediaPath, index) when a
// fresh sidecar exists. ok=false means the caller should extract on demand.
func ReadCachedSubtitle(mediaPath string, index int) ([]byte, bool) {
p := SubtitleCachePath(mediaPath, index)
p := subtitleCachePath(mediaPath, index)
if !sidecarFresh(p, mediaPath) {
return nil, false
}
@ -115,7 +115,7 @@ func ReadCachedSubtitle(mediaPath string, index int) ([]byte, bool) {
// WriteCachedSubtitle stores extracted WebVTT next to the media. Best-effort.
func WriteCachedSubtitle(mediaPath string, index int, vtt []byte) error {
return writeSidecar(SubtitleCachePath(mediaPath, index), vtt)
return writeSidecar(subtitleCachePath(mediaPath, index), vtt)
}
// ExtractSubtitleVTT runs ffmpeg to convert subtitle stream `index` of mediaPath
@ -180,24 +180,31 @@ func ExtractSubtitlesVTTMulti(ctx context.Context, ffmpegPath, mediaPath string,
cmd.Stderr = &stderr
// Run it at IDLE I/O priority: this single ~14 min sequential read of a huge
// remux must not starve live streaming off the same disk/NFS.
var runErr error
if startErr := cmd.Start(); startErr != nil {
runErr = startErr
} else {
setIdleIOPriority(cmd.Process.Pid)
// A non-zero exit can still leave good per-track files (e.g. one corrupt
// stream), so don't bail on err — read whatever landed and judge by that.
runErr = cmd.Wait()
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("ffmpeg multi-subtitle start: %w", err)
}
setIdleIOPriority(cmd.Process.Pid)
runErr := cmd.Wait()
// If ffmpeg was KILLED (ctx deadline/cancel on a file too big to finish in
// time), any temp file it left is a truncated WebVTT — a valid header plus
// partial cues, so it passes the len>0 check and would be cached as a
// silently-incomplete track until the media's mtime changes. Distrust all
// output in that case. A clean non-zero exit (e.g. one empty/corrupt stream)
// still leaves good complete files for the other tracks, so we keep those.
var exitErr *exec.ExitError
killed := runErr != nil && errors.As(runErr, &exitErr) && !exitErr.ProcessState.Exited()
out := make(map[int][]byte, len(indices))
for idx, f := range tmp {
if b, rerr := os.ReadFile(f); rerr == nil && len(b) > 0 {
out[idx] = b
if !killed {
for idx, f := range tmp {
if b, rerr := os.ReadFile(f); rerr == nil && len(b) > 0 {
out[idx] = b
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("ffmpeg multi-subtitle extract: no output (err=%v): %s", runErr, strings.TrimSpace(stderr.String()))
return nil, fmt.Errorf("ffmpeg multi-subtitle extract: no usable output (err=%v): %s", runErr, strings.TrimSpace(stderr.String()))
}
return out, nil
}
@ -205,7 +212,7 @@ func ExtractSubtitlesVTTMulti(ctx context.Context, ffmpegPath, mediaPath string,
// 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)
p := thumbnailCachePath(mediaPath, posSec, width)
if !sidecarFresh(p, mediaPath) {
return nil, false
}
@ -218,7 +225,7 @@ func ReadCachedThumbnail(mediaPath string, posSec float64, width int) ([]byte, b
// 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)
return writeSidecar(thumbnailCachePath(mediaPath, posSec, width), jpeg)
}
// ExtractThumbnailJPEG decodes ONE frame at posSec, scaled to `width`, as JPEG