From 5d99a5f253529740c2c272776e4dc02518c1e101 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sun, 23 Aug 2026 04:40:09 +0200
Subject: [PATCH] =?UTF-8?q?feat(plex):=20Collection=20info=20page=20?=
=?UTF-8?q?=E2=80=94=20hero,=20play/shuffle,=20and=20write-through=20editi?=
=?UTF-8?q?ng=20(B)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
backend/app/routes/plex/collections.py | 61 +++
backend/tests/test_plex_collections_filter.py | 23 +
frontend/src/components/PlexBrowse.tsx | 22 +-
.../src/components/PlexCollectionView.tsx | 417 ++++++++++++++++++
frontend/src/components/plexBrowseLists.tsx | 16 +-
frontend/src/components/plexSubUrl.test.ts | 5 +-
frontend/src/components/plexSubUrl.ts | 7 +-
frontend/src/i18n/locales/en/plex.json | 20 +
frontend/src/i18n/locales/hu/plex.json | 20 +
frontend/src/lib/api/plex.ts | 10 +
10 files changed, 593 insertions(+), 8 deletions(-)
create mode 100644 frontend/src/components/PlexCollectionView.tsx
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")}