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:
@@ -18,7 +18,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import PlexPlayer from "./PlexPlayer";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { api, HttpError, type PlexItemDetail } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
@@ -276,3 +276,225 @@ it("shows a toast raised while it is open inside itself, not under its scrim", a
|
||||
while (host && !host.contains(toast)) host = host.parentElement;
|
||||
expect(host).not.toBe(document.body); // the player's own host, beside its <video>
|
||||
});
|
||||
|
||||
// --- deep-review round 4 ------------------------------------------------------------------------
|
||||
|
||||
/** A session answer that FAILS when the test says so. */
|
||||
function deferredFailure() {
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<Awaited<ReturnType<typeof api.plexSession>>>((_r, j) => (reject = j));
|
||||
return { promise, reject };
|
||||
}
|
||||
|
||||
const startsAsked = (id: string) =>
|
||||
vi
|
||||
.mocked(api.plexSession)
|
||||
.mock.calls.filter(([i]) => i === id)
|
||||
.map(([, at]) => at);
|
||||
|
||||
describe("leaving an item that was played through", () => {
|
||||
const prefs = (p: object) =>
|
||||
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
|
||||
|
||||
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
|
||||
prefs({ upNext: false });
|
||||
const { qc, progress } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await playingAt("/media/A", 2995);
|
||||
act(() => void video().dispatchEvent(new Event("ended")));
|
||||
await screen.findByText("Title B");
|
||||
await tick();
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.prev"));
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
|
||||
expect(startsAsked("A")[1]).toBe(0);
|
||||
});
|
||||
|
||||
it("sends no checkpoint at the credits when the auto-skip leaves it", async () => {
|
||||
// A final checkpoint at the credits raced the watched mark: landing second, it left the row
|
||||
// watched WITH a resume point, and pushed that point to Plex right after the scrobble.
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
...item("A"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995 }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const { qc, progress } = mount({
|
||||
details: { A: withCredits, B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
const setState = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
// The 'pause' event (and its checkpoint) can land before the player has re-rendered for the
|
||||
// next item — here it lands at once.
|
||||
video().pause = function (this: HTMLMediaElement) {
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
};
|
||||
await playingAt("/media/A", 2910);
|
||||
await screen.findByText("Title B");
|
||||
await tick(); // the pause() that go() queued has fired its events by now
|
||||
expect(setState).toHaveBeenCalledWith("A", "watched");
|
||||
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([]);
|
||||
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
|
||||
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restarting the session before playback has ticked", () => {
|
||||
it("an audio switch or a keyboard seek keeps the resume point", async () => {
|
||||
const twoAudio = {
|
||||
...item("A", 1200),
|
||||
audio_streams: [
|
||||
{ ord: 0, label: "English", language: "en", default: true },
|
||||
{ ord: 1, label: "Magyar", language: "hu", default: false },
|
||||
],
|
||||
} as unknown as PlexItemDetail;
|
||||
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
|
||||
fireEvent.keyDown(window, { key: "a" });
|
||||
fireEvent.keyDown(window, { key: "ArrowRight" });
|
||||
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
|
||||
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a seek made while a restart is pending wins over it", async () => {
|
||||
let calls = 0;
|
||||
const pending = deferred();
|
||||
mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () => {
|
||||
calls++;
|
||||
if (calls === 1) return Promise.resolve(direct("A"));
|
||||
return calls === 2 ? pending.promise : new Promise(() => {});
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
Object.defineProperty(video(), "seekable", { get: () => ({ length: 1, end: () => 100 }) });
|
||||
await playingAt("/media/A", 95);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10")); // 105: past the buffer → restart
|
||||
fireEvent.click(screen.getByLabelText("plex.player.rewind10")); // 95: inside the OLD buffer
|
||||
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
|
||||
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
|
||||
});
|
||||
|
||||
it("a direct-play item left before its metadata does not seek the next one", async () => {
|
||||
mount({
|
||||
details: { A: item("A", 1200), B: item("B") },
|
||||
sessions: { A: () => Promise.resolve(direct("A")), B: () => Promise.resolve(direct("B")) },
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/B"));
|
||||
act(() => void video().dispatchEvent(new Event("loadedmetadata")));
|
||||
expect(video().currentTime).toBe(0); // not A's resume point, 1200
|
||||
});
|
||||
|
||||
it("a refetch of the playing item's detail does not restart it", async () => {
|
||||
// react-query refetches it on reconnect, and the in-player info panel invalidates it after a
|
||||
// watched toggle — each one restarted the film at the last saved checkpoint.
|
||||
const details = { A: item("A", 100) };
|
||||
const { qc } = mount({ details, sessions: { A: () => Promise.resolve(direct("A")) } });
|
||||
await playingAt("/media/A", 1500);
|
||||
details.A = item("A", 1490);
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: qk.plexItem("A") });
|
||||
});
|
||||
await tick();
|
||||
expect(vi.mocked(api.plexItem).mock.calls.length).toBeGreaterThan(1); // it did refetch
|
||||
expect(startsAsked("A")).toEqual([100]);
|
||||
});
|
||||
|
||||
describe("a session request that fails", () => {
|
||||
it("is not reported when it has been superseded", async () => {
|
||||
const slowB = deferredFailure();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("Title B");
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/C"));
|
||||
await act(async () => slowB.reject(new HttpError(404)));
|
||||
await tick();
|
||||
expect(screen.queryByText("plex.player.errNotFound")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps following the old stream when a seek-restart fails", async () => {
|
||||
let calls = 0;
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A") },
|
||||
sessions: {
|
||||
A: () =>
|
||||
calls++ === 0 ? Promise.resolve(direct("A")) : Promise.reject(new HttpError(500)),
|
||||
},
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.forward10"));
|
||||
await screen.findByText("plex.player.errGeneric");
|
||||
await playingAt("/media/A", 1600); // the old stream plays on
|
||||
unmount();
|
||||
expect(progress.mock.calls).toContainEqual(["A", 1600, 3000, true]);
|
||||
});
|
||||
|
||||
it("saves nothing of the previous item's position when moving to the next one fails", async () => {
|
||||
const { progress, unmount } = mount({
|
||||
details: { A: item("A"), B: item("B") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => Promise.reject(new HttpError(404)),
|
||||
},
|
||||
queue: ["A", "B"],
|
||||
});
|
||||
await playingAt("/media/A", 1500);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await screen.findByText("plex.player.errNotFound");
|
||||
await tick();
|
||||
await playingAt("/media/A", 1600); // A's media, still attached, ticks once more
|
||||
unmount();
|
||||
expect(progress.mock.calls.filter(([id]) => id === "B")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("a session answer that lands while the next item's detail is still loading is dropped", async () => {
|
||||
const slowB = deferred();
|
||||
mount({
|
||||
details: { A: item("A"), B: item("B"), C: item("C") },
|
||||
sessions: {
|
||||
A: () => Promise.resolve(direct("A")),
|
||||
B: () => slowB.promise,
|
||||
C: () => Promise.resolve(direct("C")),
|
||||
},
|
||||
queue: ["A", "B", "C"],
|
||||
});
|
||||
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
|
||||
const real = vi.mocked(api.plexItem).getMockImplementation()!;
|
||||
vi.mocked(api.plexItem).mockImplementation((id: string, lang?: string) =>
|
||||
id === "C" ? new Promise(() => {}) : real(id, lang),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await waitFor(() => expect(startsAsked("B")).toHaveLength(1));
|
||||
fireEvent.click(screen.getByLabelText("plex.player.next"));
|
||||
await tick();
|
||||
await act(async () => slowB.resolve(direct("B")));
|
||||
await tick();
|
||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user