The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
// @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("gives up and says so when the reclaimed session keeps 404ing", async () => {
|
||
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
|
||
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
|
||
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
|
||
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
|
||
// spinner that never became an error.
|
||
const session = mount();
|
||
const v = () => document.querySelector("video") as Media;
|
||
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
|
||
for (let i = 0; i < 3; i++) {
|
||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||
v()._t = 700;
|
||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||
}
|
||
await screen.findByText("plex.player.errGeneric");
|
||
// Two reclaims, then the honest answer — not a fourth, fifth, hundredth session.
|
||
expect(session).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("keeps giving up even though every reclaimed session plays a fragment first", async () => {
|
||
// The budget used to be cleared by the first FRAG_LOADED of any session — and a reclaimed
|
||
// session essentially always delivers one, because `start_session` does not return until its
|
||
// first segment exists. So it bounded NEITHER case its own comment named: measured at 40 loss
|
||
// cycles, the player opened 41 sessions and never showed an error. A
|
||
// fragment is not proof the session can be served; surviving the window is.
|
||
const session = mount();
|
||
const v = () => document.querySelector("video") as Media;
|
||
for (let i = 0; i < 3; i++) {
|
||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||
v()._t = 700;
|
||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||
act(() =>
|
||
hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, {
|
||
frag: { type: "main", duration: 6, stats: { total: 1000 } },
|
||
}),
|
||
);
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||
}
|
||
await screen.findByText("plex.player.errGeneric");
|
||
expect(session).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("starts the budget over for a loss long after the last one", async () => {
|
||
// The other half: a session reaped once an hour is not a session that cannot be served. The
|
||
// window is what separates the two, so a spent budget must not follow the viewer around.
|
||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||
try {
|
||
const session = mount();
|
||
const v = () => document.querySelector("video") as Media;
|
||
for (let i = 0; i < 4; i++) {
|
||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||
v()._t = 700;
|
||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||
act(() => void vi.setSystemTime(Date.now() + 10 * 60_000));
|
||
}
|
||
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
|
||
expect(session).toHaveBeenCalledTimes(5);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("gives up on a session lost over and over on a SLOW cadence too", async () => {
|
||
// The window's anchor used to be written only where the window RESET, so it marked the start of
|
||
// the window instead of the last loss — a tumbling window, not the rolling one the constant is
|
||
// named for. Any cadence slower than half the window then cleared the budget on every second
|
||
// loss and the bound never bit: a transcode falling ~70s behind realtime — the case the ERROR
|
||
// handler itself names — reclaimed forever (measured: 200 losses 70s apart → 200 reclaims,
|
||
// never an error). The two cases above space their losses 0s and 10min apart, i.e. both sit
|
||
// outside the 60–120s band where the defect lived.
|
||
vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ["Date"] });
|
||
try {
|
||
const session = mount();
|
||
const v = () => document.querySelector("video") as Media;
|
||
for (let i = 0; i < 6 && !screen.queryByText("plex.player.errGeneric"); i++) {
|
||
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
|
||
v()._t = 700;
|
||
act(() => void v().dispatchEvent(new Event("timeupdate")));
|
||
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
|
||
act(() => void vi.setSystemTime(Date.now() + 70_000));
|
||
}
|
||
await screen.findByText("plex.player.errGeneric");
|
||
// Two reclaims, each within two minutes of the loss before it — then the honest answer.
|
||
expect(session).toHaveBeenCalledTimes(3);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
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, 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(3);
|
||
});
|
||
});
|