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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user