"""Plex playback: image/subtitle proxies, HLS streaming, and watch-state writes.""" import threading from typing import TYPE_CHECKING from datetime import datetime, timedelta, timezone import httpx from fastapi import ( APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request, Response, ) from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.orm import Session from app.auth import current_user, session_user_id from app.db import SessionLocal, get_db from app.models import User from app.plex import imgcache, queries, service, subtitles from app.plex import paths as plex_paths from app.plex import people as plex_people from app.plex import stream as plex_stream from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.routes.plex._shared import item_or_404 from app.refusal import refused from app.routes._common import ProgressIn, not_found if TYPE_CHECKING: from app.plex.models import PlexItem router = APIRouter(prefix="/api/plex", tags=["plex"]) def _require_session(request: Request) -> int: """Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are low-sensitivity; a valid session is enough.""" uid = session_user_id(request) if not uid: raise refused(401, "not_authenticated") return uid @router.get("/image/{rating_key}") def image(rating_key: str, request: Request, variant: str = "thumb") -> Response: """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A thin on-disk cache serves repeat views without re-hitting Plex. ⚠️ This endpoint is called in BURSTS (a collections page fires 100+ concurrent poster requests), so it must NOT hold a DB connection during the slow Plex fetch — doing so exhausted the pool (QueuePool timeout, broken images on prod). Warm path (cache hit) touches NO DB; cold path opens a brief session only to resolve the image key + read the Plex config, then RELEASES it before the fetch. Auth is the signed-session check (no DB user-load).""" _require_session(request) v = variant if variant in imgcache.SIZES else "thumb" w, h = imgcache.SIZES[v] # Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache # the small variant. The width tag also means changing a target size just re-caches, never serves a # stale differently-sized file (old full-size files under the un-tagged key are simply orphaned). cache_key = f"item_{rating_key}_{v}_{w}" hit = imgcache.read(cache_key) if hit: return imgcache.response(*hit) # Cold miss: short-lived session for the key + Plex client config, released before the slow fetch. try: with SessionLocal() as db: key = imgcache.key_for(db, str(rating_key), imgcache.picture(v)) if not key: raise not_found("image") plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB except PlexNotConfigured: raise refused(400, "plex_not_configured") from None try: with plex: data, ct = plex.image_bytes(key, w, h) # silent-ok: a byte proxy for an ; the browser shows the placeholder and no reader sees the # detail — the 502 is the whole answer, as for the person-image proxy below. except PlexError: raise refused(502, "image_fetch_failed") from None imgcache.write(cache_key, data, ct) 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. # # Both path parameters are bounded, the same way on both routes: they become a cache FILE name # (`sub__`), and an absurd one — a 250-digit key or ordinal — outgrew the OS's name # limit and turned the status poll into a 500. A Plex rating_key is a short number; the bound only # has to be generous. _RatingKey = Path(max_length=32) _SubOrd = Path(ge=0, le=9999) @router.get("/subtitle/{rating_key}/{sub_ord}/status") def subtitle_status( rating_key: str = _RatingKey, sub_ord: int = _SubOrd, user: User = Depends(current_user), # 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 this while it waits instead of hanging on a 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.""" return subtitles.ensure(rating_key, sub_ord, at, viewer=user.id) @router.get("/subtitle/{rating_key}/{sub_ord}") def subtitle( rating_key: str = _RatingKey, sub_ord: int = _SubOrd, 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 — 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. 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 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). hit, code = subtitles.cached_track(rating_key, sub_ord) if hit is None and code: raise HTTPException( status_code=subtitles.UNAVAILABLE_STATUS.get(code, 422), detail={"code": "subtitle", "reason": code}, ) if hit is None: return Response( status_code=202, media_type="application/json", content='{"state":"preparing"}' ) return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt") # Resume/watched thresholds. _PROGRESS_MIN_S matches PROGRESS_MIN_SECONDS (5), but _FINISH_MARGIN_S is # DELIBERATELY larger than FINISH_MARGIN_SECONDS (30 vs 10): this is long-form content # (movies/episodes with credits) and crossing it MARKS THE ITEM WATCHED, whereas the feed's and the # library's margin only clears a resume bar — different domains, intentional divergence, not drift. # Keep them separate on purpose (see routes/_common.py). _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 # Write-back throttle: the resume position is now pushed to Plex on the periodic (non-final) # checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays # current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is # rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint # (pause / seek / stop) always pushes immediately and resets the window. This is an in-memory, single- # process rate-limiter (the deployment is one FastAPI process): after a restart the map is empty, so at # most one extra push happens on the first checkpoint — harmless. It is NOT durable domain state. _PUSH_THROTTLE_S = 10 _last_push_at: dict[tuple[int, int], datetime] = {} _push_throttle_lock = threading.Lock() def _should_push_resume(user_id: int, item_id: int, now: datetime, final: bool) -> bool: """Whether to mirror this resume checkpoint to Plex now. `final` always pushes; a periodic tick pushes only when ≥_PUSH_THROTTLE_S has elapsed since the last push for this (user, item). Records `now` as the last-push time whenever it returns True so the window is measured from the real push (and so `_resume_push_superseded` can drop an older, still-pending push — see there).""" with _push_throttle_lock: last = _last_push_at.get((user_id, item_id)) if final or last is None or (now - last).total_seconds() >= _PUSH_THROTTLE_S: _last_push_at[(user_id, item_id)] = now # Opportunistic eviction so the map can't grow unbounded over a long-lived process (one # entry per item ever checkpointed). Anything older than a few windows is dead — a push # completes in seconds, so a stale entry protects nothing. if len(_last_push_at) > 2048: cutoff = now - timedelta(seconds=_PUSH_THROTTLE_S * 6) for k in [k for k, t in _last_push_at.items() if t < cutoff]: del _last_push_at[k] return True return False def _resume_push_superseded(user_id: int, item_id: int, intent: datetime) -> bool: """True if a newer resume push for this (user, item) was scheduled after `intent`. Called at the HTTP boundary of a background resume push: because periodic and seek/pause pushes are independent background tasks with no ordering guard, an older still-pending periodic push could otherwise land at Plex AFTER a newer backward-seek push and — with the Plex-authoritative pull — durably revive the stale position. Skipping a superseded push keeps the last-scheduled position the one Plex settles on.""" with _push_throttle_lock: return _last_push_at.get((user_id, item_id), intent) > intent @router.get("/person-image") def person_image(u: str, request: Request) -> Response: """Proxy a cast/crew photo from Plex's public metadata CDN (only a headshot-shaped URL on its host; no token needed), resized through Plex and cached (plex.people.cache_portrait; the people_enrich job fills the same cache ahead of time). Signed-session auth; like /image, the DB session is released before the fetch.""" _require_session(request) if not plex_people.is_portrait_url(u): raise refused(400, "image_host") hit = plex_people.portrait_cached(u) if hit: return imgcache.response(*hit) try: with SessionLocal() as db: plex: PlexClient | None = PlexClient(db) # silent-ok: Plex off is a setting; without its resize the original comes straight from the CDN. except PlexNotConfigured: plex = None try: if plex is None: data, ct = plex_people.cache_portrait(None, u) else: with plex: data, ct = plex_people.cache_portrait(plex, u) except (PlexError, httpx.HTTPError): raise refused(502, "image_fetch_failed") from None return imgcache.response(data, ct) def _supersede_resume_pushes(user_id: int, item_id: int, now: datetime) -> None: """A watched mark supersedes every resume push still in flight for the item: stamping it as the latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep the map small) removed the very guard, and an older resume push could then land at Plex AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.""" with _push_throttle_lock: _last_push_at[(user_id, item_id)] = now def _push_resume_guarded( user_id: int, item_id: int, rating_key: str, time_ms: int, duration_ms: int, expect_ts: datetime | None, intent: datetime, ) -> None: """Background entrypoint for a resume push that drops itself if a newer resume push for the same (user, item) was scheduled after it (see `_resume_push_superseded`), then delegates to the real push. This narrows the periodic-vs-seek write-ordering race to the (rare) case where the older push has already left the process before the newer one is scheduled.""" if _resume_push_superseded(user_id, item_id, intent): return plex_watch.push_state_to_plex( user_id, item_id, rating_key, "resume", time_ms, duration_ms, expect_ts ) def _push_watch( background: BackgroundTasks, db: Session, user_id: int, item_id: int, rating_key: str, action: str, time_ms: int = 0, duration_ms: int = 0, intent: datetime | None = None, ) -> None: """Schedule a best-effort Siftlode→Plex watch push, but only for a user whose owner link has push enabled — non-owner users never touch Plex. Guarding here (with the request session) avoids spinning up a throwaway background session on every state change for the common no-link case. A `resume` push with an `intent` timestamp is routed through `_push_resume_guarded` so a superseded (older) push can't land out of order; `watched`/`unwatched` scrobbles go straight through.""" if plex_watch.link_for_push(db, user_id) is None: return # Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit # lands before it settles synced_to_plex (which would otherwise strand that newer value). marker = plex_watch._state_marker(queries.state(db, user_id, item_id)) if action == "resume" and intent is not None: background.add_task( _push_resume_guarded, user_id, item_id, rating_key, time_ms, duration_ms, marker, intent ) return background.add_task( plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms, marker, ) class ItemProgressIn(ProgressIn): """The shared checkpoint (its bounds included), plus whether the player is leaving the item.""" final: bool = False @router.post("/item/{rating_key}/progress") def item_progress( rating_key: str, body: ItemProgressIn, background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Checkpoint the resume position while playing (near the end → mark watched + clear it). Write-back: the resume position is mirrored to Plex (the source of truth) on the periodic checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks.""" it = item_or_404(db, rating_key) # A player parked mid-item — playing or PAUSED — checkpoints every 10 s, so this is the signal # that such a viewer still wants their HLS session: the one thing `last_access` could not see, # since it moved only on a file fetch and a paused player fetches nothing (see # stream.reap_idle). It is NOT every open player: `usePlexProgress.flush` sends nothing once the # finish latch is up, or before the first position is known. Those are the states in which # reaping is the right answer anyway, and a viewer who scrubs back into a reaped session gets # the same 404 → reclaim the player already handles. plex_stream.touch_session(user.id, str(rating_key)) position = max(0, int(body.position_seconds)) duration = int(body.duration_seconds) final = body.final now = datetime.now(timezone.utc) near_end = duration > 0 and position > duration - _FINISH_MARGIN_S if near_end: flipped = service.mark_finished(db, user.id, it.id, now) _supersede_resume_pushes(user.id, it.id, now) db.commit() # Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished # item — or a second one racing this one — would otherwise bump Plex's viewCount again. if flipped: _push_watch(background, db, user.id, it.id, rating_key, "watched") return {"ok": True, "status": "watched", "position_seconds": 0} st = queries.state(db, user, it.id) if position < _PROGRESS_MIN_S: service.clear_progress(db, st, now) db.commit() return {"ok": True, "position_seconds": 0} push_now = _should_push_resume(user.id, it.id, now, final) # Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so # `push_state_to_plex` can settle synced_to_plex once Plex confirms. if not service.checkpoint(db, user, it.id, st, position, now, dirty=push_now): # Marked watched a moment ago: this is the finishing player's late tick (see # `service.checkpoint`), and the answer is the same one a near-end checkpoint gets. db.commit() return {"ok": True, "status": "watched", "position_seconds": 0} db.commit() if push_now: _push_watch( background, db, user.id, it.id, rating_key, "resume", position * 1000, duration * 1000, intent=now, ) return {"ok": True, "position_seconds": position} class ItemStateIn(BaseModel): status: str = "" @router.post("/item/{rating_key}/state") def item_state( rating_key: str, body: ItemStateIn, background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Set the per-user watch status (new|watched|hidden). watched→scrobble / →new→unscrobble push to a linked Plex account immediately; `hidden` is a Siftlode-only concept with no Plex analogue, so it never touches Plex.""" it = item_or_404(db, rating_key) status = body.status if status not in ("new", "watched", "hidden"): raise refused(400, "invalid_status") st = queries.state(db, user, it.id) prev_status = st.status if st is not None else "new" if status == "new": # Does Plex hold anything for this item that the un-mark has to clear? Two things qualify: a # view count, and a resume point. An IN-PROGRESS item carries status "new" WITH a position — # that is how the importer maps a bare `viewOffset` (`_plex_watch_to_state`) — so keying the # push on the status alone silently skipped the single case "Clear resume point" exists for: # the un-mark never reached Plex, the offset stayed, and the next pull imported it straight # back (Protector, cleared twice by the user and back at 92s both times). `hidden` is # Siftlode-only and still pushes nothing, exactly as this endpoint's docstring says. mirrored_on_plex = st is not None and ( st.status == "watched" or (st.position_seconds or 0) > 0 ) if st is not None: service.forget_state(db, st) db.commit() if mirrored_on_plex: _push_watch(background, db, user.id, it.id, rating_key, "unwatched") return {"ok": True, "status": "new"} service.set_status(db, user, it.id, st, status) if status == "watched": _supersede_resume_pushes(user.id, it.id, datetime.now(timezone.utc)) db.commit() if status == "watched" and prev_status != "watched": _push_watch(background, db, user.id, it.id, rating_key, "watched") return {"ok": True, "status": status} def _bulk_state( background: BackgroundTasks, db: Session, user_id: int, eps: "list[PlexItem]", watched: bool, ) -> int: """Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a linked Plex account in the background (best-effort). Returns how many rows actually changed. The handler commits before the response — which is when the background push runs, so the push always reads the committed marks.""" pushable = plex_watch.link_for_push(db, user_id) is not None to_push = service.bulk_state(db, user_id, eps, watched) if watched: now = datetime.now(timezone.utc) for item_id, _rating_key, _action in to_push: _supersede_resume_pushes(user_id, item_id, now) if pushable and to_push: # One coalesced background task for the whole batch (reuses a single session + Plex client), # not one task per episode — a 200-ep show mark is 1 task, not 200. background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push) return len(to_push) class WatchedIn(BaseModel): watched: bool = False @router.post("/show/{rating_key}/state") def show_state( rating_key: str, body: WatchedIn, background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" sh = queries.show(db, rating_key) if sh is None: raise not_found("show") watched = body.watched eps = queries.episodes(db, sh) changed = _bulk_state(background, db, user.id, eps, watched) db.commit() return {"ok": True, "changed": changed, "watched": watched} @router.post("/season/{rating_key}/state") def season_state( rating_key: str, body: WatchedIn, background: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" se = queries.season(db, rating_key) if se is None: raise not_found("season") watched = body.watched eps = queries.season_episodes(db, se) changed = _bulk_state(background, db, user.id, eps, watched) db.commit() return {"ok": True, "changed": changed, "watched": watched} @router.post("/stream/{rating_key}/session") def stream_session( rating_key: str, # 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 = Query(0.0, allow_inf_nan=False), multi: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Prepare playback from `start` seconds. Direct-playable files are served raw; remux files (re)start an HLS session at the offset (seek-restart, video stream-copied); transcode files (non-h264: HEVC/VP9/AV1/…) take the same HLS path but with the video re-encoded to H.264 (VAAPI or libx264). Selecting a non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) — no session restart.""" it = item_or_404(db, rating_key) transcode = it.playable == "transcode" # `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and # the client can switch audio without a restart — even for otherwise direct-playable files. if it.playable == "direct" and audio is None and not aoff and not multi: return { "ok": True, "mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0, } if plex_paths.local_media_path(db, it.file_path) is None: raise refused(404, "media_missing") s = plex_stream.start_session(db, it, user.id, start, audio, aoff, multi, transcode=transcode) if s is None: raise refused(404, "media_missing") # Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the # requested offset — the client's absolute clock and the subtitle cue-shift both key off this. # `transcode`/`hw`/`tonemap` let the player show an honest cold-start ("Preparing stream…") and # surface the active mode + encoder in the stats overlay (`tonemap` = the HW HDR→SDR path); both # segment streams are otherwise identical. return { "ok": True, "mode": "hls", "start_s": s.media_start_s, # The session id is part of the path (not a query param) so the playlist's RELATIVE segment # names resolve back into the same session — see `stream_hls_file` for why it has to. "url": f"/api/plex/stream/{it.rating_key}/hls/{s.id}/{s.entry}", # What the player names when it no longer wants the session (POST …/session/{id}/stop). "id": s.id, "transcode": s.transcode, "hw": s.hw, "tonemap": s.tonemap, } @router.post("/stream/{rating_key}/session/{session_id}/stop") def stream_session_stop( rating_key: str, session_id: str, user: User = Depends(current_user), ) -> dict: """The player no longer wants this session (it left the item, closed, or the page unloaded): its ffmpeg stops now instead of at the idle reaper. A POST, so the player can send it as a keepalive beacon from a page being unloaded. Stops nothing but the caller's own session, and only while it is still the one registered for the item (see `stop_session`).""" return {"ok": True, "stopped": plex_stream.stop_session(user.id, rating_key, session_id)} @router.get("/stream/{rating_key}/file") def stream_file( rating_key: str, download: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> FileResponse: """Serve the raw local media file with HTTP range support. Used both for direct-play (inline) and for the player's "download original" button (`?download=1` → attachment). The download is the untouched physical file — no re-encode/repackage.""" it = item_or_404(db, rating_key) src = plex_paths.local_media_path(db, it.file_path) if src is None: raise refused(404, "media_missing") if download: # A clean, safe download name from the title + the real file extension. ext = src.suffix or (f".{it.container}" if it.container else "") base = ( "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video" ) return FileResponse(str(src), filename=f"{base}{ext}") return FileResponse(str(src)) # Starlette honours the Range header (206) # What a session directory can contain, and nothing else. No `.vtt`: ffmpeg runs with `-sn` and # subtitles are independent tracks served by GET /subtitle, so a WebVTT name here could only ever be # a name that will not arrive — and accepting it cost a 20 s parked worker per request. _HLS_CT = { ".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", } # A real artifact name is ~20 characters. The bound is what keeps an absurd one out of the parser: # `int()` on an unbounded digit run raises ValueError past 4300 digits (CPython's # `sys.get_int_max_str_digits`), which reached the client as a 500 — the same shape of bug the # subtitle routes were bounded for (`_RatingKey`, above). _HLS_NAME_MAX = 128 @router.get("/stream/{rating_key}/hls/{session_id}/{filename}") def stream_hls_file( rating_key: str, session_id: str, filename: str, user: User = Depends(current_user), ) -> Response: """Serve a file (playlist / segment / subtitle) from ONE HLS session's directory. The playlist references its segments/subtitles by relative name, all resolving back here. The session id in the path is what makes "back here" mean the same session. Sessions are keyed by (viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong bytes with a 200, which the player has no way to notice. A request for a session that is no longer the registered one is a 404, and the player reclaims at the playhead. That reclaim is also why a request with no live session is no longer auto-started here: the auto-start could only ever begin at 0, silently throwing away the viewer's position.""" # Filename must be a plain hls artifact — no path separators, only the expected extensions. ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else "" if ( len(filename) > _HLS_NAME_MAX or ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename ): raise not_found("file") s = plex_stream.current_session(user.id, str(rating_key)) if s is None or s.id != session_id: raise refused(404, "session_gone") path = s.dir / filename # Only a name THIS session can actually produce is worth waiting for: its own playlists, and a # segment of one of its own renditions whose predecessor is already on disk (ffmpeg writes them # in order, so if it is not there this one is not next). The session is passed, not just its # directory, because the single- and multi-audio argv branches spell their names differently and # neither can produce the other's. Answering now instead of waiting it out is what keeps a seek # past the produced region — or a deliberate flood of hopeless names — from parking threadpool # workers and their DB connections (see `artifact_may_arrive`). if not plex_stream.artifact_may_arrive(s, filename): raise refused(404, "not_ready") timeout = 30 if ext == ".m3u8" else 20 # `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the # whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection # with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek # past the produced region was enough to do it. if not plex_stream.wait_for(path, timeout, proc=s.proc): raise refused(404, "not_ready") try: content = path.read_bytes() except OSError: # The session was torn down between the wait and the read — teardown moves the whole # directory aside and deletes it, and nothing holds `_lock` across those two steps. It has # to present as the same 404 a gone session does: the player's recovery is keyed on the # STATUS (`code === 404`), so # a 500 here skipped the reclaim and ended playback with "couldn't play" instead of # restarting at the playhead. Ordinary trigger: `loadSession` awaits the new session — which # kills and rmtrees the old one — before destroying the old hls instance, so the outgoing # instance's in-flight segment request races the rmtree on every seek-restart. raise refused(404, "session_gone") from None return Response( content=content, media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}, )