diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index daf89f94..da08c438 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -1117,6 +1117,23 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int: return dropped +def stop_session(user_id: int, key: str, session_id: str) -> bool: + """Stop this viewer's session for `key` now, because the player that asked for it is gone (left + the item, closed, unloaded the page). Left to `reap_idle`, a transcode ran on to the end of the + film first — measured: ~4 cores for a minute after leaving a resumed 480p film. + + Only while `session_id` is still the registered one: the player names the session it played, and + a seek-restart or a second tab may have replaced it meanwhile — that newer session is somebody's + current playback, not this request's to end.""" + with _lock: + s = _sessions.get((user_id, key)) + if s is None or s.id != session_id: + return False + _kill(s) + _sessions.pop((user_id, key), None) + return True + + def touch_session(user_id: int, key: str) -> None: """Say this viewer is still there for `key`, without fetching anything. `last_access` used to move only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 92fafd99..72dcd58c 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -637,12 +637,27 @@ def stream_session( # 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, diff --git a/backend/tests/test_plex_playback_session.py b/backend/tests/test_plex_playback_session.py index d7339afb..ea823c66 100644 --- a/backend/tests/test_plex_playback_session.py +++ b/backend/tests/test_plex_playback_session.py @@ -96,6 +96,8 @@ def test_remux_item_stays_a_stream_copy_session(db, monkeypatch, override_dep): assert body["mode"] == "hls" assert body["transcode"] is False assert captured["transcode"] is False + # The id the player names when it stops the session (POST …/session/{id}/stop). + assert body["id"] == "sid" def test_direct_item_never_opens_a_session(db, monkeypatch, override_dep): diff --git a/backend/tests/test_plex_session_stop.py b/backend/tests/test_plex_session_stop.py new file mode 100644 index 00000000..b375be7d --- /dev/null +++ b/backend/tests/test_plex_session_stop.py @@ -0,0 +1,101 @@ +"""A player that no longer wants its HLS session stops it, instead of leaving ffmpeg to run on. + +Left to the idle reaper, a transcode ran to the end of the film after the viewer had gone (measured on +localdev: ~4 cores for a minute after leaving a resumed 480p film). The player names the session it +played; the server stops it only while it is still that viewer's registered session for the item, so +a stop that arrives after a seek-restart (or from a second tab) cannot end the newer playback. + +No `db` fixture: the route reads nothing from the database (`current_user` is overridden wholesale). +""" + +import time + +import pytest +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.main import app +from app.models import User +from app.plex import stream as plex_stream + +VIEWER = 41 + + +class _Proc: + def __init__(self): + self.terminated = False + + def poll(self): + return None + + def terminate(self): + self.terminated = True + + def wait(self, timeout=None): + return 0 + + +class _Session: + def __init__(self, session_id: str, directory): + self.id = session_id + self.dir = directory + self.proc = _Proc() + self.last_access = time.time() + + +@pytest.fixture +def sessions(monkeypatch): + live: dict = {} + monkeypatch.setattr(plex_stream, "_sessions", live) + return live + + +@pytest.fixture +def client(monkeypatch, override_dep): + monkeypatch.setattr(state, "_configured_cache", True) + user = User(id=VIEWER, email="stop@example.com") + override_dep(current_user, lambda: user) + return TestClient(app) + + +def _session(tmp_path, sid: str) -> _Session: + d = tmp_path / sid + d.mkdir() + return _Session(sid, d) + + +def test_the_named_session_is_stopped_and_its_directory_goes(client, tmp_path, sessions): + s = _session(tmp_path, "sid-a") + sessions[(VIEWER, "m1")] = s + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.status_code == 200, r.text + assert r.json() == {"ok": True, "stopped": True} + assert s.proc.terminated + assert sessions == {} + assert not s.dir.exists() + + +def test_a_session_that_replaced_the_named_one_plays_on(client, tmp_path, sessions): + """The stop of the session a seek-restart replaced arrives after the new one registered.""" + newer = _session(tmp_path, "sid-b") + sessions[(VIEWER, "m1")] = newer + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.json() == {"ok": True, "stopped": False} + assert not newer.proc.terminated + assert sessions == {(VIEWER, "m1"): newer} + + +def test_another_viewers_session_is_not_this_viewers_to_stop(client, tmp_path, sessions): + theirs = _session(tmp_path, "sid-a") + sessions[(7, "m1")] = theirs + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.json() == {"ok": True, "stopped": False} + assert not theirs.proc.terminated + + +def test_a_stop_with_no_session_left_is_not_an_error(client, sessions): + """The reaper or a cap may have dropped it first; the beacon must not fail loudly for that.""" + r = client.post("/api/plex/stream/m1/session/sid-a/stop", json={}) + assert r.status_code == 200, r.text + assert r.json() == {"ok": True, "stopped": False} diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index 6d6b4870..4cf57eb6 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -502,11 +502,18 @@ describe("restarting the session before playback has ticked", () => { { ord: 1, label: "Magyar", language: "hu", default: false }, ], } as unknown as PlexItemDetail; - mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } }); + const answers = [deferred(), deferred(), deferred()]; + let calls = 0; + mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } }); await waitFor(() => expect(startsAsked("A")).toEqual([1200])); fireEvent.keyDown(window, { key: "a" }); + // One request per item in flight: the switch waits for the start's answer… + await act(async () => answers[0]!.resolve(direct("A"))); + await waitFor(() => expect(startsAsked("A")).toHaveLength(2)); fireEvent.keyDown(window, { key: "ArrowRight" }); + await act(async () => answers[1]!.resolve(direct("A"))); await waitFor(() => expect(startsAsked("A")).toHaveLength(3)); + // …and each restart asked where the resume put the viewer, not at the unticked 0:00. expect(startsAsked("A")).toEqual([1200, 1200, 1210]); }); }); @@ -529,12 +536,41 @@ it("a seek made while a restart is pending wins over it", async () => { await playingAt("/media/A", 95); fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer - await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95])); + await waitFor(() => expect(startsAsked("A")).toEqual([0, 105])); + await tick(); + expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never)); + await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95])); await tick(); expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached }); +it("a burst of seeks past the buffer asks the server once more, not once per press", async () => { + // Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests — + // twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured + // on localdev, 2026-10-06). + let calls = 0; + const restart = deferred(); + mount({ + details: { A: item("A", 1200) }, + sessions: { + A: () => { + calls++; + if (calls === 1) return Promise.resolve(direct("A")); + return calls === 2 ? restart.promise : new Promise(() => {}); + }, + }, + }); + await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A")); + await playingAt("/media/A", 1200); + for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" }); + await tick(); + expect(startsAsked("A")).toEqual([1200, 1190]); + await act(async () => restart.resolve(direct("A"))); + // The queued target is the LAST press's, asked once. + await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000])); +}); + it("a direct-play item left before its metadata does not seek the next one", async () => { mount({ details: { A: item("A", 1200), B: item("B") }, diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 787907e7..95173569 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,4 +1,13 @@ -import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { qk } from "../lib/queryKeys"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -6,7 +15,6 @@ import { plexLang } from "../i18n"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { useFocusKeeper } from "../lib/useFocusKeeper"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Hls from "hls.js"; import { ArrowLeft, Clock, @@ -27,7 +35,7 @@ import { Volume2, VolumeX, } from "lucide-react"; -import { api, HttpError, type PlexPlaySession, type PlexTrackNaming } from "../lib/api"; +import { api, HttpError, type PlexTrackNaming } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { useLatest } from "../lib/useLatest"; import { seekableEndOf } from "../lib/seekableEnd"; @@ -36,7 +44,6 @@ import { clockParts, DEFAULT_PREFS, fmt, - HLS_SUB_LAG, subOrdForLang, subShadowCss, type PlexPlayerPrefs, @@ -45,13 +52,14 @@ import { Ctrl, PanelHead } from "./plexPlayerControls"; import { PlexPlayerSettings } from "./PlexPlayerSettings"; import { PlexStatsOverlay } from "./PlexStatsOverlay"; import { createFragMeter } from "../lib/plexStats"; +import { HlsEngine } from "../lib/hlsEngine"; +import { createHlsPort } from "../lib/hlsPort"; import { enabled, log } from "../lib/log"; import { logMediaElement } from "../lib/mediaLog"; import { UpNextCard } from "./UpNextCard"; import Toaster from "./ui/Toaster"; import { useOverlayOrder } from "./ui/overlayOrder"; import { - playhead, useAutoSkip, useControlsVisibility, useFullscreen, @@ -94,17 +102,6 @@ import { trackLabel } from "../lib/vocab"; // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. const PlexInfo = lazy(() => import("./PlexInfo")); -// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so. -// Two covers the real case (the session was reaped, once) without letting a server that cannot serve -// the stream keep the viewer on a spinner (see the ERROR handler). -const MAX_SESSION_RECLAIMS = 2; -// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by -// the first fragment of any session, and a reclaimed session essentially always delivers one — -// `start_session` does not return until its first segment exists. So both cases the ERROR handler -// names restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error). -// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not. -const RECLAIM_WINDOW_MS = 120_000; - // C5: Back restarts the item after this many seconds in, and steps to the previous one before it. const BACK_TO_PREV_S = 3; // C5: a touch double-tap on the picture's left or right third skips this far; each further tap @@ -134,11 +131,6 @@ type Props = { itemId: string; onClose: () => void; queue?: string[]; fromStart? export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }: Props) { const { t, i18n } = useTranslation(); - // `t` changes identity on a language switch. loadSession only uses it for error strings, so read it - // through a ref — keeping it OUT of loadSession's deps. Otherwise a mid-playback language change - // rebuilt loadSession → re-ran the detail effect → reloaded the session from the STALE saved resume - // position (losing live progress). See the detail effect's deps. - const tRef = useLatest(t); // A track named in the reader's language (lib/vocab), not in the Plex server's ("English (AC3 5.1)"). const audioName = (a: PlexTrackNaming) => trackLabel(t, a, "audio", i18n.language); const subtitleName = (x: PlexTrackNaming) => trackLabel(t, x, "subtitle", i18n.language); @@ -154,86 +146,33 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } const duration = detail?.duration_seconds ?? 0; const videoRef = useRef(null); - const hlsRef = useRef(null); - // False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back) - // can't start a detached