Files
silo-server/internal/playback/subtitle_cache.go
T
CoffeeKnyte 2312c0f4be perf(playback): cache embedded text-subtitle extracts
Extracting an embedded subtitle track walks the interleaved container:
subtitle packets sit between video and audio across clusters, so
harvesting a few KB of text means demuxing that stretch of a multi-GB
file off CephFS. Over 41.6h the web player saw p95 120,021ms / max
121,470ms on /api/v1/stream/{session_id}/subtitles/{track}, with 45 of
220 fetches over 10s. The 120s ceiling is the server's absolute
WriteTimeout (cmd/silo/main.go:2389) cutting the body mid-flight -- and
because WriteHeader(200) already ran, those truncations are logged
status=200 and are invisible in error metrics.

The server believes a 600s window bounds this. It does not: -t is passed
as an input option and ffmpeg silently ignores it for these extracts, so
every request runs from the seek point to EOF. Measured against the
production binary, `-ss 4000 -t 30` and `-ss 4000 -t 300` are
byte-identical to passing no -t at all (last cue 02:10:04, end of film).
Only -ss works, so cost tracks (duration - seek) x bitrate.

The window cannot simply be turned on. silo-apple and silo-android both
fetch a track once and depend on receiving the whole thing, so bounding
the output would silently kill subtitles ~10min into every film on both
platforms. The accidental whole-track behaviour is the de-facto contract.

So: keep whole-track delivery, make it cheap. SubtitleCache already had
the right shape for PGS; text was excluded only by the assumption that
"VTT is already windowed and fast", which the inert -t makes false.
Windowing a cached 83KB VTT costs 52ms versus 16s against the original
27GB remux.

Routing on AllowWindow would have poisoned the cache: it is set only in
the PGS branch, so it is always false for text, while streamExtractArgs
applies -ss to any non-ASS/non-PGS source regardless. A seeked subrip
request would take the full-track path, emit seek->EOF, exit cleanly and
publish that partial as canonical -- and every later viewer from 0 would
lose all cues before it (118 of 143 production requests carry a non-zero
seek). Canonicality is now derived from the effective argv instead:
streamExtractPlanFor is the single source of truth for both the argv and
the partial() predicate, so only a seek=0/duration=0 extract can fill.

- key: adds a schema version and the resolved output profile (an ASS
  source is reachable as both .ass and .vtt, so format must be keyed)
- removeStaleSiblings now groups by profile, so committing .vtt no longer
  deletes a valid .ass sibling; cleanup and eviction no longer hardcode
  .sup, so text entries are reclaimed and counted
- InputIsExtractedTrack carries the cached input's format
- text hits use a plain copy with no-store, matching cold-path HTTP
  semantics; ServeContent stays on the PGS path only, since Media3 uses
  range-capable data sources and responses must not vary with cache warmth
- renames SUP-specific identifiers now that the cache carries text

The inert -t is deliberately retained and its comment corrected in place:
removing it or moving it after -i would bound the output and break the
native clients.

Latency-only: for every (source codec, requested format, seek) the bytes
a client receives are unchanged.

Rationale, measurements, four superseded revisions and the dead ends are
recorded in docs/superpowers/plans/2026-07-17-subtitle-extract-cache.md.

AI-use disclosure: investigated and implemented with AI assistance
(Claude Code + Codex gpt-5.6-sol); Codex's review caught the cache-
poisoning bug above.
2026-08-05 04:55:54 +00:00

616 lines
22 KiB
Go

