From 12d9561ba17f9357f06e2b517965e0ad7250ec6d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 27 Aug 2026 00:50:58 +0200 Subject: [PATCH] fix(plex): warm up HLS buffer on session start + player loading/buffering spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plex player stalled ~2s a few seconds into every auto-advanced next episode. Root cause: each auto-advance spins up a cold ffmpeg HLS remux session, and for a fresh start (start_s==0) start_session returned immediately with zero buffer — hls.js drained the first segment, then rebuffered waiting for the still-warming ffmpeg to write the next one. Once ffmpeg gets ahead (stream-copy is faster than realtime) it never stalls again. - stream.py: before reporting a session ready, wait for the first _WARMUP_SEGMENTS (3) video segments to exist — the seg0 wait is now unconditional (was start_s>0 only) and warm-up runs for all starts. Best-effort and bounded: stop at the first segment not ready in time. - PlexPlayer.tsx: a loading/buffering spinner. The existing overlay only covered the INITIAL load (!ready); add a `buffering` state driven by the video's waiting/stalled/seeking (set) and playing/canplay/seeked/pause (clear) events so a mid-playback stall or in-flight seek reads as "loading" instead of a frozen frame, and surface a fatal loadError even once playback has started. Spinner uses the shared Loader2. Merges roadmap E6·item1 (spinner while loading/seeking) into this fix, since the server-side warm-up deliberately holds ready a beat longer. Tests: 3 new in test_plex_stream.py (warm-up waits for the first few segments; stops at the first missing one; no warm-up when seg0 never arrives). Gate: backend pytest 460 (Docker lane), FE tsc/lint/knip/ prettier + vitest 483. --- backend/app/plex/stream.py | 38 +++++++++++--- backend/tests/test_plex_stream.py | 71 ++++++++++++++++++++++++++ frontend/src/components/PlexPlayer.tsx | 36 ++++++++++++- 3 files changed, 135 insertions(+), 10 deletions(-) diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index f10b4744..c8aa4ca6 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -38,6 +38,17 @@ _HLS_ROOT = ( else Path(settings.download_root) / ".plex-hls" ) _SEG_SECONDS = 6 +# Cold-start warm-up. A freshly-spawned ffmpeg has produced only the first segment (or two) by the +# time we could report the session ready; hls.js parses the EVENT playlist, drains that tiny buffer, +# then STALLS a few seconds into playback waiting for the next segment the still-warming ffmpeg +# hasn't written yet. Very visible on auto-advance to the next episode (start_s==0, which previously +# returned with ZERO buffer). So before reporting ready we wait for the first few segments to exist: +# the client then starts with ~_WARMUP_SEGMENTS×_SEG_SECONDS of buffer and, since stream-copy runs +# faster than realtime, ffmpeg stays ahead from there and never stalls again. Best-effort and bounded +# — we stop at the first segment that isn't ready within the per-segment budget (a slow miss is no +# worse than the old zero-buffer behaviour). +_WARMUP_SEGMENTS = 3 +_WARMUP_SEG_TIMEOUT_S = 8.0 _SESSION_IDLE_S = 600 # reap a session with no access for this long # A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session); # before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names @@ -296,16 +307,27 @@ def start_session( # Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes); # at least 1 so playback can't be capped to zero. _enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes"))) - # Learn the REAL start offset K from the first VIDEO segment (outside the lock — this blocks on - # ffmpeg producing it, and we must not hold up other sessions). The client reads `media_start_s`, so - # its absolute clock + subtitle shift key off the true keyframe, not the requested offset. On - # timeout/probe failure we keep the provisional requested offset (old behaviour, small drift). - if start_s > 0: - seg0 = directory / ("seg_0_0.ts" if is_multi else "seg_0.ts") # multi: video variant = v0 - if wait_for(seg0, timeout=25.0): - k = _probe_first_pts(seg0) + + # Wait for the first video segment, then warm up a few more — all outside the lock (this blocks on + # ffmpeg producing them, and we must not hold up other sessions). + def _seg(i: int) -> Path: + # multi-audio: the video rendition is variant v0 → seg_{i}_0.ts; single-audio → seg_{i}.ts + return directory / (f"seg_{i}_0.ts" if is_multi else f"seg_{i}.ts") + + if wait_for(_seg(0), timeout=25.0): + # For a seek (start_s>0) learn the REAL start offset K from the first segment's absolute PTS, + # so the client's absolute clock + subtitle shift key off the true keyframe, not the requested + # offset. On probe failure we keep the provisional requested offset (old behaviour, small drift). + if start_s > 0: + k = _probe_first_pts(_seg(0)) if k is not None and k >= 0: s.media_start_s = k + # Warm up the buffer (see _WARMUP_SEGMENTS) so the client doesn't outrun the cold ffmpeg. Stop + # at the first segment that isn't ready in time — ffmpeg produces them faster than realtime once + # warm, so a miss means something slow and we proceed rather than block the request further. + for i in range(1, _WARMUP_SEGMENTS): + if not wait_for(_seg(i), timeout=_WARMUP_SEG_TIMEOUT_S): + break log.info( "plex hls session start key=%s req=%.1f real=%.3f multi=%s audio=%s", key, diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 5b10e9ad..f23453a6 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -154,3 +154,74 @@ class TestSweepOrphans: os.utime(f, (f.stat().st_mtime - 3600,) * 2) # old enough that only is_dir() saves it assert stream.sweep_orphans(older_than=_cutoff(hls_root)) == 0 assert f.exists() + + +class _FakeProc: + """Stand-in for the ffmpeg Popen — start_session only stores it (and _kill/reap poll it later).""" + + def poll(self): + return None + + +class _FakeItem: + def __init__(self, rating_key="12345", file_path="/media/movie.mkv"): + self.rating_key = rating_key + self.file_path = file_path + self.playable = "remux" + + +class TestColdStartWarmup: + """The cold-start buffer warm-up: before reporting a session ready, start_session waits for the + first _WARMUP_SEGMENTS video segments so hls.js doesn't drain a one-segment buffer and stall a + few seconds in (the auto-advance-to-next-episode stall). We spy on wait_for instead of running + ffmpeg.""" + + @pytest.fixture + def fake_env(self, tmp_path, monkeypatch): + monkeypatch.setattr(stream, "_HLS_ROOT", tmp_path) + monkeypatch.setattr(stream, "_sessions", {}) + src = tmp_path / "movie.mkv" + src.write_bytes(b"x") + monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src) + monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2) + monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc()) + return tmp_path + + def test_warmup_waits_for_the_first_few_segments(self, fake_env, monkeypatch): + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return True + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + # seg_0 (unconditional now) + the warm-up run up to _WARMUP_SEGMENTS. + assert waited == [f"seg_{i}.ts" for i in range(stream._WARMUP_SEGMENTS)] + + def test_warmup_stops_at_the_first_segment_that_isnt_ready(self, fake_env, monkeypatch): + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return path.name != "seg_1.ts" # seg_1 never appears → warm-up gives up there + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + assert waited == ["seg_0.ts", "seg_1.ts"] # never asked for seg_2 + + def test_no_warmup_wait_when_the_first_segment_never_arrives(self, fake_env, monkeypatch): + # If seg_0 itself times out we don't warm up at all (nothing to build on) — and the session is + # still returned so the client's own segment requests can drive it, as before. + waited = [] + + def spy(path, timeout=20.0): + waited.append(path.name) + return False + + monkeypatch.setattr(stream, "wait_for", spy) + s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0) + assert s is not None + assert waited == ["seg_0.ts"] diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 76a7d8de..383625d7 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -12,6 +12,7 @@ import { Info, Keyboard, Languages, + Loader2, Maximize, Minimize, Pause, @@ -108,6 +109,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region const { fs, toggleFs } = useFullscreen(wrapRef); const [ready, setReady] = useState(false); + // True while playback is stalled waiting for data (mid-stream rebuffer) or a seek is in flight — drives + // a spinner so a stall reads as "loading, hang on" instead of a frozen frame. Distinct from `ready`, + // which only covers the INITIAL session load; this covers stalls AFTER playback has started (e.g. the + // brief cold-ffmpeg rebuffer on auto-advance, now largely prevented by the server-side segment warm-up). + const [buffering, setBuffering] = useState(false); const [loadError, setLoadError] = useState(null); // The current session's real media-start K (reactive mirror of sessionStartRef). Subtitles are // absolute-time VTT shifted by K to the session's zero-based clock, so the src/key keys off @@ -178,6 +184,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { const video = videoRef.current; if (!video) return; setReady(false); + setBuffering(false); setLoadError(null); let sess; try { @@ -362,12 +369,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { // button lie (⏸ shown while actually paused). Resync the flag from reality on the settling events // so the icon can never contradict the video. const syncPlaying = () => setPlaying(!video.paused); + // Buffering spinner: `waiting`/`stalled` = playback stopped for lack of data; `seeking` = a seek is + // in flight. Cleared once data flows again (`playing`/`canplay`/`seeked`) or the user pauses (a + // deliberate pause isn't a stall, and would otherwise leave the spinner stuck). + const onWaiting = () => setBuffering(true); + const onResumed = () => setBuffering(false); video.addEventListener("timeupdate", onTime); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); video.addEventListener("emptied", syncPlaying); video.addEventListener("canplay", syncPlaying); video.addEventListener("seeked", syncPlaying); + video.addEventListener("waiting", onWaiting); + video.addEventListener("stalled", onWaiting); + video.addEventListener("seeking", onWaiting); + video.addEventListener("playing", onResumed); + video.addEventListener("canplay", onResumed); + video.addEventListener("seeked", onResumed); + video.addEventListener("pause", onResumed); return () => { video.removeEventListener("timeupdate", onTime); video.removeEventListener("play", onPlay); @@ -375,6 +394,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { video.removeEventListener("emptied", syncPlaying); video.removeEventListener("canplay", syncPlaying); video.removeEventListener("seeked", syncPlaying); + video.removeEventListener("waiting", onWaiting); + video.removeEventListener("stalled", onWaiting); + video.removeEventListener("seeking", onWaiting); + video.removeEventListener("playing", onResumed); + video.removeEventListener("canplay", onResumed); + video.removeEventListener("seeked", onResumed); + video.removeEventListener("pause", onResumed); }; }, [id]); @@ -730,12 +756,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { )} - {!ready && ( + {/* Loading / buffering / error overlay. `!ready` = initial session load; `buffering` = a + mid-playback stall or in-flight seek (see the state); `loadError` surfaces a fatal failure so + it can't hide behind a running video. Spinner on load/stall, error text otherwise. */} + {(!ready || buffering || loadError) && (
{loadError ? ( {loadError} ) : ( - {t("plex.player.loading")} +
+ + {!ready && {t("plex.player.loading")}} +
)}
)}