From 2abd4ffcb66b106981e2e72259f6ef77736dbd1d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 20:22:43 +0200 Subject: [PATCH] fix(player): a session superseded before it played is reclaimed, not "couldn't start" Two session requests for one item in flight at once (a quick double seek; the StrictMode replay of the start effect) let the server's last spawn be the older request's. The newer request's answer then named a session already gone; its playlist 404'd before a manifest parsed, and the player stopped with "Playback couldn't start". The 404 reclaim now covers a session that never played too, bounded by the same budget, and keeps the start's own autoplay. --- .../components/PlexPlayer.hls.dom.test.tsx | 31 ++++++++++++++++--- frontend/src/components/PlexPlayer.tsx | 29 +++++++++++------ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/PlexPlayer.hls.dom.test.tsx b/frontend/src/components/PlexPlayer.hls.dom.test.tsx index 1e83fb9e..7377fa7a 100644 --- a/frontend/src/components/PlexPlayer.hls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.hls.dom.test.tsx @@ -248,12 +248,35 @@ describe("an HLS session the server no longer has", () => { } }); - it("is a failure, not a restart, when the session never played", async () => { - // Otherwise a session that 404s from the start would restart itself in a loop. + it("is reclaimed at the asked position, and still starts, when it was lost before it played", async () => { + // Two session requests for one item in flight at once (a seek twice in quick succession; the + // dev StrictMode replay of the start effect): the server's LAST spawn wins, and it may be the + // older request's. The newer request's answer then names a session that is already gone, and + // its playlist 404s before a single manifest parsed — which ended playback with "couldn't + // start" (Arrow S03E14 on localdev, 2026-09-27). The video is still paused then, so the + // reclaim must keep the start's own "play once ready", not read it off the paused element. + const play = vi.spyOn(HTMLMediaElement.prototype, "play"); const session = mount(); await waitFor(() => expect(hlsInstances).toHaveLength(1)); - act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404)); + act(() => + hlsInstances[0]!.emit(Hls.Events.ERROR, { ...lost404, details: "manifestLoadError" }), + ); + await waitFor(() => expect(hlsInstances).toHaveLength(2)); + expect(session).toHaveBeenCalledTimes(2); + expect(session.mock.calls[1]![1]).toBe(600); + act(() => hlsInstances[1]!.emit(Hls.Events.MANIFEST_PARSED)); + expect(play).toHaveBeenCalled(); + expect(screen.queryByText("plex.player.errGeneric")).toBeNull(); + }); + + it("gives up and says so when a session that never played keeps 404ing", async () => { + // The reclaim budget is what bounds a server that 404s every session from the start. + const session = mount(); + for (let i = 0; i < 3; i++) { + await waitFor(() => expect(hlsInstances).toHaveLength(i + 1)); + act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404)); + } await screen.findByText("plex.player.errGeneric"); - expect(session).toHaveBeenCalledTimes(1); + expect(session).toHaveBeenCalledTimes(3); }); }); diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 1acf94aa..2d1fbea6 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -483,8 +483,8 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false } hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`); hls.attachMedia(video); // Subtitles are native s (not muxed into the HLS), so nothing subtitle-related here. - // Whether this session has ever played. Only such a session can be "lost" (below): one that - // fails before its manifest is simply a failure, and must not restart itself in a loop. + // Whether this session has ever played. A reclaim of one that has not (below) keeps the + // start's own `resumePlay`: its