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,