Both found by UAT in a real browser, after the tests for them were already green — which is the whole reason this repo settles focus and timing claims by running them. - **The autoFocus fix worked, and then the dialog took the focus back.** React 18's StrictMode runs every effect mount→cleanup→mount in development: the cleanup handed focus back to the trigger, and the second mount, finding the focus outside the dialog, moved it to the first focusable — off the `autoFocus` field a beat after the browser had put it there. Chrome measured it: INPUT (commitMount) → BODY (cleanup) → ✕ (re-mount). The effect is idempotent now — it remembers what the dialog was focused on and restores THAT on a re-mount — and the close-time restore is conditional on the dialog still holding the focus, which it tracks as it changes rather than computing at cleanup, where the panel is already out of the document. A dialog no longer yanks focus back from wherever the user has since moved it. - **Passing ffmpeg's handle to the segment wait only shortened the FINISHED case.** Measured against the running localdev API: a request for `seg_99999.ts` on a live session still took 20 195 ms, because that ffmpeg has not exited and the wait has no other reason to stop. ffmpeg writes segments in order, so a segment whose predecessor does not exist is not the next one and cannot arrive — and that is knowable at once. Same request now: 45 ms. Verified on localdev against the real library (Westworld S01E06, remux, multi-audio): Mod+K opens the command palette with the focus in its search field and typing reaches it; playback runs on the new session-scoped HLS URL; a request naming a stale session id is a 404; and a viewer paused for longer than the finished-session rule keeps their 2.4 GB session across two reaper passes — the progress checkpoint touches it — and resumes cleanly. vitest 1009, pytest 1127.
154 lines
5.6 KiB
Python
154 lines
5.6 KiB
Python
"""An HLS artifact request names the session it belongs to, and gets nothing else.
|
|
|
|
Sessions are keyed by rating_key ALONE, while a session's directory is per (key, offset, tag). So a
|
|
seek-restart — or a second viewer of the same item — replaces the registered session, and the old
|
|
viewer's next request for `seg_12.ts`, resolved by rating_key, was answered 200 with the NEW
|
|
session's twelfth segment: bytes produced from a different `-ss` offset under `-copyts`, i.e. a
|
|
timeline the client is not on. Wrong content with a success status is the one failure a player cannot
|
|
notice, and the reclaim-on-404 added in round 4 could never fire on it (deep-review round 5).
|
|
|
|
DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
|
"""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.state as state
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.main import app
|
|
from app.models import PlexItem, PlexLibrary, User
|
|
from app.plex import stream as plex_stream
|
|
|
|
|
|
class _Proc:
|
|
def __init__(self, running: bool = False):
|
|
self.running = running
|
|
|
|
def poll(self):
|
|
return None if self.running else 0
|
|
|
|
def terminate(self):
|
|
pass
|
|
|
|
def wait(self, timeout=None):
|
|
return 0
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, session_id: str, directory, running: bool = False):
|
|
self.id = session_id
|
|
self.dir = directory
|
|
self.proc = _Proc(running)
|
|
self.last_access = time.time()
|
|
|
|
|
|
@pytest.fixture
|
|
def sessions(monkeypatch):
|
|
live: dict = {}
|
|
monkeypatch.setattr(plex_stream, "_sessions", live)
|
|
return live
|
|
|
|
|
|
def _client(db, monkeypatch):
|
|
monkeypatch.setattr(state, "_configured_cache", True)
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
db.add(
|
|
PlexItem(
|
|
rating_key="m1",
|
|
library_id=lib.id,
|
|
kind="movie",
|
|
title="A Movie",
|
|
file_path="/media/a.mkv",
|
|
playable="remux",
|
|
)
|
|
)
|
|
user = User(email="hls@example.com")
|
|
db.add(user)
|
|
db.commit()
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
app.dependency_overrides[current_user] = lambda: user
|
|
return TestClient(app)
|
|
|
|
|
|
def test_a_segment_is_served_from_the_session_the_url_names(db, monkeypatch, tmp_path, sessions):
|
|
d = tmp_path / "m1_0_0_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_11.ts").write_bytes(b"") # its predecessor, so seg_12 is the next one
|
|
(d / "seg_12.ts").write_bytes(b"mine")
|
|
sessions["m1"] = _Session("sid-a", d)
|
|
client = _client(db, monkeypatch)
|
|
try:
|
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
assert r.status_code == 200, r.text
|
|
assert r.content == b"mine"
|
|
|
|
|
|
def test_a_segment_of_a_replaced_session_is_a_404_not_the_new_sessions_bytes(
|
|
db, monkeypatch, tmp_path, sessions
|
|
):
|
|
"""Viewer A is at 0:00; viewer B seeks to 25:00 and their session takes the registry slot. A's
|
|
next segment must fail honestly — the player reclaims at the playhead — not arrive as B's."""
|
|
theirs = tmp_path / "m1_1500_0_+0.00"
|
|
theirs.mkdir()
|
|
(theirs / "seg_11.ts").write_bytes(b"")
|
|
(theirs / "seg_12.ts").write_bytes(b"theirs")
|
|
sessions["m1"] = _Session("sid-b", theirs)
|
|
client = _client(db, monkeypatch)
|
|
try:
|
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_12.ts")
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
assert r.status_code == 404, r.text
|
|
assert b"theirs" not in r.content
|
|
|
|
|
|
def test_a_playlist_with_no_live_session_is_a_404_not_a_silent_restart_at_zero(
|
|
db, monkeypatch, sessions
|
|
):
|
|
"""The entry playlist used to auto-start a session when none was registered — always at 0.0,
|
|
silently throwing away the viewer's position. A 404 sends the player through its own reclaim,
|
|
which restarts at the playhead."""
|
|
started: list[float] = []
|
|
monkeypatch.setattr(
|
|
plex_stream,
|
|
"start_session",
|
|
lambda *a, **k: started.append(a[2] if len(a) > 2 else 0.0),
|
|
)
|
|
client = _client(db, monkeypatch)
|
|
try:
|
|
r = client.get("/api/plex/stream/m1/hls/sid-gone/index.m3u8")
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
assert r.status_code == 404, r.text
|
|
assert started == []
|
|
|
|
|
|
def test_a_segment_that_will_never_arrive_fails_at_once(db, monkeypatch, tmp_path, sessions):
|
|
"""`stream_hls_file` is a SYNC route: it runs on the ~40-slot threadpool and holds a pooled DB
|
|
connection while it waits. It used to poll out the whole 20 s for a name that could never appear
|
|
— an ordinary seek past the produced region was enough to park a worker and a connection, and
|
|
forty hopeless names parked the API (deep-review round 5). ffmpeg writes segments in order, so a
|
|
segment whose predecessor is absent is not the next one, and that is knowable at once — even
|
|
while ffmpeg is still running, which is the case `proc` alone cannot shorten."""
|
|
d = tmp_path / "m1_0_0_+0.00"
|
|
d.mkdir()
|
|
(d / "seg_0.ts").write_bytes(b"x") # the session is producing; seg_99999 is simply not next
|
|
sessions["m1"] = _Session("sid-a", d, running=True)
|
|
client = _client(db, monkeypatch)
|
|
started = time.monotonic()
|
|
try:
|
|
r = client.get("/api/plex/stream/m1/hls/sid-a/seg_99999.ts")
|
|
finally:
|
|
app.dependency_overrides.clear()
|
|
elapsed = time.monotonic() - started
|
|
assert r.status_code == 404, r.text
|
|
# The wait is 20 s; anything in that neighbourhood means the producer's exit was not consulted.
|
|
assert elapsed < 2.0, f"waited {elapsed:.1f}s for a segment that can never arrive"
|