From 0385b13a287c4c228e04b7de90fe5976a7bb9011 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 6 Jul 2026 01:35:47 +0200
Subject: [PATCH 1/4] fix(scheduler): give the plex_sync job a proper name +
tooltip
The Plex catalog sync job showed its raw id 'plex_sync' with no tooltip because it was
missing from the scheduler i18n. Add jobs.plex_sync + jobDesc.plex_sync (en/hu/de).
---
frontend/src/i18n/locales/de/scheduler.json | 6 ++++--
frontend/src/i18n/locales/en/scheduler.json | 6 ++++--
frontend/src/i18n/locales/hu/scheduler.json | 6 ++++--
3 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json
index 4fca278..104324c 100644
--- a/frontend/src/i18n/locales/de/scheduler.json
+++ b/frontend/src/i18n/locales/de/scheduler.json
@@ -68,7 +68,8 @@
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
- "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben."
+ "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
+ "plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync."
},
"jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)",
@@ -81,7 +82,8 @@
"maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset",
"explore_cleanup": "Bereinigung erkundeter Kanäle",
- "download_gc": "Download-Bereinigung"
+ "download_gc": "Download-Bereinigung",
+ "plex_sync": "Plex-Bibliothek-Sync"
},
"queue": {
"recentPending": "Zu synchronisierende Kanäle",
diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json
index c8c8fce..e553852 100644
--- a/frontend/src/i18n/locales/en/scheduler.json
+++ b/frontend/src/i18n/locales/en/scheduler.json
@@ -68,7 +68,8 @@
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
- "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed."
+ "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
+ "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -81,7 +82,8 @@
"maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset",
"explore_cleanup": "Explored-channel cleanup",
- "download_gc": "Download cleanup"
+ "download_gc": "Download cleanup",
+ "plex_sync": "Plex library sync"
},
"queue": {
"recentPending": "Channels to sync",
diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json
index ba01c02..4d35281 100644
--- a/frontend/src/i18n/locales/hu/scheduler.json
+++ b/frontend/src/i18n/locales/hu/scheduler.json
@@ -68,7 +68,8 @@
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
- "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel."
+ "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
+ "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -81,7 +82,8 @@
"maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása",
"explore_cleanup": "Felfedezett csatornák takarítása",
- "download_gc": "Letöltések takarítása"
+ "download_gc": "Letöltések takarítása",
+ "plex_sync": "Plex-könyvtár szinkron"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
From 418a874851c1b2d13ba7cc37343d58f8455f0251 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 6 Jul 2026 02:25:48 +0200
Subject: [PATCH 2/4] =?UTF-8?q?feat(plex):=20collections=20=E2=80=94=20syn?=
=?UTF-8?q?c,=20browse,=20filter,=20and=20info-page=20strips=20(Phase=201,?=
=?UTF-8?q?=20read)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend (migration 0047_plex_collections): a plex_collections table mirrors every Plex
collection (card metadata + smart flag + an editable flag reserved for Phase 2); membership
is stored as GIN-indexed collection_keys on member movies (plex_items) and shows (plex_shows).
The background sync (plex_sync) now fetches each library's collections + their children and
rebuilds membership — so ALL reads are local (Plex's dual-language collection queries are slow;
this trades a ~4-min background sync for instant reads). New /api/plex/collections endpoint;
/browse gains a combinable filter; item_detail returns the movie's collection
'strips' (sibling titles as playable cards, smallest/most-specific collection first) — all pure
local lookups.
Frontend: PlexSidebar gains a searchable Collection picker + an active-collection chip;
PlexInfo renders the collection strips (playable posters + 'Browse collection' → sets the
filter); the collection is part of PlexFilters (persisted). i18n en/hu/de.
Phase 2 (create/edit collections with write-back to Plex) is separate.
---
VERSION | 2 +-
.../alembic/versions/0047_plex_collections.py | 57 ++++++++++++++
backend/app/models.py | 27 +++++++
backend/app/plex/client.py | 10 +++
backend/app/plex/sync.py | 45 ++++++++++-
backend/app/routes/plex.py | 78 ++++++++++++++++++-
frontend/src/components/PlexBrowse.tsx | 11 ++-
frontend/src/components/PlexInfo.tsx | 67 +++++++++++++++-
frontend/src/components/PlexSidebar.tsx | 52 ++++++++++++-
frontend/src/i18n/locales/de/plex.json | 8 +-
frontend/src/i18n/locales/en/plex.json | 8 +-
frontend/src/i18n/locales/hu/plex.json | 8 +-
frontend/src/lib/api.ts | 18 ++++-
frontend/src/lib/releaseNotes.ts | 10 +++
14 files changed, 386 insertions(+), 15 deletions(-)
create mode 100644 backend/alembic/versions/0047_plex_collections.py
diff --git a/VERSION b/VERSION
index 81566e4..022a033 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.27.0
\ No newline at end of file
+0.28.0
\ No newline at end of file
diff --git a/backend/alembic/versions/0047_plex_collections.py b/backend/alembic/versions/0047_plex_collections.py
new file mode 100644
index 0000000..684e627
--- /dev/null
+++ b/backend/alembic/versions/0047_plex_collections.py
@@ -0,0 +1,57 @@
+"""Plex collections
+
+Revision ID: 0047_plex_collections
+Revises: 0046_plex_people_search
+Create Date: 2026-07-06
+
+Mirrors Plex collections locally so reads never hit Plex's slow (dual-language) collection queries.
+A `plex_collections` table holds each collection's card metadata; membership is stored as GIN-indexed
+`collection_keys` on the member movies (plex_items) and shows (plex_shows), so a collection can be a
+combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
+(curated) collections we may later write back to Plex; everything is read-only by default.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision: str = "0047_plex_collections"
+down_revision: Union[str, None] = "0046_plex_people_search"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "plex_collections",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("rating_key", sa.String(length=32), nullable=False),
+ sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
+ sa.Column("title", sa.String(length=512), nullable=False),
+ sa.Column("summary", sa.Text(), nullable=True),
+ sa.Column("thumb_key", sa.String(length=512), nullable=True),
+ sa.Column("child_count", sa.Integer(), nullable=True),
+ sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
+ sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
+ sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ )
+ op.create_index("ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True)
+ op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
+
+ op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
+ op.create_index("ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin")
+ op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
+ op.create_index("ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin")
+
+
+def downgrade() -> None:
+ op.drop_index("ix_plex_shows_collections_gin", table_name="plex_shows")
+ op.drop_column("plex_shows", "collection_keys")
+ op.drop_index("ix_plex_items_collections_gin", table_name="plex_items")
+ op.drop_column("plex_items", "collection_keys")
+ op.drop_index("ix_plex_collections_library_id", table_name="plex_collections")
+ op.drop_index("ix_plex_collections_rating_key", table_name="plex_collections")
+ op.drop_table("plex_collections")
diff --git a/backend/app/models.py b/backend/app/models.py
index 23d50b9..354114e 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -959,6 +959,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored)
art_key: Mapped[str | None] = mapped_column(String(512))
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
+ collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
@@ -998,6 +999,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
+ Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
)
id: Mapped[int] = mapped_column(primary_key=True)
@@ -1044,6 +1046,8 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
genres: Mapped[list | None] = mapped_column(JSONB)
directors: Mapped[list | None] = mapped_column(JSONB)
cast_names: Mapped[list | None] = mapped_column(JSONB)
+ # rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
+ collection_keys: Mapped[list | None] = mapped_column(JSONB)
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
# finds titles by a person's name (and ranks them above summary-only mentions).
people_text: Mapped[str | None] = mapped_column(Text)
@@ -1058,6 +1062,29 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
)
+class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
+ """A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
+ sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
+ sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
+ on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
+ `editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
+
+ __tablename__ = "plex_collections"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
+ library_id: Mapped[int] = mapped_column(
+ ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
+ )
+ title: Mapped[str] = mapped_column(String(512))
+ summary: Mapped[str | None] = mapped_column(Text)
+ thumb_key: Mapped[str | None] = mapped_column(String(512))
+ child_count: Mapped[int | None] = mapped_column(Integer)
+ smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+ editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+ synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+
class PlexState(Base, UpdatedAtMixin):
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py
index 11362a1..e9237c4 100644
--- a/backend/app/plex/client.py
+++ b/backend/app/plex/client.py
@@ -95,6 +95,16 @@ class PlexClient:
mc = self._get(f"/library/metadata/{rating_key}/children")
return mc.get("Metadata", []) or []
+ def collections(self, section_key: str) -> list[dict]:
+ """All collections in a library section (title/summary/thumb/childCount/smart)."""
+ mc = self._get(f"/library/sections/{section_key}/collections")
+ return mc.get("Metadata", []) or []
+
+ def collection_children(self, rating_key: str) -> list[dict]:
+ """The member items (movies / shows) of a collection."""
+ mc = self._get(f"/library/collections/{rating_key}/children")
+ return mc.get("Metadata", []) or []
+
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
"""Full metadata for one item; include intro/credit markers when requested."""
params = {"includeMarkers": 1} if markers else None
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index 21a3fcf..445ae3c 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app import sysconfig
-from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
+from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
log = logging.getLogger("siftlode.plex")
@@ -111,7 +111,7 @@ def sync(db: Session) -> dict:
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
if not sysconfig.get_bool(db, "plex_enabled"):
return {"skipped": "disabled"}
- stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0}
+ stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, "collections": 0}
wanted = _enabled_section_keys(db)
try:
with PlexClient(db) as plex:
@@ -127,6 +127,7 @@ def sync(db: Session) -> dict:
_sync_movies(db, plex, lib, stats)
else:
_sync_shows(db, plex, lib, stats)
+ _sync_collections(db, plex, lib, stats)
db.commit()
except PlexNotConfigured as e:
return {"skipped": str(e)}
@@ -268,3 +269,43 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
row.episode_number = meta.get("index")
stats["episodes"] += 1
db.flush()
+
+
+def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
+ """Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is
+ written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
+ collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch."""
+ cols = plex.collections(lib.plex_key)
+ existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)}
+ seen: set[str] = set()
+ membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
+ for meta in cols:
+ rk = str(meta.get("ratingKey") or "")
+ if not rk:
+ continue
+ col = existing.get(rk) or PlexCollection(rating_key=rk)
+ if col.id is None:
+ db.add(col)
+ col.library_id = lib.id
+ col.title = (meta.get("title") or "").strip() or "Untitled"
+ col.summary = meta.get("summary")
+ col.thumb_key = meta.get("thumb")
+ col.child_count = meta.get("childCount")
+ col.smart = str(meta.get("smart")) == "1" # `editable` is preserved (user-managed, not here)
+ col.synced_at = datetime.now(timezone.utc)
+ seen.add(rk)
+ for child in plex.collection_children(rk):
+ crk = str(child.get("ratingKey") or "")
+ if crk:
+ membership.setdefault(crk, []).append(rk)
+ stats["collections"] += 1
+ db.flush()
+ for rk, col in existing.items():
+ if rk not in seen:
+ db.delete(col)
+ # Rebuild collection_keys on every member row of this library (clear + set).
+ for row in db.query(PlexItem).filter_by(library_id=lib.id):
+ row.collection_keys = membership.get(row.rating_key) or None
+ for row in db.query(PlexShow).filter_by(library_id=lib.id):
+ row.collection_keys = membership.get(row.rating_key) or None
+ db.flush()
diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index a73bc9a..77638a4 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -21,7 +21,7 @@ from app import sysconfig
from app.auth import current_user
from app.config import settings
from app.db import get_db
-from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
+from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import paths as plex_paths
from app.plex import stream as plex_stream
from app.plex import sync as plex_sync
@@ -204,6 +204,7 @@ def browse(
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
+ collection: str | None = None,
sort_dir: str = "desc",
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -262,6 +263,10 @@ def browse(
else:
query = query.filter(PlexShow.library_id == lib.id)
+ # Collection filter applies to both movies and shows (both carry collection_keys).
+ if collection:
+ query = query.filter(model.collection_keys.contains([collection]))
+
ts = _to_tsquery_str(q) if q else None
tsq = None
if ts:
@@ -420,6 +425,39 @@ def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
return None
+def _collection_card(c: PlexCollection) -> dict:
+ return {
+ "id": c.rating_key,
+ "title": c.title,
+ "summary": c.summary,
+ "thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
+ "child_count": c.child_count,
+ "smart": c.smart,
+ "editable": c.editable,
+ }
+
+
+@router.get("/collections")
+def collections(
+ library: str,
+ q: str | None = None,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """The library's mirrored collections as cards (most-populated first). Optional name filter `q`."""
+ lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
+ if lib is None:
+ return {"collections": []}
+ query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
+ term = (q or "").strip()
+ if term:
+ query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
+ rows = query.order_by(
+ PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title)
+ ).all()
+ return {"collections": [_collection_card(c) for c in rows]}
+
+
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,
@@ -482,6 +520,9 @@ def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
if se is not None:
return se.thumb_key
+ col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
+ if col is not None:
+ return col.thumb_key
return None
@@ -643,6 +684,40 @@ def person_image(u: str, _: User = Depends(current_user)) -> Response:
return _img_response(r.content, ct)
+def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
+ """The collections this movie belongs to, each with its member titles (playable cards) — the
+ 'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
+ keys = it.collection_keys or []
+ if not keys or it.kind != "movie":
+ return []
+ cols = {c.rating_key: c for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys))}
+ # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
+ ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title))
+ out = []
+ for col in ordered:
+ rk = col.rating_key
+ members = (
+ db.query(PlexItem)
+ .filter(
+ PlexItem.library_id == it.library_id,
+ PlexItem.kind == "movie",
+ PlexItem.collection_keys.contains([rk]),
+ )
+ .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
+ .limit(60)
+ .all()
+ )
+ ids = [m.id for m in members]
+ states = {
+ s.item_id: s
+ for s in db.query(PlexState).filter(
+ PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
+ )
+ }
+ out.append({"id": rk, "title": col.title, "items": [_movie_card(m, states.get(m.id)) for m in members]})
+ return out
+
+
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
@@ -742,6 +817,7 @@ def item_detail(
"episode_number": it.episode_number,
"prev_id": prev_id,
"next_id": next_id,
+ "collections": _collection_strips(db, it, user.id),
}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 28832b1..bc8476d 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -142,6 +142,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
+ onPlayItem={(id) => sub.open({ kind: "player", id })}
onFilter={(patch) => {
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
@@ -360,11 +361,13 @@ function PlexInfoView({
id,
onBack,
onPlay,
+ onPlayItem,
onFilter,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
+ onPlayItem: (id: string) => void;
onFilter: (patch: Partial) => void;
}) {
const { t } = useTranslation();
@@ -384,7 +387,13 @@ function PlexInfoView({
{t("plex.loading")}
) : (
{t("plex.loading")}
}>
-
+
)}
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 4adf81b..67a4cef 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -27,6 +27,8 @@ type Props = {
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
// the corresponding Plex filter (then the opener navigates to the filtered grid).
onFilter?: (patch: Partial) => void;
+ // Play a sibling title from a collection strip (opens its player).
+ onPlayItem?: (id: string) => void;
};
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
@@ -56,7 +58,15 @@ function fmtDur(s?: number | null): string {
return h ? `${h}h ${m}m` : `${m}m`;
}
-export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
+export default function PlexInfo({
+ detail,
+ variant,
+ onPlay,
+ onClose,
+ onStateChange,
+ onFilter,
+ onPlayItem,
+}: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -346,6 +356,61 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
)}
+
+ {/* Collection strips — the other titles in this movie's collection(s), directly playable. */}
+ {detail.collections?.map((col) => (
+
+
+
{col.title}
+ {onFilter && (
+
+ )}
+
+
+ {col.items.map((m) => (
+
+ ))}
+
+
+ ))}
);
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 79502db..f222395 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -1,8 +1,9 @@
-import { useEffect, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
-import { ChevronRight, Film, SlidersHorizontal, Tv2, X } from "lucide-react";
+import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
+import { useDebounced } from "../lib/useDebounced";
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
// full metadata filter set (genre / rating / year / duration / added / content rating + the
@@ -60,6 +61,16 @@ export default function PlexSidebar({
});
const facets = facetsQ.data;
+ // Collections picker (searchable; the list is only fetched when none is active).
+ const [collSearch, setCollSearch] = useState("");
+ const collSearchDeb = useDebounced(collSearch.trim(), 300);
+ const collectionsQ = useQuery({
+ queryKey: ["plex-collections", library, collSearchDeb],
+ queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
+ enabled: !!library && isMovieLib && !filters.collection,
+ });
+ const collections = collectionsQ.data?.collections ?? [];
+
// Default to the first library once loaded (or if the stored one vanished).
useEffect(() => {
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
@@ -205,6 +216,43 @@ export default function PlexSidebar({
)}
+ {/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
+
+ {filters.collection ? (
+