fix(plex): an HLS artifact names the session it belongs to, and a paused viewer says so
Deep-review round 5, over round 4's own fixes. - Sessions are keyed by rating_key alone while a session's DIRECTORY is per (key, offset, tag), and the artifact URL carried no session identity — so once a seek-restart or a second viewer of the same item took the registry slot, the old viewer's request for `seg_12.ts` was answered 200 with the new session's twelfth segment: bytes from a different `-ss` offset under `-copyts`, on a timeline the client is not on. Wrong content with a success status is the one failure a player cannot notice, and round 4's reclaim-on-404 could never fire on it. The id is in the path now (so the playlist's relative names resolve back into the same session), a mismatch is a 404, and the player reclaims at the playhead. The entry playlist's auto-start is gone with it: it could only ever begin at 0, silently discarding the viewer's position. - Round 4 exempted paused viewers from the reaper by keeping EVERY finished session for the full ten-minute idle window — one whole segmented copy of a film per item, on the volume the download center shares. The signal it needed already existed: an open player checkpoints its position every 10 s whether or not it is playing, so that route now touches the session and the periodic pass is back on the short rule. - POST /session's opportunistic reap was never scoped to the key being started: it walked the whole registry with the 30 s finished rule, so pressing play on ANY item — even a direct-play one, the call ran before that early return — killed another viewer's paused session. It is gone; the periodic pass owns reaping. - A segment that can never arrive no longer polls out the full 20 s: the producer's handle is passed to `wait_for`, so a finished ffmpeg answers at once. This is a sync route, so each such wait parked a threadpool worker AND a pooled DB connection, and an ordinary seek past the produced region was enough to do it. - The DataError handler's re-raise branch — the half that keeps a genuine DataError a 500 — is pinned, so simplifying it to an unconditional 422 cannot pass in silence. Six regression tests (two route-level, in the DB lane), each mutation-checked red on the assertion it is named after. pytest 1124, vitest 999.
This commit is contained in:
@@ -51,3 +51,24 @@ def test_a_nul_in_a_request_string_is_a_422_not_a_500(db, make_user, as_user):
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == {"code": "invalid_input", "reason": "nul"}
|
||||
assert client.get("/api/plex/playlists?contains_group=1,%00").status_code == 422
|
||||
|
||||
|
||||
def test_any_other_data_error_is_still_a_500():
|
||||
"""The other half of the app-wide handler, and the half a refactor can delete in silence: it is
|
||||
registered for EVERY DataError on every route and decides on a substring of the driver's message,
|
||||
so only a NUL may become a 422. Without this, simplifying it to an unconditional 422 — it reads
|
||||
like dead defensive code — would answer a numeric overflow or an over-long ingested title with
|
||||
"your input was bad", losing the traceback that names the real fault (deep-review round 5)."""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import DataError
|
||||
|
||||
from app.main import _nul_in_text
|
||||
|
||||
overflow = DataError("SELECT 1", {}, Exception("numeric field overflow"))
|
||||
with pytest.raises(DataError):
|
||||
asyncio.run(_nul_in_text(None, overflow)) # re-raised: it stays the 500 it always was
|
||||
|
||||
nul = DataError("SELECT 1", {}, Exception("invalid byte sequence: NUL (0x00)"))
|
||||
assert asyncio.run(_nul_in_text(None, nul)).status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user