test(player): hold the session stop and the kept-point rule to the server
- The client's resume thresholds equal the server's (read from both sources). - The session stop's URL matches the route the server serves it on. - The player stops its server session by its item and its own id on an unload and on close, and keeps it when the page only goes into the back/forward cache.
This commit is contained in:
@@ -23,9 +23,10 @@ M = TypeVar("M", bound=Base)
|
|||||||
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
|
# Resume-position thresholds of a player that only keeps a resume point (the YouTube player, the
|
||||||
# library's): positions below this are "didn't really start" and within this of the end are
|
# library's): positions below this are "didn't really start" and within this of the end are
|
||||||
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
|
# "basically finished" — neither is worth storing, so the position is cleared instead (no progress
|
||||||
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers. NB: the Plex player
|
# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by
|
||||||
# intentionally uses a LARGER finish margin (30s) because it marks long-form items watched, not just
|
# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin
|
||||||
# clears a resume bar — see routes/plex/playback.py.
|
# (30s) because it marks long-form items watched, not just clears a resume bar — see
|
||||||
|
# routes/plex/playback.py.
|
||||||
PROGRESS_MIN_SECONDS = 5
|
PROGRESS_MIN_SECONDS = 5
|
||||||
FINISH_MARGIN_SECONDS = 10
|
FINISH_MARGIN_SECONDS = 10
|
||||||
|
|
||||||
|
|||||||
@@ -731,3 +731,38 @@ it("a session answer that lands while the next item's detail is still loading is
|
|||||||
await tick();
|
await tick();
|
||||||
expect(video().getAttribute("src")).not.toBe("/media/B");
|
expect(video().getAttribute("src")).not.toBe("/media/B");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the server session the player no longer wants", () => {
|
||||||
|
const hlsA = { mode: "hls", url: "/hls/A/index.m3u8", start_s: 0, id: "sid-A" } as Awaited<
|
||||||
|
ReturnType<typeof api.plexSession>
|
||||||
|
>;
|
||||||
|
|
||||||
|
it("is stopped by its item and its own id when the page is unloaded", async () => {
|
||||||
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||||
|
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||||
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||||
|
act(
|
||||||
|
() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: false })),
|
||||||
|
);
|
||||||
|
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stopped the same way when the player closes", async () => {
|
||||||
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||||
|
const { unmount } = mount({
|
||||||
|
details: { A: item("A") },
|
||||||
|
sessions: { A: () => Promise.resolve(hlsA) },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||||
|
unmount();
|
||||||
|
expect(stop).toHaveBeenCalledWith("A", "sid-A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is kept when the page only goes into the back/forward cache", async () => {
|
||||||
|
const stop = vi.spyOn(api, "plexStopSession").mockImplementation(() => {});
|
||||||
|
mount({ details: { A: item("A") }, sessions: { A: () => Promise.resolve(hlsA) } });
|
||||||
|
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/hls/A/index.m3u8"));
|
||||||
|
act(() => void window.dispatchEvent(Object.assign(new Event("pagehide"), { persisted: true })));
|
||||||
|
expect(stop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { api } from "../api";
|
||||||
|
|
||||||
|
// The session stop is a keepalive beacon: nothing answers it on screen, so a URL that no longer
|
||||||
|
// matches the server's route fails silently — every transcode the player leaves runs on until the
|
||||||
|
// idle reaper. The route is read from the server's source, as the other contract tests do.
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
function serverRoute(): RegExp {
|
||||||
|
// From the frontend root (vitest's cwd): this file runs under jsdom — `api` boots i18n, which reads
|
||||||
|
// localStorage — and there `import.meta.url` is not a file URL `readFileSync` takes.
|
||||||
|
const source = readFileSync(
|
||||||
|
join(process.cwd(), "../backend/app/routes/plex/playback.py"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const prefix = /APIRouter\(prefix="([^"]+)"/.exec(source)?.[1];
|
||||||
|
const path = /@router\.post\("(\/stream\/[^"]*\/stop)"\)/.exec(source)?.[1];
|
||||||
|
// If either is missing the route was renamed or moved — re-point this test, the rule stands.
|
||||||
|
expect(prefix, "router prefix").toBeDefined();
|
||||||
|
expect(path, "the session stop route").toBeDefined();
|
||||||
|
const pattern = (prefix! + path!).replace(/\{[^}]+\}/g, "[^/]+");
|
||||||
|
return new RegExp(`^${pattern}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sends the session stop to the route the server serves it on", () => {
|
||||||
|
const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null));
|
||||||
|
api.plexStopSession("12345", "a1b2c3");
|
||||||
|
const url = String(fetch.mock.calls[0]![0]);
|
||||||
|
expect(url).toMatch(serverRoute());
|
||||||
|
expect(url).toContain("/12345/");
|
||||||
|
expect(url).toContain("/a1b2c3/");
|
||||||
|
expect(fetch.mock.calls[0]![1]).toMatchObject({ method: "POST", keepalive: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
// One rule, two languages, no shared wire format — so a test is the only thing that holds them
|
||||||
|
// together. Which point a library item keeps is the server's (`routes/_common.kept_position`); the
|
||||||
|
// client repeats it (`lib/resume.ts`) for the shared link, which has no server side, and for the
|
||||||
|
// library row it patches before the server has answered. A server-only change left the row showing
|
||||||
|
// a Continue point the server had already cleared, and a link resuming at points the library no
|
||||||
|
// longer keeps. Both must be EQUAL: neither direction is merely conservative here.
|
||||||
|
//
|
||||||
|
// Read from source rather than imported, as in plexFinishMargin.contract.test.ts.
|
||||||
|
function constantIn(path: string, pattern: RegExp): number {
|
||||||
|
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||||
|
const m = pattern.exec(source);
|
||||||
|
// If this fails the constant was renamed or moved — find it and re-point this test, because the
|
||||||
|
// rule it guards did not go away.
|
||||||
|
expect(m, `${pattern} not found in ${path}`).not.toBeNull();
|
||||||
|
return Number(m![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER = "../../../backend/app/routes/_common.py";
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["the least position that counts as started", "PROGRESS_MIN_SECONDS", "PROGRESS_MIN_S"],
|
||||||
|
["the margin before the end that counts as finished", "FINISH_MARGIN_SECONDS", "FINISH_MARGIN_S"],
|
||||||
|
])("keeps %s the same as the server", (_what, server, client) => {
|
||||||
|
const backend = constantIn(SERVER, new RegExp(`^${server}\\s*=\\s*(\\d+)`, "m"));
|
||||||
|
const frontend = constantIn("./resume.ts", new RegExp(`^const ${client}\\s*=\\s*(\\d+)`, "m"));
|
||||||
|
expect(backend).toBeGreaterThan(0);
|
||||||
|
expect(frontend).toBe(backend);
|
||||||
|
});
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
// Where the point is kept is the caller's: the library's on the server (`library_states`), a shared
|
// Where the point is kept is the caller's: the library's on the server (`library_states`), a shared
|
||||||
// link's in this browser (the viewer has no account). The rule for which point is worth keeping is
|
// link's in this browser (the viewer has no account). The rule for which point is worth keeping is
|
||||||
// the server's own (`routes/_common.kept_position`), repeated here for the link that has no server
|
// the server's own (`routes/_common.kept_position`), repeated here for the link that has no server
|
||||||
// side: too early to count as started, or close enough to the end to count as finished, is "from
|
// side (`resume.contract.test.ts` holds the two numbers equal): too early to count as started, or
|
||||||
// the start".
|
// close enough to the end to count as finished, is "from the start".
|
||||||
import { useEffect, useEffectEvent } from "react";
|
import { useEffect, useEffectEvent } from "react";
|
||||||
|
|
||||||
import { LS, readJSON, writeJSON } from "./storage";
|
import { LS, readJSON, writeJSON } from "./storage";
|
||||||
|
|||||||
Reference in New Issue
Block a user