Files
siftlode/frontend/src/components/plexSubUrl.ts
T
peter 5d99a5f253 feat(plex): Collection info page — hero, play/shuffle, and write-through editing (B)
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).
2026-08-23 04:40:09 +02:00

85 lines
3.2 KiB
TypeScript

import type { SubviewUrl } from "../lib/history";
import { parsePositiveIntId } from "../lib/parseId";
// 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: "discover" }
| { kind: "grid" }
| { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode)
| { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode)
| { 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 }
| { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key)
// Mirror the Plex sub-view to ?v=<kind>:<args> so a show / season / movie-info / person / playlist page
// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the
// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a
// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing
// ":" round-trips.
export const PLEX_SUB_URL: SubviewUrl<Sub> = {
key: "v",
encode: (v) => {
switch (v.kind) {
case "grid":
return "browse";
case "playlists":
return "playlists";
case "collections":
return "collections";
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}`;
case "collection":
return `collection:${v.id}`;
default:
return null; // discover (root) + 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 "browse":
return { kind: "grid" };
case "playlists":
return { kind: "playlists" };
case "collections":
return { kind: "collections" };
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:<arg> would open a
// sub-view for a non-existent id (shares parsePositiveIntId with readSelectedId's ?pl guard).
const id = parsePositiveIntId(arg);
return id != null ? { kind: "playlist", id } : null;
}
case "collection":
return arg ? { kind: "collection", id: arg } : null;
case "season": {
const [showId, seasonId] = arg.split(":");
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
}
default:
return null;
}
},
};