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:
@@ -0,0 +1,44 @@
|
||||
"""The subtitle routes must refuse a session the rest of the app refuses.
|
||||
|
||||
They used to sit behind `_require_session`, the cookie-only gate the poster proxies use — a signed
|
||||
cookie was enough, with none of `current_user`'s checks (is_active, is_suspended, the session epoch
|
||||
that "log out everywhere" bumps). Fine for a poster; not for a route that starts reads of the media
|
||||
library. Found by the 2026-09 bug-hunt deep-review; the gate itself pre-dates that round.
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.auth as auth
|
||||
import app.state as app_state
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.plex import subtitles
|
||||
from app.routes.plex import playback
|
||||
|
||||
|
||||
class _NoPool:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: list = []
|
||||
|
||||
def submit(self, fn, *args) -> None:
|
||||
self.jobs.append((fn, args))
|
||||
|
||||
|
||||
def test_a_suspended_account_cannot_drive_the_subtitle_extractor(
|
||||
db, make_user, override_dep, monkeypatch, tmp_path
|
||||
):
|
||||
user = make_user(is_suspended=True)
|
||||
override_dep(get_db, lambda: db)
|
||||
monkeypatch.setattr(app_state, "_configured_cache", True)
|
||||
# A validly SIGNED cookie for that account — which is all the old gate looked at.
|
||||
for mod in (auth, playback):
|
||||
monkeypatch.setattr(mod, "resolved_user_id", lambda request: (user.id, True))
|
||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||
pool = _NoPool()
|
||||
monkeypatch.setattr(subtitles, "_POOL", pool)
|
||||
subtitles.reset_state()
|
||||
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/plex/subtitle/98614/0/status?at=600").status_code == 401
|
||||
assert client.get("/api/plex/subtitle/98614/0").status_code == 401
|
||||
assert pool.jobs == [], "a refused session still scheduled an extraction"
|
||||
Reference in New Issue
Block a user