Skip to content

perf(playback): cache embedded text-subtitle extracts - #542

Open
CoffeeKnyte wants to merge 1 commit into
mainfrom
fix/subtitle-vtt-windowing
Open

perf(playback): cache embedded text-subtitle extracts#542
CoffeeKnyte wants to merge 1 commit into
mainfrom
fix/subtitle-vtt-windowing

Conversation

@CoffeeKnyte

@CoffeeKnyte CoffeeKnyte commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Closes #423. Part of #296.

Problem

Turning on a subtitle mid-film could hang for two minutes, and the server never noticed.
On the web player, picking an embedded subtitle track sometimes did nothing for a very long
time before cues appeared — or before subtitles simply never showed up at all. Over a 41.6
hour window the subtitle endpoint measured p95 120,021 ms and max 121,470 ms, with 45
of 220 fetches taking over ten seconds.

The 120-second number is not a coincidence: it is the server's own hard write deadline. The
response was being cut off mid-flight. Because the server had already told the client
"200 OK" before the slow part began, every one of those cut-off responses was recorded in
the logs as a success
. Operators looking at error rates saw nothing wrong, while viewers
were watching a film with no subtitles.

The underlying cost is that a subtitle track is not stored in one piece. Its text is
scattered through the whole file, interleaved between video and audio. Pulling out a few
kilobytes of dialogue means reading across that entire stretch of a multi-gigabyte file on
network storage.

The safety limit that was supposed to bound this does nothing. The server passes a
600-second window to ffmpeg, and has since the endpoint was written. It is silently ignored
for these extracts: every request reads from its start point all the way to the end of the
film, no matter what the window says. So the cost of a request tracks how much film is left,
and nobody had noticed because the code reads as though it were bounded.

Solution

perf(playback): cache embedded text-subtitle extracts

Measured against the production binary, -ss 4000 -t 30 and -ss 4000 -t 300 produce
byte-identical output to passing no -t at all (last cue 02:10:04, end of film). -t is
passed as an input option and ffmpeg discards it for subtitle extraction; only -ss
survives. Cost is therefore (duration - seek) × bitrate.

The window cannot simply be repaired. silo-apple and silo-android both fetch a track
once and depend on receiving the whole thing. Actually bounding the output would silently
kill subtitles roughly ten minutes into every film on both platforms. The accidental
whole-track behaviour is the de-facto client contract, so this keeps whole-track delivery
and makes it cheap instead.

SubtitleCache already had the right shape from the PGS work; text was excluded only by the
assumption that "VTT is already windowed and fast", which the inert -t makes false.
Windowing a cached 83 KB VTT costs 52 ms against 16 s for the same window over the original
27 GB remux.

Canonicality is derived from the effective ffmpeg argv, not from a flag.
streamExtractPlanFor (internal/playback/subtitle_stream.go) is the single source of
truth for both the argv and the partial() predicate, so only a seek=0/duration=0
extract can fill the cache. Routing on AllowWindow instead would have poisoned it:
AllowWindow 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 have taken the full-track path, emitted seek→EOF, exited cleanly, and
published that partial as canonical — and every later viewer starting from 0 would lose all
cues before that point. 118 of 143 production requests carry a non-zero seek.

Supporting changes in internal/playback/subtitle_cache.go:

  • Cache key gains a schema version and the resolved output profile — an ASS source is
    reachable as both .ass and .vtt, so format has to be part of the key.
  • removeStaleSiblings groups by profile, so committing a .vtt no longer deletes a valid
    .ass sibling. Cleanup and eviction no longer hardcode .sup, so text entries are
    actually reclaimed and counted against the budget.
  • InputIsExtractedTrack carries the cached input's format.
  • Text hits use a plain copy with no-store, matching cold-path HTTP semantics.
    http.ServeContent stays on the PGS path only: Media3 uses range-capable data sources,
    and responses must not vary with cache warmth.
  • SUP-specific identifiers renamed now that the cache carries text as well.

The inert -t is deliberately retained and its comment corrected in place
(internal/playback/subtitle_stream.go:152) — removing it, or moving it after -i, would
bound the output and break the native clients.

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

