fix(plex): subtitle windows land where the full pass does, and the extractor stops re-reading, respawning and outliving the process

Deep-review round 2 of the 2026-09 bug hunt, over the round-1 fix diff.

- A window was LATE by the distance to the keyframe its input seek landed on, and then doubled for
  good once the full pass merged in: a cue already running at the window's start came out at a
  negative time, and the webvtt muxer's default shifted the whole output so it sat at 0. Measured on
  the real Westworld S01E06 at 25:00: every window cue +5.005 s, 682 cues merged instead of 608.
  `-avoid_negative_ts disabled` keeps the times exact (the parser reads ffmpeg's signed-field
  spelling of a negative time); now 608/608 on that file, and a real-ffmpeg test pins it.
- A full pass that failed was re-armed every minute while the viewer watched what it did publish —
  a whole-container read per minute. It now backs off for an hour; windows keep running meanwhile.
- A failed WINDOW set the track's failure memo, so any uncovered position read "failed" — a final
  answer that ends the player's poll, even with the full pass about to deliver, and one any account
  could plant with an absurd `at`. It now marks only the span it failed to read; "failed" is given
  once the full pass AND a window there have both failed.
- Windows join by what they COVER, not a bucketed identity: a backward seek no longer waits behind a
  window that cannot cover it, and a playhead crossing a bucket edge no longer queues a second read.
  The queue is bounded per track and overall.
- shutdown() sets a flag a worker checks before it spawns ffmpeg (one still in its Plex lookup used to
  start a read after the kill sweep); queued-job cancellation and the lifespan call are now tested.
- A window starts where ffmpeg is told to seek (rounded to the ms it gets), a rounded-up millisecond
  carries into the minute (no more `00:00:60.000`), a key's first publish claims nothing before its
  VTT lands, and `at`/`offset`/`start`/`aoff` refuse non-finite values (`at` also an absurd one).
This commit is contained in:
2026-09-11 03:18:08 +02:00
parent d2e225666b
commit 473d9f02f2
3 changed files with 454 additions and 53 deletions
+17 -6
View File
@@ -15,7 +15,7 @@ from fastapi import (
Response,
)
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -101,8 +101,9 @@ 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),
# Bounded both ways: a non-finite position used to reach the scheduler's arithmetic and 500, and
# an absurd one reached ffmpeg's `-ss`, which refused it.
at: float = Query(0.0, ge=0.0, le=subtitles.MAX_POSITION_S, 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
@@ -115,7 +116,12 @@ def subtitle_status(
@router.get("/subtitle/{rating_key}/{sub_ord}")
def subtitle(
rating_key: str, sub_ord: int, user: User = Depends(current_user), offset: float = 0.0
rating_key: str,
sub_ord: int,
user: User = Depends(current_user),
# Finite, like every float this router takes: `-inf` crashed the shift (a 500), `nan` stacked
# every cue at 0. It can legitimately be negative (a subtitle nudged later than the session start).
offset: float = Query(0.0, allow_inf_nan=False),
) -> 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
@@ -281,6 +287,10 @@ def _push_watch(
class ItemProgressIn(BaseModel):
# Finite only: JSON parsing here accepts `Infinity`/`NaN`, and the handler's `int()` turned
# one into a 500.
model_config = ConfigDict(allow_inf_nan=False)
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
position_seconds: float = 0
@@ -528,9 +538,10 @@ def season_state(
@router.post("/stream/{rating_key}/session")
def stream_session(
rating_key: str,
start: float = 0.0,
# Finite: `start=inf` reached `int()` in the session builder and came back as a 500.
start: float = Query(0.0, allow_inf_nan=False),
audio: int | None = None,
aoff: float = 0.0,
aoff: float = Query(0.0, allow_inf_nan=False),
multi: bool = False,
user: User = Depends(current_user),
db: Session = Depends(get_db),