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,
|
PlexShow,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.plex import rollup
|
||||||
from app.plex import sync as plex_sync
|
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.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
from app.plex.lookups import by_rating_key
|
from app.plex.lookups import by_rating_key
|
||||||
from app.plex.query import like_escape, runtime_by_key, top_art_keys
|
from app.plex.query import like_escape, runtime_by_key, top_art_keys
|
||||||
from app.routes.plex._shared import _content_libs
|
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"])
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||||
|
|
||||||
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
_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:
|
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
|
||||||
col = by_rating_key(db, PlexCollection, rating_key)
|
col = by_rating_key(db, PlexCollection, rating_key)
|
||||||
if col is None:
|
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
|
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):
|
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
|
# 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).
|
# 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.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||||
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
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
|
// 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
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||||
@@ -173,6 +174,7 @@ export default function PlexBrowse() {
|
|||||||
"info",
|
"info",
|
||||||
"person",
|
"person",
|
||||||
"playlist",
|
"playlist",
|
||||||
|
"collection",
|
||||||
].includes(sub.view.kind)
|
].includes(sub.view.kind)
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -341,6 +343,20 @@ export default function PlexBrowse() {
|
|||||||
</Suspense>
|
</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") {
|
if (sub.view.kind === "info") {
|
||||||
const infoId = sub.view.id;
|
const infoId = sub.view.id;
|
||||||
return (
|
return (
|
||||||
@@ -431,11 +447,7 @@ export default function PlexBrowse() {
|
|||||||
if (sub.view.kind === "collections") {
|
if (sub.view.kind === "collections") {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[1600px] mx-auto">
|
<div className="p-4 max-w-[1600px] mx-auto">
|
||||||
<PlexCollectionsBrowse
|
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
|
||||||
onPick={(id, title) =>
|
|
||||||
applyFilter({ collections: [id], collectionTitles: { [id]: title } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ListMusic } from "lucide-react";
|
import { ListMusic, Lock } from "lucide-react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { api, type PlexCollection } from "../lib/api";
|
import { api, type PlexCollection } from "../lib/api";
|
||||||
import { formatRuntime } from "../lib/format";
|
import { formatRuntime } from "../lib/format";
|
||||||
@@ -20,6 +20,12 @@ function collectionMeta(c: PlexCollection, t: TFunction): string {
|
|||||||
return parts.join(" · ");
|
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,
|
// 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
|
// 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
|
// 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}
|
title={c.title}
|
||||||
art={c.art}
|
art={c.art}
|
||||||
subtitle={collectionMeta(c, t)}
|
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)}
|
onClick={() => onPick(c.id, c.title)}
|
||||||
className="h-28"
|
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: "info", id: "m9" })).toBe("info:m9");
|
||||||
expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe");
|
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: "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)", () => {
|
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: "info", id: "m9" },
|
||||||
{ kind: "person", name: "Jane Doe" },
|
{ kind: "person", name: "Jane Doe" },
|
||||||
{ kind: "playlist", id: 12 },
|
{ kind: "playlist", id: 12 },
|
||||||
|
{ kind: "collection", id: "c5" },
|
||||||
];
|
];
|
||||||
it.each(cases)("encode→decode is identity for %o", (view) => {
|
it.each(cases)("encode→decode is identity for %o", (view) => {
|
||||||
const raw = U.encode(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" });
|
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("show:")).toBeNull();
|
||||||
expect(U.decode("info:")).toBeNull();
|
expect(U.decode("info:")).toBeNull();
|
||||||
expect(U.decode("person:")).toBeNull();
|
expect(U.decode("person:")).toBeNull();
|
||||||
|
expect(U.decode("collection:")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown kind or garbage", () => {
|
it("rejects an unknown kind or garbage", () => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export type Sub =
|
|||||||
| { kind: "player"; id: string; queue?: string[] }
|
| { kind: "player"; id: string; queue?: string[] }
|
||||||
| { kind: "info"; id: string }
|
| { kind: "info"; id: string }
|
||||||
| { kind: "person"; name: 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
|
// 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
|
// — 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}`;
|
return `person:${v.name}`;
|
||||||
case "playlist":
|
case "playlist":
|
||||||
return `playlist:${v.id}`;
|
return `playlist:${v.id}`;
|
||||||
|
case "collection":
|
||||||
|
return `collection:${v.id}`;
|
||||||
default:
|
default:
|
||||||
return null; // discover (root) + the ephemeral player are not URL-mirrored
|
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);
|
const id = parsePositiveIntId(arg);
|
||||||
return id != null ? { kind: "playlist", id } : null;
|
return id != null ? { kind: "playlist", id } : null;
|
||||||
}
|
}
|
||||||
|
case "collection":
|
||||||
|
return arg ? { kind: "collection", id: arg } : null;
|
||||||
case "season": {
|
case "season": {
|
||||||
const [showId, seasonId] = arg.split(":");
|
const [showId, seasonId] = arg.split(":");
|
||||||
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
"navLabel": "Plex",
|
"navLabel": "Plex",
|
||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Back to library",
|
"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…",
|
"searchPlaceholder": "Search Plex…",
|
||||||
"searchField": {
|
"searchField": {
|
||||||
"aria": "Search scope",
|
"aria": "Search scope",
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
"navLabel": "Plex",
|
"navLabel": "Plex",
|
||||||
"backToFeed": "YouTube feed",
|
"backToFeed": "YouTube feed",
|
||||||
"backToLibrary": "Vissza a könyvtárhoz",
|
"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…",
|
"searchPlaceholder": "Keresés a Plexben…",
|
||||||
"searchField": {
|
"searchField": {
|
||||||
"aria": "Keresési hatókör",
|
"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)
|
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)
|
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 {
|
export interface PlexPlaylist {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -417,6 +424,9 @@ export const plexApi = {
|
|||||||
const qs = u.toString();
|
const qs = u.toString();
|
||||||
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
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) ---
|
// --- Collection editing (admin only; writes back to Plex) ---
|
||||||
plexCreateCollection: (
|
plexCreateCollection: (
|
||||||
library: string,
|
library: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user