fix(plex): the player restarts a reaped session, finishes a played-through item as watched, and starts each item once

Deep-review round 4, the Plex player.

- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
  hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
  now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
  its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
  credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
  patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
  as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
  playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
  session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
  seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
  language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
This commit is contained in:
2026-09-11 23:18:33 +02:00
parent 31dc4184f5
commit 6a92585098
4 changed files with 552 additions and 54 deletions
@@ -0,0 +1,164 @@
// @vitest-environment jsdom
//
// The Plex player's HLS path, with hls.js replaced by a stand-in the test drives (jsdom has no
// MediaSource, so the real one never runs here). Its own file because the module mock is file-wide.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, waitFor, screen, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
// Hoisted with the mock below (vi.mock runs before the module body).
const { FakeHls, hlsInstances } = vi.hoisted(() => {
type Handler = (event: string, data: unknown) => void;
const hlsInstances: InstanceType<typeof FakeHls>[] = [];
class FakeHls {
handlers = new Map<string, Handler[]>();
audioTrack = 0;
constructor() {
hlsInstances.push(this);
}
on(event: string, fn: Handler) {
this.handlers.set(event, [...(this.handlers.get(event) ?? []), fn]);
}
emit(event: string, data: unknown = {}) {
for (const fn of this.handlers.get(event) ?? []) fn(event, data);
}
loadSource() {}
attachMedia() {}
destroy() {}
}
return { FakeHls, hlsInstances };
});
vi.mock("hls.js", async (importOriginal) => {
const actual = (await importOriginal<{ default: typeof import("hls.js").default }>()).default;
return {
default: Object.assign(FakeHls, {
isSupported: () => true,
Events: actual.Events,
ErrorTypes: actual.ErrorTypes,
}),
};
});
import Hls from "hls.js";
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
type Media = HTMLMediaElement & { _t?: number };
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
Object.defineProperty(HTMLMediaElement.prototype, "currentTime", {
configurable: true,
get(this: Media) {
return this._t ?? 0;
},
set(this: Media, v: number) {
this._t = v;
},
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value: () => Promise.resolve(),
});
Object.defineProperty(HTMLMediaElement.prototype, "pause", { configurable: true, value() {} });
Object.defineProperty(HTMLMediaElement.prototype, "load", { configurable: true, value() {} });
Object.defineProperty(HTMLMediaElement.prototype, "seekable", {
configurable: true,
get: () => ({ length: 0, end: () => 0 }),
});
Object.defineProperty(HTMLMediaElement.prototype, "textTracks", {
configurable: true,
get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }),
});
});
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
hlsInstances.length = 0;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
sessionStorage.clear();
localStorage.clear();
});
const detail = {
id: "A",
title: "Title A",
type: "movie",
duration_seconds: 3000,
status: "new",
position_seconds: 600,
audio_streams: [{ ord: 0, label: "English", language: "en", default: true }],
subtitle_streams: [],
markers: [],
} as unknown as PlexItemDetail;
function mount() {
vi.spyOn(api, "plexItem").mockResolvedValue(detail);
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
const session = vi
.spyOn(api, "plexSession")
.mockResolvedValue({ mode: "hls", url: "/hls/A/index.m3u8", start_s: 600 } as never);
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<PlexPlayer itemId="A" onClose={() => {}} />
</QueryClientProvider>,
);
return session;
}
const lost404 = {
fatal: true,
type: Hls.ErrorTypes.NETWORK_ERROR,
details: "fragLoadError",
response: { code: 404, text: "No active playback session" },
};
describe("an HLS session the server no longer has", () => {
it("is restarted where the viewer is, instead of ending playback", async () => {
// The server reaps a session nobody fetches from (a long pause; a deploy). hls.js never retries
// a 4xx, so the next segment's 404 used to end playback with "couldn't play".
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.MANIFEST_PARSED));
const v = document.querySelector("video") as Media;
v._t = 700; // 600 + 700 − the HLS lag
act(() => void v.dispatchEvent(new Event("timeupdate")));
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
await waitFor(() => expect(session).toHaveBeenCalledTimes(2));
expect(session.mock.calls[1]![1]).toBeCloseTo(1299, 0);
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
});
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.
const session = mount();
await waitFor(() => expect(hlsInstances).toHaveLength(1));
act(() => hlsInstances[0]!.emit(Hls.Events.ERROR, lost404));
await screen.findByText("plex.player.errGeneric");
expect(session).toHaveBeenCalledTimes(1);
});
});