diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 9fe25528..545d4770 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -21,7 +21,8 @@ import { } from "../lib/api"; import { plexLang } from "../i18n"; import { useDebounced } from "../lib/useDebounced"; -import { useHistorySubview, type SubviewUrl } from "../lib/history"; +import { useHistorySubview } from "../lib/history"; +import { PLEX_SUB_URL, type Sub } from "./plexSubUrl"; import { toggleWatchState } from "../lib/plexWatch"; import SortControl from "./SortControl"; import { PageToolbar } from "./PageShell"; @@ -36,63 +37,6 @@ import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./pl const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); -type Sub = - | { kind: "grid" } - | { kind: "show"; id: string } - | { kind: "season"; showId: string; seasonId: string } - | { kind: "player"; id: string; queue?: string[] } - | { kind: "info"; id: string } - | { kind: "person"; name: string } - | { kind: "playlist"; id: number }; - -// Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page -// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person -// name is the whole tail after the first ":", so a name containing ":" round-trips. -const PLEX_SUB_URL: SubviewUrl = { - key: "v", - encode: (v) => { - switch (v.kind) { - case "show": - return `show:${v.id}`; - case "season": - return `season:${v.showId}:${v.seasonId}`; - case "info": - return `info:${v.id}`; - case "person": - return `person:${v.name}`; - case "playlist": - return `playlist:${v.id}`; - default: - return null; // grid + the ephemeral player are not URL-mirrored - } - }, - decode: (raw) => { - const i = raw.indexOf(":"); - const kind = i === -1 ? raw : raw.slice(0, i); - const arg = i === -1 ? "" : raw.slice(i + 1); - switch (kind) { - case "show": - return arg ? { kind: "show", id: arg } : null; - case "info": - return arg ? { kind: "info", id: arg } : null; - case "person": - return arg ? { kind: "person", name: arg } : null; - case "playlist": { - // Positive-integer id only — a fractional/zero/negative ?v=playlist: would open a - // sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId). - const id = arg ? Number(arg) : NaN; - return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null; - } - case "season": { - const [showId, seasonId] = arg.split(":"); - return showId && seasonId ? { kind: "season", showId, seasonId } : null; - } - default: - return null; - } - }, -}; - // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the // search term comes from the shared top Header search box (q). A show drill-down rides history diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts new file mode 100644 index 00000000..5a153802 --- /dev/null +++ b/frontend/src/components/plexSubUrl.test.ts @@ -0,0 +1,69 @@ +// PLEX_SUB_URL encode/decode round-trip + guards. Extracted from PlexBrowse for this test (E4 +// deep-review deferred gap). Pure — no DOM, no history. +import { describe, it, expect } from "vitest"; +import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl"; + +describe("PLEX_SUB_URL encode", () => { + it("mirrors each bookmarkable sub-view to ?v=:", () => { + expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1"); + expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2"); + expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9"); + expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe"); + expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12"); + }); + + it("does NOT mirror the root grid or the ephemeral player (encode → null)", () => { + expect(U.encode({ kind: "grid" })).toBeNull(); + expect(U.encode({ kind: "player", id: "m9" })).toBeNull(); + }); +}); + +describe("PLEX_SUB_URL round-trip", () => { + const cases: Sub[] = [ + { kind: "show", id: "s1" }, + { kind: "season", showId: "s1", seasonId: "se2" }, + { kind: "info", id: "m9" }, + { kind: "person", name: "Jane Doe" }, + { kind: "playlist", id: 12 }, + ]; + it.each(cases)("encode→decode is identity for %o", (view) => { + const raw = U.encode(view); + expect(raw).not.toBeNull(); + expect(U.decode(raw as string)).toEqual(view); + }); + + it("a person name containing ':' round-trips (tail after the first colon)", () => { + const view: Sub = { kind: "person", name: "Dr: Strange: Origins" }; + expect(U.decode(U.encode(view) as string)).toEqual(view); + }); +}); + +describe("PLEX_SUB_URL decode guards", () => { + it("rejects a playlist id that is not a positive integer", () => { + expect(U.decode("playlist:")).toBeNull(); // Number("")===0, not >0 + expect(U.decode("playlist:0")).toBeNull(); + expect(U.decode("playlist:-1")).toBeNull(); + expect(U.decode("playlist:1.5")).toBeNull(); + expect(U.decode("playlist:abc")).toBeNull(); + expect(U.decode("playlist:12")).toEqual({ kind: "playlist", id: 12 }); + }); + + it("requires both ids for a season", () => { + expect(U.decode("season:s1")).toBeNull(); + expect(U.decode("season:")).toBeNull(); + expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" }); + }); + + it("requires a non-empty arg for show / info / person", () => { + expect(U.decode("show:")).toBeNull(); + expect(U.decode("info:")).toBeNull(); + expect(U.decode("person:")).toBeNull(); + }); + + it("rejects an unknown kind or garbage", () => { + expect(U.decode("grid")).toBeNull(); + expect(U.decode("player:m9")).toBeNull(); // player is not URL-restorable + expect(U.decode("bogus")).toBeNull(); + expect(U.decode("")).toBeNull(); + }); +}); diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts new file mode 100644 index 00000000..d3fc2816 --- /dev/null +++ b/frontend/src/components/plexSubUrl.ts @@ -0,0 +1,61 @@ +import type { SubviewUrl } from "../lib/history"; + +// The Plex module's sub-view union and its address-bar mirror, extracted from PlexBrowse so the +// encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component +// (and its lazy hls.js player) into the test graph. +export type Sub = + | { kind: "grid" } + | { kind: "show"; id: string } + | { kind: "season"; showId: string; seasonId: string } + | { kind: "player"; id: string; queue?: string[] } + | { kind: "info"; id: string } + | { kind: "person"; name: string } + | { kind: "playlist"; id: number }; + +// Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page +// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person +// name is the whole tail after the first ":", so a name containing ":" round-trips. +export const PLEX_SUB_URL: SubviewUrl = { + key: "v", + encode: (v) => { + switch (v.kind) { + case "show": + return `show:${v.id}`; + case "season": + return `season:${v.showId}:${v.seasonId}`; + case "info": + return `info:${v.id}`; + case "person": + return `person:${v.name}`; + case "playlist": + return `playlist:${v.id}`; + default: + return null; // grid + the ephemeral player are not URL-mirrored + } + }, + decode: (raw) => { + const i = raw.indexOf(":"); + const kind = i === -1 ? raw : raw.slice(0, i); + const arg = i === -1 ? "" : raw.slice(i + 1); + switch (kind) { + case "show": + return arg ? { kind: "show", id: arg } : null; + case "info": + return arg ? { kind: "info", id: arg } : null; + case "person": + return arg ? { kind: "person", name: arg } : null; + case "playlist": { + // Positive-integer id only — a fractional/zero/negative ?v=playlist: would open a + // sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId). + const id = arg ? Number(arg) : NaN; + return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null; + } + case "season": { + const [showId, seasonId] = arg.split(":"); + return showId && seasonId ? { kind: "season", showId, seasonId } : null; + } + default: + return null; + } + }, +};