package playback
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// SubtitleCache stores canonical full-track subtitle extracts on disk so
// repeat selections of the same embedded track don't re-run a whole-file
// ffmpeg demux (minutes for a large remux). Only extracts whose effective
// ffmpeg argv has no seek or duration are cached; partial requests may use a
// canonical cached artifact as their input but are never published themselves.
//
// Entries are keyed by schema version, source file path, subtitle stream
// ordinal, resolved output codec+muxer, and source mtime+size, all encoded in
// the cache filename. Invalidation is therefore implicit when any dimension
// changes. Entry recency for LRU is tracked by bumping the cache file's mtime
// on every hit (portable, unlike atime on noatime/relatime mounts).
//
// Concurrency: the first requester of an uncached track streams the extract
// progressively to its client while teeing bytes into a temp file that is
// atomically renamed into the cache on clean ffmpeg exit (and discarded on
// any error, so a partial entry is never served). Concurrent requesters for
// the same track and output profile while a fill is in flight simply run their
// own un-teed extract — no worse than today's behavior, and it avoids making a
// viewer's first-byte latency depend on another client's connection.
type SubtitleCache struct {
// transcodeDir returns the current transcode directory; the cache lives
// in a subtitle-cache subdirectory beneath it, created lazily. An empty
// return disables the cache for that call.
transcodeDir func() string
// maxBytes is the total-size eviction budget for committed entries.
maxBytes int64
mu sync.Mutex
inflight map[string]struct{}
// warmSem bounds concurrent background warms server-wide (each warm
// demuxes an entire source file — heavy sequential IO). Acquisition is
// non-blocking: warms beyond the budget are dropped, not queued; the
// next windowed miss for that track re-attempts the warm.
warmSem chan struct{}
}
const (
subtitleCacheDirName = "subtitle-cache"
// subtitleCacheSchemaVersion invalidates artifacts when extraction argv or
// artifact semantics change in a way source mtime+size cannot detect.
subtitleCacheSchemaVersion = 1
// defaultSubtitleCacheMaxBytes caps all cached subtitle formats at 2 GiB.
// Bitmap tracks dominate sizing at roughly 15-80 MB apiece; text tracks
// are much smaller.
// TODO: expose as a config knob following the download.artifact_max_bytes
// pattern (internal/config/config.go DownloadConfig.ArtifactMaxBytes).
defaultSubtitleCacheMaxBytes = 2 << 30
// stalePartMaxAge is how long an orphaned .part temp file (leftover from
// a crash mid-fill) survives before eviction sweeps remove it.
stalePartMaxAge = time.Hour
// subtitleCacheWarmSlots caps concurrent background warms server-wide.
// Two lets a second household stream warm while the first is still
// demuxing, without letting a burst of playbacks saturate disk IO.
subtitleCacheWarmSlots = 2
// subtitleCacheWarmTimeout bounds a single background warm. A full-file
// demux of a large remux on network storage can take minutes; anything
// beyond this is stuck and should release its slot.
subtitleCacheWarmTimeout = 30 * time.Minute
)
// ffmpeg muxer names for the subtitle formats an extract can produce (see
// StreamExtractOutput), plus the on-disk extension and the source codec the
// PGS-only entry points key on. Named because the extraction plan, the cache
// key, and the response Content-Type all have to agree on the same spellings.
const (
subtitleFormatWebVTT = "webvtt"
subtitleFormatASS = "ass"
subtitleFormatSUP = "sup"
// subtitleExtVTT is the file extension for a webvtt artifact; the ffmpeg
// muxer name and the conventional extension differ only for this format.
subtitleExtVTT = "vtt"
subtitleCodecPGS = "hdmv_pgs_subtitle"
subtitleTypeOctetStream = "application/octet-stream"
)
// SubtitleExtractFunc runs one ffmpeg subtitle extract described by opts, writing
// output to opts.Writer. Production callers pass StreamExtractSubtitle;
// tests substitute fakes. The cache invokes it with the caller's options
// rewritten as needed (tee writer for fills, cached-artifact input for partial
// serves, cleared window for background warms).
type SubtitleExtractFunc func(ctx context.Context, opts StreamExtractOpts) error
// NewSubtitleCache builds a cache rooted under the transcode directory
// returned by transcodeDir at call time (so runtime config changes are
// honored). Pass nil to disable caching entirely.
func NewSubtitleCache(transcodeDir func() string) *SubtitleCache {
return &SubtitleCache{
transcodeDir: transcodeDir,
maxBytes: defaultSubtitleCacheMaxBytes,
inflight: make(map[string]struct{}),
warmSem: make(chan struct{}, subtitleCacheWarmSlots),
}
}
// ServeExtract serves one embedded subtitle extract (opts.Writer is ignored;
// the cache supplies it). Canonical requests may fill the cache on a miss.
// Requests whose effective ffmpeg argv applies seek or duration are partial:
// they never fill, re-extract from a cached canonical artifact on a hit, and
// trigger a canonical background warm on a miss.
//
// PGS cache hits retain the existing http.ServeContent behavior. Text hits
// deliberately use a plain copy with no-store, matching cold-path HTTP
// semantics without adding Range, validators, or Content-Length based on cache
// warmth. A nil receiver disables caching and just streams.
//
// The caller sets any extra response headers (e.g. CORS) before calling.
// The returned error is the extract error; cache hits return nil.
func (c *SubtitleCache) ServeExtract(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SubtitleExtractFunc) error {
plan := streamExtractPlanFor(opts)
if plan.partial() {
return c.serveWindowed(w, r, opts, plan, extract)
}
if cached, modTime, ok := c.lookup(opts); ok {
defer func() { _ = cached.Close() }()
slog.DebugContext(r.Context(), "subtitle stream served from cache",
"input", opts.InputPath, "track", opts.TrackIndex)
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
if plan.outFormat == subtitleFormatSUP {
w.Header().Set("Cache-Control", "private, no-cache")
http.ServeContent(w, r, "", modTime, cached)
return nil
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return copyAndFlush(w, cached)
}
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
// BeginFill returns nil when another fill for this track is already in
// flight (or the cache dir is unusable); this request then streams its
// own uncached extract.
fill := c.beginFill(opts)
var writer io.Writer = w
if fill != nil {
writer = fill.Tee(w)
}
opts.Writer = writer
err := extract(r.Context(), opts)
if fill != nil {
if err != nil {
fill.Discard()
} else if commitErr := fill.Commit(); commitErr != nil {
slog.WarnContext(r.Context(), "subtitle cache commit failed",
"input", opts.InputPath, "track", opts.TrackIndex, "error", commitErr)
}
}
return err
}
// serveWindowed streams a partial request. Its effective argv is noncanonical,
// so its output is never cached itself, but the cache still speeds it up: with
// a committed full-track entry the extract's input is
// rewritten to the cached artifact (small enough that the -ss scan is fast
// versus re-demuxing a multi-GB source); without one, a background warm is
// started so later windows — the client re-fetches on every seek — hit the
// fast path.
func (c *SubtitleCache) serveWindowed(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, plan streamExtractPlan, extract SubtitleExtractFunc) error {
if cachedPath, _, ok := c.cachedEntryPath(opts); ok {
slog.DebugContext(r.Context(), "windowed subtitle extract using cached full track",
"input", opts.InputPath, "track", opts.TrackIndex, "cache_entry", cachedPath)
opts.InputPath = cachedPath
opts.ExtractedInputFormat = plan.outFormat
} else {
c.WarmInBackground(opts, extract)
}
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
opts.Writer = w
return extract(r.Context(), opts)
}
// WarmInBackground starts a detached full-track extract that fills the cache
// entry for opts' source+track+output profile, so future partial requests can
// extract from the small cached artifact instead of the original file. The
// warm runs on a background context with a generous timeout — it must survive
// the request that triggered it. BeginFill's in-flight coalescing guarantees at
// most one fill per output profile (a concurrent client-driven fill wins and the warm is
// skipped), and warmSem bounds warms server-wide: beyond the budget the warm
// is dropped, not queued — the next windowed miss re-attempts it. A nil
// receiver is a no-op.
func (c *SubtitleCache) WarmInBackground(opts StreamExtractOpts, extract SubtitleExtractFunc) {
if c == nil || extract == nil {
return
}
select {
case c.warmSem <- struct{}{}:
default:
slog.Debug("subtitle cache warm skipped: all warm slots busy",
"input", opts.InputPath, "track", opts.TrackIndex)
return
}
// Full-track options: the warm ignores the triggering request's window
// and writes only to the cache temp file (no response writer).
opts.SeekSeconds = 0
opts.DurationSeconds = 0
opts.AllowWindow = false
opts.ExtractedInputFormat = ""
fill := c.beginFill(opts)
if fill == nil {
// Another fill (client-driven or a previous warm) is already in
// flight, or the cache is unusable — either way, nothing to do.
<-c.warmSem
return
}
opts.Writer = fill.Tee(io.Discard)
go func() {
defer func() { <-c.warmSem }()
ctx, cancel := context.WithTimeout(context.Background(), subtitleCacheWarmTimeout)
defer cancel()
start := time.Now()
slog.Info("subtitle cache warm started",
"input", opts.InputPath, "track", opts.TrackIndex)
if err := extract(ctx, opts); err != nil {
fill.Discard()
slog.Warn("subtitle cache warm failed",
"input", opts.InputPath, "track", opts.TrackIndex,
"elapsed_ms", time.Since(start).Milliseconds(), "error", err)
return
}
if err := fill.Commit(); err != nil {
slog.Warn("subtitle cache warm commit failed",
"input", opts.InputPath, "track", opts.TrackIndex, "error", err)
return
}
slog.Info("subtitle cache warm finished",
"input", opts.InputPath, "track", opts.TrackIndex,
"elapsed_ms", time.Since(start).Milliseconds())
}()
}
// dir resolves the cache directory, or "" when caching is disabled.
func (c *SubtitleCache) dir() string {
if c == nil || c.transcodeDir == nil {
return ""
}
base := c.transcodeDir()
if base == "" {
return ""
}
return filepath.Join(base, subtitleCacheDirName)
}
type subtitleCacheProfile struct {
codec string
format string
}
func subtitleProfile(opts StreamExtractOpts) subtitleCacheProfile {
plan := streamExtractPlanFor(opts)
return subtitleCacheProfile{codec: plan.outCodec, format: plan.outFormat}
}
// subtitleCacheKeyPrefix identifies a source file + track ordinal + resolved
// output profile regardless of source version. Including the profile keeps,
// for example, ASS and WebVTT artifacts for the same source track independent.
func subtitleCacheKeyPrefix(inputPath string, trackIndex int, profile subtitleCacheProfile) string {
sum := sha256.Sum256([]byte(inputPath))
return fmt.Sprintf("v%d-%x-s%d-%s-%s-", subtitleCacheSchemaVersion, sum[:12], trackIndex, profile.codec, profile.format)
}
func subtitleCacheKey(inputPath string, trackIndex int, profile subtitleCacheProfile, mtime time.Time, size int64) string {
return fmt.Sprintf("%s%d-%d.%s", subtitleCacheKeyPrefix(inputPath, trackIndex, profile), mtime.UnixNano(), size, subtitleCacheExtension(profile.format))
}
func subtitleCacheExtension(format string) string {
if format == subtitleFormatWebVTT {
return subtitleExtVTT
}
return format
}
func subtitleContentType(format string) string {
switch format {
case subtitleFormatASS:
return "text/x-ssa; charset=utf-8"
case subtitleFormatWebVTT:
return "text/vtt; charset=utf-8"
default:
return subtitleTypeOctetStream
}
}
// Lookup opens the cached canonical PGS extract for the source and track.
// It is retained as a focused cache primitive; format-aware serving uses
// lookup with the caller's resolved extraction options.
//
// mtime or size mismatch means the entry (if any) is stale and reads as a
// miss. On a hit the returned modTime is the *source* file's mtime — stable
// across hits, suitable for Last-Modified — while the cache file's own mtime
// is bumped to record recency for LRU eviction. The caller owns closing the
// returned file.
func (c *SubtitleCache) Lookup(inputPath string, trackIndex int) (f *os.File, modTime time.Time, ok bool) {
return c.lookup(StreamExtractOpts{
InputPath: inputPath,
TrackIndex: trackIndex,
SourceCodec: subtitleCodecPGS,
})
}
func (c *SubtitleCache) lookup(opts StreamExtractOpts) (f *os.File, modTime time.Time, ok bool) {
path, modTime, ok := c.cachedEntryPath(opts)
if !ok {
return nil, time.Time{}, false
}
f, err := os.Open(path)
if err != nil {
return nil, time.Time{}, false
}
return f, modTime, true
}
// cachedEntryPath reports whether a committed entry exists for the given
// source+track and returns its path plus the source file's mtime. Like
// Lookup it stats the source on every call (a changed source reads as a
// miss) and bumps the entry's mtime to record recency for LRU eviction.
// Callers that hand the path to an external reader (ffmpeg) rather than
// opening it themselves use this instead of Lookup.
func (c *SubtitleCache) cachedEntryPath(opts StreamExtractOpts) (path string, srcModTime time.Time, ok bool) {
dir := c.dir()
if dir == "" {
return "", time.Time{}, false
}
src, err := os.Stat(opts.InputPath)
if err != nil {
return "", time.Time{}, false
}
path = filepath.Join(dir, subtitleCacheKey(opts.InputPath, opts.TrackIndex, subtitleProfile(opts), src.ModTime(), src.Size()))
if _, err := os.Stat(path); err != nil {
return "", time.Time{}, false
}
// Recency bump for LRU. Best-effort: a failure (e.g. read-only remount)
// only degrades eviction ordering, not correctness.
now := time.Now()
if err := os.Chtimes(path, now, now); err != nil {
slog.Debug("subtitle cache recency bump failed", "path", path, "error", err)
}
return path, src.ModTime(), true
}
// SubtitleCacheFill is an in-progress cache population for one track. Bytes
// are written to a temp file via the writer returned by Tee; Commit renames
// it into place atomically, Discard throws it away. Exactly one of Commit or
// Discard must be called.
type SubtitleCacheFill struct {
c *SubtitleCache
key string
inputPath string
trackIndex int
profile subtitleCacheProfile
srcMtime time.Time
srcSize int64
tmp *os.File
// failed flips when a temp-file write errors (e.g. disk full); the tee
// keeps serving the client and Commit refuses to publish the entry.
failed bool
}
// BeginFill reserves the in-flight slot for the given PGS track and creates the
// temp file the tee will write into. Returns nil — meaning "stream without
// caching" — when caching is disabled, the source can't be stat'ed, the
// cache directory can't be created, or another fill for the same track is
// already in flight.
func (c *SubtitleCache) BeginFill(inputPath string, trackIndex int) *SubtitleCacheFill {
return c.beginFill(StreamExtractOpts{
InputPath: inputPath,
TrackIndex: trackIndex,
SourceCodec: subtitleCodecPGS,
})
}
func (c *SubtitleCache) beginFill(opts StreamExtractOpts) *SubtitleCacheFill {
dir := c.dir()
if dir == "" {
return nil
}
src, err := os.Stat(opts.InputPath)
if err != nil {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Warn("subtitle cache dir create failed", "dir", dir, "error", err)
return nil
}
profile := subtitleProfile(opts)
key := subtitleCacheKey(opts.InputPath, opts.TrackIndex, profile, src.ModTime(), src.Size())
c.mu.Lock()
if _, busy := c.inflight[key]; busy {
c.mu.Unlock()
return nil
}
c.inflight[key] = struct{}{}
c.mu.Unlock()
tmp, err := os.CreateTemp(dir, key+".part-*")
if err != nil {
c.release(key)
slog.Warn("subtitle cache temp create failed", "dir", dir, "error", err)
return nil
}
return &SubtitleCacheFill{
c: c,
key: key,
inputPath: opts.InputPath,
trackIndex: opts.TrackIndex,
profile: profile,
srcMtime: src.ModTime(),
srcSize: src.Size(),
tmp: tmp,
}
}
func (c *SubtitleCache) release(key string) {
c.mu.Lock()
delete(c.inflight, key)
c.mu.Unlock()
}
// Tee wraps the response writer so every chunk also lands in the fill's temp
// file. The returned writer implements http.Flusher (delegating to w when w
// does), so copyAndFlush keeps flushing cues to the client in real time. A
// temp-file write failure never fails the response — the fill is marked
// failed and the client keeps streaming.
func (f *SubtitleCacheFill) Tee(w io.Writer) io.Writer {
flusher, _ := w.(http.Flusher)
return &subtitleTeeWriter{w: w, flusher: flusher, fill: f}
}
type subtitleTeeWriter struct {
w io.Writer
flusher http.Flusher
fill *SubtitleCacheFill
}
func (t *subtitleTeeWriter) Write(p []byte) (int, error) {
if !t.fill.failed {
if _, err := t.fill.tmp.Write(p); err != nil {
t.fill.failed = true
slog.Warn("subtitle cache tee write failed; continuing uncached",
"track", t.fill.trackIndex, "error", err)
}
}
return t.w.Write(p)
}
func (t *subtitleTeeWriter) Flush() {
if t.flusher != nil {
t.flusher.Flush()
}
}
// Commit publishes the temp file as the cache entry: fsync, atomic rename,
// stale-sibling cleanup, then size-cap eviction. It refuses to publish (and
// discards instead) when a tee write failed or when the source file changed
// while the extract ran — a partial or mismatched entry must never be served.
func (f *SubtitleCacheFill) Commit() error {
if f.failed {
f.Discard()
return errors.New("subtitle cache fill had write errors; discarded")
}
if src, err := os.Stat(f.inputPath); err != nil ||
!src.ModTime().Equal(f.srcMtime) || src.Size() != f.srcSize {
f.Discard()
return errors.New("source file changed during extract; cache fill discarded")
}
defer f.c.release(f.key)
tmpPath := f.tmp.Name()
if err := f.tmp.Sync(); err != nil {
f.closeAndRemoveTmp()
return fmt.Errorf("sync subtitle cache temp: %w", err)
}
if err := f.tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("close subtitle cache temp: %w", err)
}
dir := filepath.Dir(tmpPath)
final := filepath.Join(dir, f.key)
if err := os.Rename(tmpPath, final); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("publish subtitle cache entry: %w", err)
}
f.c.removeStaleSiblings(dir, f.inputPath, f.trackIndex, f.profile, f.key)
f.c.evict(dir)
return nil
}
// Discard abandons the fill: the temp file is removed and the in-flight slot
// released. Safe to call after a failed Commit (idempotent enough — the temp
// file is already gone and re-removal is a no-op).
func (f *SubtitleCacheFill) Discard() {
f.closeAndRemoveTmp()
f.c.release(f.key)
}
func (f *SubtitleCacheFill) closeAndRemoveTmp() {
_ = f.tmp.Close()
if err := os.Remove(f.tmp.Name()); err != nil && !os.IsNotExist(err) {
slog.Warn("subtitle cache temp remove failed", "path", f.tmp.Name(), "error", err)
}
}
// removeStaleSiblings deletes committed entries for the same
// source+track+output profile with a different mtime/size suffix. Other output
// profiles remain valid siblings and must coexist.
func (c *SubtitleCache) removeStaleSiblings(dir, inputPath string, trackIndex int, profile subtitleCacheProfile, keepKey string) {
prefix := subtitleCacheKeyPrefix(inputPath, trackIndex, profile)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if name == keepKey || !strings.HasPrefix(name, prefix) || strings.Contains(name, ".part-") {
continue
}
if err := os.Remove(filepath.Join(dir, name)); err != nil && !os.IsNotExist(err) {
slog.Warn("subtitle cache stale entry remove failed", "name", name, "error", err)
}
}
}
// evict is the scan-on-write LRU pass: when committed entries exceed the
// byte budget, the oldest-mtime entries are removed until the total fits.
// It also sweeps orphaned .part temp files older than stalePartMaxAge
// (crash leftovers). No background daemon — commits are rare enough that a
// directory scan per commit is cheap.
func (c *SubtitleCache) evict(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type cacheEnt struct {
path string
size int64
mtime time.Time
}
var (
ents []cacheEnt
total int64
)
now := time.Now()
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
path := filepath.Join(dir, e.Name())
if strings.Contains(e.Name(), ".part-") {
if now.Sub(info.ModTime()) > stalePartMaxAge {
_ = os.Remove(path)
}
continue
}
if !isCommittedSubtitleCacheEntry(e.Name()) {
continue
}
ents = append(ents, cacheEnt{path: path, size: info.Size(), mtime: info.ModTime()})
total += info.Size()
}
if total <= c.maxBytes {
return
}
sort.Slice(ents, func(i, j int) bool { return ents[i].mtime.Before(ents[j].mtime) })
for _, e := range ents {
if total <= c.maxBytes {
break
}
if err := os.Remove(e.path); err != nil {
if !os.IsNotExist(err) {
slog.Warn("subtitle cache eviction remove failed", "path", e.path, "error", err)
}
continue
}
slog.Info("evicted cached subtitle track (LRU)", "path", e.path, "bytes", e.size)
total -= e.size
}
}
func isCommittedSubtitleCacheEntry(name string) bool {
if strings.Contains(name, ".part-") {
return false
}
return strings.HasSuffix(name, ".sup") || strings.HasSuffix(name, ".ass") || strings.HasSuffix(name, ".vtt")
}