diff --git a/backend/app/routes/_common.py b/backend/app/routes/_common.py index d70a12aa..51cd22c4 100644 --- a/backend/app/routes/_common.py +++ b/backend/app/routes/_common.py @@ -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 # 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 -# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers. NB: the Plex player -# intentionally uses a LARGER finish margin (30s) because it marks long-form items watched, not just -# clears a resume bar — see routes/plex/playback.py. +# bar). The client's local resume (`lib/resume.ts`) keeps the same two numbers, held equal by +# `resume.contract.test.ts` beside it. NB: the Plex player intentionally uses a LARGER finish margin +# (30s) because it marks long-form items watched, not just clears a resume bar — see +# routes/plex/playback.py. PROGRESS_MIN_SECONDS = 5 FINISH_MARGIN_SECONDS = 10 diff --git a/frontend/src/components/PlexPlayer.session.dom.test.tsx b/frontend/src/components/PlexPlayer.session.dom.test.tsx index 2a2b3603..7bfe4579 100644 --- a/frontend/src/components/PlexPlayer.session.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.session.dom.test.tsx @@ -731,3 +731,38 @@ it("a session answer that lands while the next item's detail is still loading is await tick(); 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 + >; + + 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(); + }); +}); diff --git a/frontend/src/lib/api/plexStream.contract.test.ts b/frontend/src/lib/api/plexStream.contract.test.ts new file mode 100644 index 00000000..3565d73b --- /dev/null +++ b/frontend/src/lib/api/plexStream.contract.test.ts @@ -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 }); +}); diff --git a/frontend/src/lib/resume.contract.test.ts b/frontend/src/lib/resume.contract.test.ts new file mode 100644 index 00000000..4ab31850 --- /dev/null +++ b/frontend/src/lib/resume.contract.test.ts @@ -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); +}); diff --git a/frontend/src/lib/resume.ts b/frontend/src/lib/resume.ts index c0f49428..20d2e093 100644 --- a/frontend/src/lib/resume.ts +++ b/frontend/src/lib/resume.ts @@ -6,8 +6,8 @@ // 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 // 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 -// the start". +// side (`resume.contract.test.ts` holds the two numbers equal): too early to count as started, or +// close enough to the end to count as finished, is "from the start". import { useEffect, useEffectEvent } from "react"; import { LS, readJSON, writeJSON } from "./storage";