Risk / follow-ups

  • The 600s window stays inert by design. Anyone "fixing" that line in future breaks
    silo-apple and silo-android; the comment now says so, but it is still a trap.
  • Cache budget is the existing 2 GiB shared with PGS entries, still a hardcoded
    defaultSubtitleCacheMaxBytes. Text entries are small (tens of KB) so they will not
    meaningfully displace bitmap entries, but exposing this as a config knob following the
    download.artifact_max_bytes pattern is still outstanding.
  • Concurrent requesters for the same track while a fill is in flight run their own un-teed
    extract rather than waiting. That is no worse than current behaviour and avoids making one
    viewer's first-byte latency depend on another client's connection, but it does mean a
    cold-start burst can duplicate work.
  • The status=200-on-truncation observability gap is not fixed here; this removes the
    latency that was triggering it. Logging truncated bodies distinctly is separate work.
  • Does not touch the jellycompat subtitle endpoint's whole-file buffering, which is the
    other half of Subtitle enable mid-playback is slow: on-demand ffmpeg extraction with no caching; compat endpoint buffers whole file #296.

Verification

  • go build ./... — clean.
  • go vet ./internal/playback/... ./internal/api/handlers/... ./internal/proxy/... — clean.
  • go test ./internal/playback/... — pass. subtitle_cache_test.go gains coverage for
    profile-keyed entries, sibling retention across formats, partial requests never filling,
    and text eviction accounting.
  • go test ./internal/api/handlers/... ./internal/proxy/... — pass.
  • golangci-lint run --new-from-merge-base=origin/main over the touched packages — 0 issues.
  • gofmt -l internal/ — clean. make verify-local-paths — clean.
  • ffmpeg argv behaviour confirmed empirically against the production binary: -t variants
    byte-identical, documented in the plan doc.
  • Rebased onto origin/main (8bde6f1); the three touched Go files have had no commits on
    main since 2026-07-15, so the rebase carried no conflicts.

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 AllowWindow cache-poisoning bug described above. All measurements were
taken against the real production binary and production request logs.

Summary by CodeRabbit

  • New Features

    • Added subtitle extraction caching for WebVTT, ASS, and SUP formats.
    • Partial subtitle requests can reuse cached full-track results, improving response times.
    • Subtitle cache entries now preserve distinct output formats and serve the appropriate content type.
  • Bug Fixes

    • Prevented seeked or failed extractions from creating invalid canonical cache entries.
    • Preserved existing subtitle streaming and HTTP behavior, including compatibility with full-track extraction.

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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Embedded subtitle extraction now uses shared output planning and a format-aware cache. SUP, ASS, and WebVTT artifacts use profile-specific keys, content types, canonical full-track caching, and generalized serving behavior. Stream and proxy handlers use the unified ServeExtract API.

Changes

Subtitle extraction cache

Layer / File(s) Summary
Shared extraction planning
internal/playback/subtitle_stream.go, internal/playback/subtitle_stream_test.go, internal/api/handlers/stream.go
Extraction derives codec, format, seek, duration, and cached-input handling from a shared plan. StreamExtractOutput is exported.
Format-aware cache serving
internal/playback/subtitle_cache.go, internal/playback/subtitle_cache_test.go
The cache supports SUP, ASS, and WebVTT profiles with distinct keys, extensions, content types, eviction, canonical artifacts, partial requests, and background warming.
Extraction API integration and plan
internal/api/handlers/stream.go, internal/proxy/server.go, docs/superpowers/plans/2026-07-17-subtitle-extract-cache.md
Embedded subtitle handlers use ServeExtract. The plan documents the cache design, compatibility behavior, risks, and verification requirements.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels: v1

