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:
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user