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).
This commit is contained in:
2026-08-23 04:40:09 +02:00
parent c0d869089d
commit 5d99a5f253
10 changed files with 593 additions and 8 deletions
+61
View File
@@ -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:
@@ -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).
+17 -5
View File
@@ -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() {
</Suspense>
);
}
if (sub.view.kind === "collection") {
const cid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexCollectionView
collectionId={cid}
onBack={sub.back}
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
onOpenCard={onCard}
onInfoCard={onInfo}
/>
</Suspense>
);
}
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 (
<div className="p-4 max-w-[1600px] mx-auto">
<PlexCollectionsBrowse
onPick={(id, title) =>
applyFilter({ collections: [id], collectionTitles: { [id]: title } })
}
/>
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
</div>
);
}
@@ -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<PlexCollectionDetail>(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 (
<div className="glass-media mx-auto max-w-[1200px] p-4">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
posterTitle={t("plex.play")}
posterDisabled={playable.length === 0}
showPlayOverlay={playable.length > 0}
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
customizeMenu={null}
>
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => 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"
/>
) : (
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
{readOnly && (
<span
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
title={t("plex.collection.readOnly")}
>
<Lock className="h-3 w-3" />
{t("plex.collection.readOnly")}
</span>
)}
</div>
)}
{meta.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
{meta.map((m, i) => (
<span key={i}>
{i > 0 && "· "}
{m}
</span>
))}
</div>
)}
{d.summary && (
<div className="mt-3">
<p
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
>
{d.summary}
</p>
<button
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-xs font-medium text-accent hover:underline"
>
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
</button>
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
{playable.length > 0 && (
<>
<ActionBtn
primary
onClick={() => onPlay(playable[0]!, playable)}
icon={Play}
label={t("plex.collection.playAll")}
/>
<ActionBtn
onClick={() => {
const order = shuffled(playable);
onPlay(order[0]!, order);
}}
icon={Shuffle}
label={t("plex.collection.shuffle")}
/>
</>
)}
{canEdit && (
<>
<ActionBtn
onClick={() => {
setName(d.title);
setRenaming(true);
}}
icon={Pencil}
label={t("plex.collection.rename")}
/>
<ActionBtn
onClick={() => setAdding((v) => !v)}
icon={Plus}
label={t("plex.collection.addItem")}
/>
<ActionBtn
onClick={doDelete}
icon={Trash2}
label={t("plex.collection.delete")}
disabled={busy}
/>
</>
)}
{canEnableEdit && (
<ActionBtn
onClick={enableEdit}
icon={Check}
label={t("plex.collection.enableEdit")}
/>
)}
</div>
</DetailHero>
{adding && canEdit && (
<AddItemPanel
collectionId={collectionId}
scope={items.some((c) => c.type === "show") ? "show" : "movie"}
existing={new Set(items.map((c) => c.id))}
onAdded={invalidate}
onClose={() => setAdding(false)}
/>
)}
{items.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c) => (
<div key={c.id} className="relative">
<PlexPosterCard
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
/>
{canEdit && (
<button
onClick={() => doRemove(c)}
disabled={busy}
title={t("plex.collection.removeItem")}
aria-label={t("plex.collection.removeItem")}
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
// 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<string>;
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [term, setTerm] = useState("");
const debounced = useDebounced(term.trim(), 300);
const [added, setAdded] = useState<Set<string>>(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 (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted" />
<input
autoFocus
value={term}
onChange={(e) => 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"
/>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div>
{debounced.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : (
<ul className="flex flex-col gap-1">
{results.map((c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
<li key={c.id} className="flex items-center gap-3">
<img
src={c.thumb}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
loading="lazy"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{c.title}
{c.year != null && <span className="text-muted"> · {c.year}</span>}
</span>
<button
onClick={() => add(c)}
disabled={inColl}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
+15 -1
View File
@@ -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) ? (
<Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")}
/>
) : undefined
}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>
+4 -1
View File
@@ -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", () => {
+6 -1
View File
@@ -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=<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
@@ -41,6 +42,8 @@ export const PLEX_SUB_URL: SubviewUrl<Sub> = {
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<Sub> = {
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;
+20
View File
@@ -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",
+20
View File
@@ -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",
+10
View File
@@ -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<PlexCollectionDetail> =>
req(`/api/plex/collections/${encodeURIComponent(rk)}`),
// --- Collection editing (admin only; writes back to Plex) ---
plexCreateCollection: (
library: string,