Suggested reviewers: quick104

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant StreamHandler
  participant SubtitleCache
  participant ffmpeg
  Client->>StreamHandler: Request embedded subtitle
  StreamHandler->>SubtitleCache: ServeExtract
  SubtitleCache->>SubtitleCache: Lookup output profile
  SubtitleCache->>ffmpeg: Extract missing artifact
  ffmpeg-->>SubtitleCache: Return subtitle artifact
  SubtitleCache-->>StreamHandler: Serve cached or extracted output
  StreamHandler-->>Client: Return subtitle response
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies caching embedded text-subtitle extracts, which is the primary change in the pull request.
Linked Issues check ✅ Passed The changes implement caching for embedded text subtitles, preserve whole-track delivery, and prevent partial extracts from becoming canonical cache entries as required by issue #423.
Out of Scope Changes check ✅ Passed The code, tests, proxy updates, and implementation plan all support the caching and compatibility objectives described in issue #423.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/subtitle-vtt-windowing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added the v1 Silo v1 scope - auto-adds to the Silo v1 project label Aug 5, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/superpowers/plans/2026-07-17-subtitle-extract-cache.md`:
- Around line 51-54: Update the documentation around streamExtractArgs to use
repository-relative references for files such as subtitle_stream.go, and remove
the host/container absolute path to ffmpeg. Describe the measured binary as the
container-installed production ffmpeg without including a local filesystem path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3deb63f9-908a-400a-aa8b-09a9326767eb

📥 Commits

Reviewing files that changed from the base of the PR and between 8bde6f1 and 2312c0f.

📒 Files selected for processing (7)
  • docs/superpowers/plans/2026-07-17-subtitle-extract-cache.md
  • internal/api/handlers/stream.go
  • internal/playback/subtitle_cache.go
  • internal/playback/subtitle_cache_test.go
  • internal/playback/subtitle_stream.go
  • internal/playback/subtitle_stream_test.go
  • internal/proxy/server.go

Comment on lines +51 to +54
`streamExtractArgs` (`subtitle_stream.go:161-168`) passes `-t` as an **input**
option, reasoning it "caps how much of the file we read". Measured against the
production binary (`ffmpeg 7.1.4-Jellyfin` at `/usr/lib/jellyfin-ffmpeg/ffmpeg`
inside the `silo` container — not on `$PATH`; there is no host ffmpeg):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use repository-relative references.

Replace basename-only file references with repository-relative paths. Remove /usr/lib/jellyfin-ffmpeg/ffmpeg and describe the container-installed binary without a local absolute path.

Proposed correction
-`streamExtractArgs` (`subtitle_stream.go:161-168`) passes `-t` as an **input**
+`streamExtractArgs` (`internal/playback/subtitle_stream.go`) passes `-t` as an **input**
 ...
-production binary (`ffmpeg 7.1.4-Jellyfin` at `/usr/lib/jellyfin-ffmpeg/ffmpeg`
-inside the `silo` container — not on `$PATH`; there is no host ffmpeg):
+production binary (`ffmpeg 7.1.4-Jellyfin` in the `silo` container's Jellyfin
+installation — not on `$PATH`; there is no host ffmpeg):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`streamExtractArgs` (`subtitle_stream.go:161-168`) passes `-t` as an **input**
option, reasoning it "caps how much of the file we read". Measured against the
production binary (`ffmpeg 7.1.4-Jellyfin` at `/usr/lib/jellyfin-ffmpeg/ffmpeg`
inside the `silo` container — not on `$PATH`; there is no host ffmpeg):
`streamExtractArgs` (`internal/playback/subtitle_stream.go`) passes `-t` as an **input**
option, reasoning it "caps how much of the file we read". Measured against the
production binary (`ffmpeg 7.1.4-Jellyfin` in the `silo` container's Jellyfin
installation — not on `$PATH`; there is no host ffmpeg):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/plans/2026-07-17-subtitle-extract-cache.md` around lines 51
- 54, Update the documentation around streamExtractArgs to use
repository-relative references for files such as subtitle_stream.go, and remove
the host/container absolute path to ffmpeg. Describe the measured binary as the
container-installed production ffmpeg without including a local filesystem path.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v1 Silo v1 scope - auto-adds to the Silo v1 project

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Embedded subtitle fetches stall up to 120s: the 600s extraction window is inert and truncations log as status=200

1 participant