diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index e7850720..4a4ceff2 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -14,12 +14,29 @@ from app.models import ( PlexShow, User, ) +from app.plex import rollup from app.plex import sync as plex_sync +from app.plex.cards import movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key from app.plex.query import like_escape, runtime_by_key, top_art_keys from app.routes.plex._shared import _content_libs +# The most-restrictive → least ordering used to pick a collection's effective content rating from its +# members (Plex shows one cert on the collection; we don't store it, so derive it). +_CERT_RANK = { + "G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1, + "PG": 2, "TV-PG": 2, + "PG-13": 3, "TV-14": 3, + "R": 4, "TV-MA": 4, + "NC-17": 5, +} + + +def _collection_cert(certs: list[str | None]) -> str | None: + ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c] + return max(ranked, key=lambda x: x[1])[0] if ranked else None + router = APIRouter(prefix="/api/plex", tags=["plex"]) _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") @@ -122,6 +139,50 @@ def collections( } +@router.get("/collections/{rating_key}") +def collection_detail( + rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """One collection's hero data + its members (movies + shows), ordered by release year then title — + the Collection info page. Read-only here; edits go through the admin mutation endpoints below.""" + col = by_rating_key(db, PlexCollection, rating_key) + if col is None: + raise HTTPException(status_code=404, detail="Unknown collection") + movies = db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key])) + .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) + ).scalars().all() + shows = db.execute( + select(PlexShow) + .where(PlexShow.collection_keys.contains([rating_key])) + .order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title)) + ).scalars().all() + mstate = rollup.item_state_map(db, user.id, [m.id for m in movies]) + sstate = rollup.state_map(db, user.id, [s.id for s in shows]) + # Merge the two type-homogeneous lists into ONE member list ordered by (year, title). + tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [ + (s.year, (s.title or "").lower(), "show", s) for s in shows + ] + tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1])) + items = [ + movie_card(o, mstate.get(o.id)) if kind == "movie" else show_card(o, sstate.get(o.id, "new")) + for _, _, kind, o in tagged + ] + years = [y for y, _, _, _ in tagged if y is not None] + certs = [m.content_rating for m in movies] + [s.content_rating for s in shows] + return { + **_collection_card(col), + "content_rating": _collection_cert(certs), + "year_min": min(years) if years else None, + "year_max": max(years) if years else None, + "total_runtime_s": sum(m.duration_s or 0 for m in movies), + "items": items, + } + + def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: col = by_rating_key(db, PlexCollection, rating_key) if col is None: diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index de4e5496..1b028e1e 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -94,6 +94,29 @@ def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch): assert card["total_runtime_s"] == 5400 # 3600 + 1800 +def test_collection_detail_hero_and_ordered_members(db, monkeypatch): + # The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) + + # members ordered by release year then title. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2)) + common = dict(library_id=lib.id, kind="movie", collection_keys=["A"]) + db.add_all([ + PlexItem(rating_key="later", title="Later", year=1996, duration_s=3600, content_rating="PG", **common), + PlexItem(rating_key="early", title="Early", year=1992, duration_s=1800, content_rating="G", **common), + ]) + user = User(email="cd@example.com") + db.add(user) + db.commit() + d = _get(db, user, monkeypatch, "/api/plex/collections/A").json() + assert d["title"] == "Coll A" and d["summary"] == "S" + assert d["year_min"] == 1992 and d["year_max"] == 1996 + assert d["content_rating"] == "PG" # most restrictive of {G, PG} + assert d["total_runtime_s"] == 5400 + assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order + + def test_show_collection_gets_montage_art_from_shows(db, monkeypatch): # A show-library collection must get its montage from PlexShow members (else it renders blank) — the # A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration). diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index deef4f58..93da008d 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -42,6 +42,7 @@ import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./pl // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); +const PlexCollectionView = lazy(() => import("./PlexCollectionView")); // 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 @@ -173,6 +174,7 @@ export default function PlexBrowse() { "info", "person", "playlist", + "collection", ].includes(sub.view.kind) ) { e.preventDefault(); @@ -341,6 +343,20 @@ export default function PlexBrowse() { ); } + if (sub.view.kind === "collection") { + const cid = sub.view.id; + return ( + {t("plex.loading")}

}> + sub.open({ kind: "player", id: rk, queue })} + onOpenCard={onCard} + onInfoCard={onInfo} + /> +
+ ); + } if (sub.view.kind === "info") { const infoId = sub.view.id; return ( @@ -431,11 +447,7 @@ export default function PlexBrowse() { if (sub.view.kind === "collections") { return (
- - applyFilter({ collections: [id], collectionTitles: { [id]: title } }) - } - /> + sub.open({ kind: "collection", id })} />
); } diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx new file mode 100644 index 00000000..c1c53eb6 --- /dev/null +++ b/frontend/src/components/PlexCollectionView.tsx @@ -0,0 +1,417 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { api, type PlexCard, type PlexCollectionDetail } from "../lib/api"; +import { formatRuntime } from "../lib/format"; +import { notifyActionError } from "../lib/actionErrors"; +import { useDebounced } from "../lib/useDebounced"; +import { useConfirm } from "./ConfirmProvider"; +import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; +import { PlexPosterCard } from "./plexCards"; +import { toggleWatchState } from "../lib/plexWatch"; +import { LoadingState, StateMessage } from "./QueryState"; + +// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the +// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external, +// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation +// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart +// / imdb / tmdb / … auto-lists) are marked with a lock. +function shuffled(a: string[]): string[] { + const r = [...a]; + for (let i = r.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = r[i]!; + r[i] = r[j]!; + r[j] = tmp; + } + return r; +} + +export default function PlexCollectionView({ + collectionId, + onBack, + onPlay, + onOpenCard, + onInfoCard, +}: { + collectionId: string; + onBack: () => void; + onPlay: (rk: string, queue: string[]) => void; + onOpenCard: (card: PlexCard) => void; + onInfoCard: (card: PlexCard) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; + const q = useQuery({ + queryKey: qk.plexCollections("detail", collectionId), + queryFn: () => api.plexCollection(collectionId), + }); + const d = q.data; + + const [expanded, setExpanded] = useState(false); + const [renaming, setRenaming] = useState(false); + const [name, setName] = useState(""); + const [adding, setAdding] = useState(false); + const [busy, setBusy] = useState(false); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: qk.plexCollections() }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + }; + + const items = d?.items ?? []; + // The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly. + const playable = items.filter((c) => c.type !== "show").map((c) => c.id); + const canEdit = isAdmin && !!d?.can_edit; + const canEnableEdit = + isAdmin && !d?.can_edit && !d?.editable && !d?.smart && d?.source === "collection"; + const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list + + const toggleWatched = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => { + qc.setQueryData(qk.plexCollections("detail", collectionId), (old) => + old + ? { + ...old, + items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)), + } + : old, + ); + }, + resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), + }); + + async function doRename() { + const title = name.trim(); + if (!title || !d || title === d.title) { + setRenaming(false); + return; + } + setBusy(true); + try { + await api.plexRenameCollection(collectionId, title); + invalidate(); + setRenaming(false); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } finally { + setBusy(false); + } + } + + async function doDelete() { + if (!d) return; + if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) }))) + return; + setBusy(true); + try { + await api.plexDeleteCollection(collectionId); + invalidate(); + onBack(); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + setBusy(false); + } + } + + async function doRemove(card: PlexCard) { + setBusy(true); + try { + await api.plexCollectionRemoveItem(collectionId, card.id); + invalidate(); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } finally { + setBusy(false); + } + } + + async function enableEdit() { + setBusy(true); + try { + await api.plexSetCollectionEditable(collectionId, true); + invalidate(); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } finally { + setBusy(false); + } + } + + const meta = [ + d?.year_min != null && + (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`), + d?.content_rating, + d && t("plex.browse.items", { count: d.child_count ?? items.length }), + formatRuntime(d?.total_runtime_s), + ].filter(Boolean) as string[]; + + return ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( + <> + 0} + onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)} + customizeMenu={null} + > + {renaming ? ( + setName(e.target.value)} + onBlur={doRename} + onKeyDown={(e) => { + if (e.key === "Enter") doRename(); + if (e.key === "Escape") setRenaming(false); + }} + aria-label={t("plex.collection.rename")} + className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + ) : ( +
+

{d.title}

+ {readOnly && ( + + + {t("plex.collection.readOnly")} + + )} +
+ )} + + {meta.length > 0 && ( +
+ {meta.map((m, i) => ( + + {i > 0 && "· "} + {m} + + ))} +
+ )} + + {d.summary && ( +
+

+ {d.summary} +

+ +
+ )} + +
+ {playable.length > 0 && ( + <> + onPlay(playable[0]!, playable)} + icon={Play} + label={t("plex.collection.playAll")} + /> + { + const order = shuffled(playable); + onPlay(order[0]!, order); + }} + icon={Shuffle} + label={t("plex.collection.shuffle")} + /> + + )} + {canEdit && ( + <> + { + setName(d.title); + setRenaming(true); + }} + icon={Pencil} + label={t("plex.collection.rename")} + /> + setAdding((v) => !v)} + icon={Plus} + label={t("plex.collection.addItem")} + /> + + + )} + {canEnableEdit && ( + + )} +
+
+ + {adding && canEdit && ( + c.type === "show") ? "show" : "movie"} + existing={new Set(items.map((c) => c.id))} + onAdded={invalidate} + onClose={() => setAdding(false)} + /> + )} + + {items.length === 0 ? ( +

{t("plex.collection.empty")}

+ ) : ( +
+ {items.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggleWatched} + /> + {canEdit && ( + + )} +
+ ))} +
+ )} + + )} +
+ ); +} + +// A compact search-and-add panel: search the collection's library, click a result to add it (write- +// through to Plex). Stays open so several items can be added in a row; already-present items are marked. +function AddItemPanel({ + collectionId, + scope, + existing, + onAdded, + onClose, +}: { + collectionId: string; + scope: string; + existing: Set; + onAdded: () => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [term, setTerm] = useState(""); + const debounced = useDebounced(term.trim(), 300); + const [added, setAdded] = useState>(new Set()); + const q = useQuery({ + queryKey: qk.plexLibrary("collection-add", scope, debounced), + queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }), + enabled: debounced.length > 0, + }); + const results = q.data?.items ?? []; + + async function add(card: PlexCard) { + try { + await api.plexCollectionAddItem(collectionId, card.id); + setAdded((s) => new Set(s).add(card.id)); + onAdded(); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } + } + + return ( +
+
+ + setTerm(e.target.value)} + placeholder={t("plex.collection.addSearch")} + aria-label={t("plex.collection.addSearch")} + className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + +
+ {debounced.length === 0 ? ( +

{t("plex.collection.addHint")}

+ ) : q.isLoading ? ( + + ) : results.length === 0 ? ( +

{t("plex.collection.addNoResults")}

+ ) : ( +
    + {results.map((c) => { + const inColl = existing.has(c.id) || added.has(c.id); + return ( +
  • + + + {c.title} + {c.year != null && · {c.year}} + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index efe7ba82..3a725550 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { useQuery } from "@tanstack/react-query"; -import { ListMusic } from "lucide-react"; +import { ListMusic, Lock } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCollection } from "../lib/api"; import { formatRuntime } from "../lib/format"; @@ -20,6 +20,12 @@ function collectionMeta(c: PlexCollection, t: TFunction): string { return parts.join(" · "); } +// A read-only collection (a smart or external auto-list — imdb/tmdb/…) can never be edited; mark it up +// front on the tile, mirroring the badge on the info page. +function isReadOnlyCollection(c: PlexCollection): boolean { + return !!c.smart || (!!c.source && c.source !== "collection"); +} + // The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, // each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the // title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections @@ -100,6 +106,14 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: title={c.title} art={c.art} subtitle={collectionMeta(c, t)} + topRight={ + isReadOnlyCollection(c) ? ( + + ) : undefined + } onClick={() => onPick(c.id, c.title)} className="h-28" /> diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index fdd6be2b..664e92a3 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -13,6 +13,7 @@ describe("PLEX_SUB_URL encode", () => { 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)", () => { @@ -31,6 +32,7 @@ describe("PLEX_SUB_URL round-trip", () => { { 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); @@ -60,10 +62,11 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" }); }); - it("requires a non-empty arg for show / info / person", () => { + 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", () => { diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index 4a558699..43d0c5b6 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -14,7 +14,8 @@ export type Sub = | { kind: "player"; id: string; queue?: string[] } | { kind: "info"; id: string } | { kind: "person"; name: string } - | { kind: "playlist"; id: number }; + | { kind: "playlist"; id: number } + | { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key) // Mirror the Plex sub-view to ?v=: 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 @@ -41,6 +42,8 @@ export const PLEX_SUB_URL: SubviewUrl = { 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 } @@ -68,6 +71,8 @@ export const PLEX_SUB_URL: SubviewUrl = { 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; diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 65ea8100..48ee9e5d 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -2,6 +2,26 @@ "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Back to library", + "collection": { + "playAll": "Play all", + "shuffle": "Shuffle", + "rename": "Rename", + "addItem": "Add title", + "delete": "Delete", + "enableEdit": "Enable editing", + "removeItem": "Remove from collection", + "readOnly": "Read-only", + "empty": "This collection has no items.", + "more": "More", + "less": "Less", + "addSearch": "Search titles to add…", + "addHint": "Type to search for a title to add.", + "addNoResults": "No matches.", + "add": "Add", + "added": "Added", + "deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.", + "editFailed": "Collection edit failed" + }, "searchPlaceholder": "Search Plex…", "searchField": { "aria": "Search scope", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index f5f99336..ad2869b4 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -2,6 +2,26 @@ "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Vissza a könyvtárhoz", + "collection": { + "playAll": "Összes lejátszása", + "shuffle": "Keverés", + "rename": "Átnevezés", + "addItem": "Cím hozzáadása", + "delete": "Törlés", + "enableEdit": "Szerkesztés engedélyezése", + "removeItem": "Eltávolítás a kollekcióból", + "readOnly": "Csak olvasható", + "empty": "Ennek a kollekciónak nincsenek elemei.", + "more": "Több", + "less": "Kevesebb", + "addSearch": "Keress címet a hozzáadáshoz…", + "addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.", + "addNoResults": "Nincs találat.", + "add": "Hozzáad", + "added": "Hozzáadva", + "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.", + "editFailed": "A kollekció szerkesztése sikertelen" + }, "searchPlaceholder": "Keresés a Plexben…", "searchField": { "aria": "Keresési hatókör", diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index c5e92668..7db0bb8c 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -187,6 +187,13 @@ export interface PlexCollection { art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only) total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only) } +// The Collection info page: the card fields + hero aggregates + the ordered members. +export interface PlexCollectionDetail extends PlexCollection { + content_rating?: string | null; + year_min?: number | null; + year_max?: number | null; + items: PlexCard[]; +} export interface PlexPlaylist { id: number; title: string; @@ -417,6 +424,9 @@ export const plexApi = { const qs = u.toString(); return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); }, + // One collection's hero + ordered members (the Collection info page). + plexCollection: (rk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}`), // --- Collection editing (admin only; writes back to Plex) --- plexCreateCollection: ( library: string,