Clicking a collection tile now opens a dedicated Collection info page (like the Plex-web
one) instead of just applying it as a filter. New GET /collections/{rk} returns the hero
aggregates (year range, most-restrictive member cert, total runtime) + the members ordered
by release year then title; a new PlexCollectionView renders the hero (art/title/meta/
summary+More) and the member grid, with a Play-all and Shuffle over the playable leaves
(reusing the player's existing queue + auto-advance).
For a non-external, admin-enabled collection the page offers rename / delete / add-title
(a search picker) / remove-item, all through the pre-existing admin endpoints that write
THROUGH to Plex (each edit awaits and re-fetches — no optimism, since a Plex write can 422).
An eligible collection shows "Enable editing" first (the DB editable flag). Read-only ones
(smart / imdb / tmdb / … auto-lists) are marked with a lock on both the info page AND the
browse tile, up front.
New `{ kind: "collection" }` sub-view wired through plexSubUrl (bookmarkable ?v=collection:rk),
PlexBrowse (render branch, Backspace back-nav, tile handler), with a round-trip test.
Verified live: tile→page, hero + chronological members (James Bond 1962→2021, PG-13, 55h),
Play/Shuffle, Enable editing → edit controls + remove-X, the add search (marks already-in
titles "Added"), and the read-only lock on external collections. Real Plex mutations wired
but not executed against the live account.
Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 6 collections tests
(+ a detail-hero/ordering assertion).
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
// 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=<kind>:<args>", () => {
|
|
expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4)
|
|
expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2)
|
|
expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2)
|
|
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");
|
|
expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5");
|
|
});
|
|
|
|
it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => {
|
|
expect(U.encode({ kind: "discover" })).toBeNull();
|
|
expect(U.encode({ kind: "player", id: "m9" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("PLEX_SUB_URL round-trip", () => {
|
|
const cases: Sub[] = [
|
|
{ kind: "grid" },
|
|
{ kind: "playlists" },
|
|
{ kind: "collections" },
|
|
{ kind: "show", id: "s1" },
|
|
{ kind: "season", showId: "s1", seasonId: "se2" },
|
|
{ kind: "info", id: "m9" },
|
|
{ kind: "person", name: "Jane Doe" },
|
|
{ kind: "playlist", id: 12 },
|
|
{ kind: "collection", id: "c5" },
|
|
];
|
|
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 / collection", () => {
|
|
expect(U.decode("show:")).toBeNull();
|
|
expect(U.decode("info:")).toBeNull();
|
|
expect(U.decode("person:")).toBeNull();
|
|
expect(U.decode("collection:")).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();
|
|
});
|
|
});
|