fix(plex): the subtitle extractor's seams — doubled cues, a truncated track cached as whole, a poll that respawned ffmpeg

Deep-review of the bug-hunt round (R1). The background extractor from ed4ab4e1 held, but its seams
did not:

- Window + full pass doubled every cue in the first hour. merge_vtt de-duplicated on the raw timing
  text, and the two passes spell a timestamp differently (shift_vtt writes HH:MM:SS.mmm, ffmpeg's
  webvtt muxer drops the hour below 1 h; shift_vtt(-0.0) never normalised the full pass). Timing is
  now parsed and re-emitted canonically, keyed with the cue text so two cues sharing a time survive.
- A full pass that exited non-zero was published as complete: "ready" forever, subtitles ending
  where ffmpeg died, and no eviction to ever retry. It now covers only up to its last cue, is never
  complete, and records the failure memo.
- The window's dedup identity held the live playhead, so every 1 s poll queued another read of the
  file. It is keyed on the track now, and a queued job re-checks coverage before reading anything.
- A sidecar track waited behind an unrelated embedded full pass (the full job is per item). Its
  window job now fetches it whole — one small request.
- The <track> fetch ran the scheduler at position 0: an unrequested window over the head of the file,
  and a refusal of a window that was on disk. It is a pure read of the cache now.
- publish() is serialised (a lost update became permanent once complete was set); the pool is shut
  down and running ffmpeg children killed at app shutdown (they held interpreter exit for minutes);
  ?at=inf is a 422, not a 500; and both subtitle routes use current_user instead of the cookie-only
  poster gate, which let a suspended or epoch-revoked session keep scheduling reads (pre-existing).

_extract_and_publish finally has tests of its own — every earlier test stubbed it out. 19 mutations,
each red on the assertion that names it.
This commit is contained in:
2026-09-11 00:52:59 +02:00
parent f76a2afbf1
commit cf7ec5e9ec
6 changed files with 508 additions and 71 deletions
+25 -12
View File
@@ -10,6 +10,7 @@ from fastapi import (
BackgroundTasks,
Depends,
HTTPException,
Query,
Request,
Response,
)
@@ -87,40 +88,52 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
return imgcache.response(data, ct)
# Both subtitle routes authenticate with the full `current_user`, not the cookie-only
# `_require_session` the image proxies use. That lighter gate exists for bursts of low-sensitivity
# poster fetches and skips the is_active / is_suspended / session-epoch checks — acceptable for a
# poster, not for a route that starts reads of the media library: a suspended or "log out
# everywhere"-revoked cookie kept scheduling them. They are not bursty (one poll a second, one fetch
# per track), so the DB user-load costs nothing that matters.
@router.get("/subtitle/{rating_key}/{sub_ord}/status")
def subtitle_status(rating_key: str, sub_ord: int, request: Request, at: float = 0.0) -> dict:
def subtitle_status(
rating_key: str,
sub_ord: int,
user: User = Depends(current_user),
# Bounded: a non-finite position used to reach the scheduler's arithmetic and 500.
at: float = Query(0.0, ge=0.0, allow_inf_nan=False),
) -> dict:
"""Is this subtitle track showable yet — and if not, get it moving. Extraction runs in the
background (see plex/subtitles.py for the measurements that forced it there), so the player polls
this while it waits instead of hanging on a <track> that will not load for a minute or more.
`at` is the viewer's absolute position: the extractor windows around it, so what gets read first
is the part actually about to be watched."""
_require_session(request)
return subtitles.ensure(rating_key, sub_ord, at)
@router.get("/subtitle/{rating_key}/{sub_ord}")
def subtitle(
rating_key: str, sub_ord: int, request: Request, offset: float = 0.0, at: float = 0.0
rating_key: str, sub_ord: int, user: User = Depends(current_user), offset: float = 0.0
) -> Response:
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) → 415. Disk-cached.
NEVER extracts inline. A cold track answers 202 and the extraction proceeds in the background; the
player mounts the <track> only once /status says there is something to show, so in practice this
is only ever hit warm (or on a cache eviction race, which the 202 sends back to polling)."""
_require_session(request)
A pure READ: it serves whatever is cached and schedules nothing — the status poll owns that,
because only it knows where the viewer is (see `subtitles.cached_track`). The player mounts the
<track> only once /status says there is something to show, so in practice this is only ever hit
warm; on a cache eviction race the 202 sends the player back to polling."""
# The cached VTT holds ABSOLUTE cue times (per rk+ord); the per-session cue-shift is applied on
# the way out, so a resume/seek to any offset reuses the same track (no cache-per-offset explosion).
st = subtitles.ensure(rating_key, sub_ord, at)
if st["state"] == "failed":
hit, code = subtitles.cached_track(rating_key, sub_ord)
if hit is None and code:
raise HTTPException(
status_code=subtitles.UNAVAILABLE_STATUS.get(st["code"], 422),
detail={"code": "subtitle", "reason": st["code"]},
status_code=subtitles.UNAVAILABLE_STATUS.get(code, 422),
detail={"code": "subtitle", "reason": code},
)
hit = subtitles.cache_read(subtitles.cache_key(rating_key, sub_ord))
if hit is None:
return Response(
status_code=202, media_type="application/json", content='{"state":"preparing"}'