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 ? ( +
+ patch({ collection: null, collectionTitle: null })} + /> +
+ ) : ( +
+ setCollSearch(e.target.value)} + placeholder={t("plex.filter.collectionSearch")} + className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none" + /> +
+ {collections.map((c) => ( + + ))} + {collections.length === 0 && ( + {t("plex.filter.collectionEmpty")} + )} +
+
+ )} +
+ {/* IMDb rating min */}
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index fc8f2db..4882360 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -33,7 +33,10 @@ "addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" }, "contentRating": "Altersfreigabe", "match": { "any": "Beliebig", "all": "Alle" }, - "dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" } + "dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }, + "collection": "Sammlung", + "collectionSearch": "Sammlungen suchen…", + "collectionEmpty": "Keine Sammlungen" }, "count": "{{count}} Titel", "searchCount": "{{count}} Treffer", @@ -103,7 +106,8 @@ "clearResume": "Fortsetzungsposition löschen", "filterYear": "Titel aus {{year}}", "filterRating": "Titel mit {{n}}+", - "filterActor": "Titel mit {{name}}" + "filterActor": "Titel mit {{name}}", + "browseCollection": "Sammlung durchsuchen →" }, "playable": { "direct": "Spielt direkt im Browser", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 1c39cd3..c8aa1d4 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -33,7 +33,10 @@ "addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" }, "contentRating": "Age rating", "match": { "any": "Any", "all": "All" }, - "dir": { "desc": "↓ Descending", "asc": "↑ Ascending" } + "dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }, + "collection": "Collection", + "collectionSearch": "Search collections…", + "collectionEmpty": "No collections" }, "count": "{{count}} titles", "searchCount": "{{count}} matches", @@ -103,7 +106,8 @@ "clearResume": "Clear resume position", "filterYear": "Show titles from {{year}}", "filterRating": "Show titles rated {{n}}+", - "filterActor": "Show titles with {{name}}" + "filterActor": "Show titles with {{name}}", + "browseCollection": "Browse collection →" }, "playable": { "direct": "Plays directly in the browser", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 9075345..f9b6f95 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -33,7 +33,10 @@ "addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" }, "contentRating": "Korhatár", "match": { "any": "Bármelyik", "all": "Mind" }, - "dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" } + "dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }, + "collection": "Kollekció", + "collectionSearch": "Kollekciók keresése…", + "collectionEmpty": "Nincs kollekció" }, "count": "{{count}} cím", "searchCount": "{{count}} találat", @@ -103,7 +106,8 @@ "clearResume": "Folytatási pozíció törlése", "filterYear": "{{year}} évi címek", "filterRating": "{{n}}+ pontos címek", - "filterActor": "Címek {{name}} szereplésével" + "filterActor": "Címek {{name}} szereplésével", + "browseCollection": "Kollekció böngészése →" }, "playable": { "direct": "Közvetlenül játszható a böngészőben", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 827f317..fbac7ba 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -714,6 +714,16 @@ export interface PlexItemDetail { episode_number?: number | null; prev_id?: string | null; next_id?: string | null; + collections?: { id: string; title: string; items: PlexCard[] }[]; +} +export interface PlexCollection { + id: string; + title: string; + summary?: string | null; + thumb?: string | null; + child_count?: number | null; + smart: boolean; + editable: boolean; } export interface PlexCastMember { name: string; @@ -734,6 +744,8 @@ export interface PlexFilters { directors: string[]; // AND — titles featuring ALL selected actors: string[]; // AND — titles featuring ALL selected studios: string[]; // OR — from any selected studio + collection?: string | null; // a single Plex collection (rating_key) + collectionTitle?: string | null; // its title, for the active-filter chip sortDir?: "asc" | "desc"; // sort direction (default desc) } export const EMPTY_PLEX_FILTERS: PlexFilters = { @@ -753,7 +765,8 @@ export function plexFilterCount(f: PlexFilters): number { (f.addedWithin ? 1 : 0) + f.directors.length + f.actors.length + - f.studios.length + f.studios.length + + (f.collection ? 1 : 0) ); } export interface PlexPerson { @@ -1122,6 +1135,7 @@ export const api = { if (f.directors?.length) u.set("directors", f.directors.join(",")); if (f.actors?.length) u.set("actors", f.actors.join(",")); if (f.studios?.length) u.set("studios", f.studios.join(",")); + if (f.collection) u.set("collection", f.collection); if (f.sortDir === "asc") u.set("sort_dir", "asc"); } return req(`/api/plex/browse?${u.toString()}`); @@ -1130,6 +1144,8 @@ export const api = { req(`/api/plex/facets?library=${encodeURIComponent(library)}`), plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> => req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`), + plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> => + req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`), plexShow: (id: string): Promise => req(`/api/plex/show/${encodeURIComponent(id)}`), plexItem: (id: string): Promise => diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index b89f8d9..1a6ba9a 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,16 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.28.0", + date: "2026-07-06", + summary: "Plex collections — browse them, filter by them, and see a title's collection right on its info page.", + features: [ + "Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.", + "Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.", + "Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).", + ], + }, { version: "0.27.0", date: "2026-07-06", From 8a8087ae9972e46d2810a2089cf93e24ca4e1c67 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 6 Jul 2026 06:49:43 +0200 Subject: [PATCH 3/4] feat(plex): classify collection-strip source (franchise/imdb/tmdb/smart) Derive a `source` bucket per collection from the already-synced title + smart flag (no schema change, no re-sync): external list providers (IMDb/TMDb/TVDb/ Trakt) by leading token, Plex smart lists via the smart flag, everything else a genuine "collection". Returned on each info-page collection strip so the client can show/hide each type independently. Adds the stripSource labels (en/hu/de). --- backend/app/routes/plex.py | 25 ++++++++++++++++++++++++- 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 | 4 +++- 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 77638a4..d7b6f81 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -684,6 +684,22 @@ def person_image(u: str, _: User = Depends(current_user)) -> Response: return _img_response(r.content, ct) +# Known external list providers that seed Plex collections (via plextraktsync et al). A collection's +# "source" buckets the info-page strips so the user can show/hide each type independently (e.g. hide +# the noisy 'IMDb Popular'/'TMDb Trending' auto-lists but keep genuine franchise collections). Derived +# from the already-synced title/smart — no schema change, no re-sync. Unknown providers stay "collection". +_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") + + +def _collection_source(col: PlexCollection) -> str: + first = (col.title or "").strip().split(" ", 1)[0].lower() + if first in _STRIP_PROVIDERS: + return first + if col.smart: + return "smart" + return "collection" + + 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).""" @@ -714,7 +730,14 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: 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]}) + out.append( + { + "id": rk, + "title": col.title, + "source": _collection_source(col), + "items": [_movie_card(m, states.get(m.id)) for m in members], + } + ) return out diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 4882360..8c9bd70 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -98,6 +98,14 @@ "customize": "Ansicht anpassen", "prefArtBg": "Dezenter Hintergrund", "prefCast": "Besetzung", + "stripSource": { + "collection": "Sammlungen", + "imdb": "IMDb", + "tmdb": "TMDb", + "tvdb": "TVDb", + "trakt": "Trakt", + "smart": "Smart-Listen" + }, "openImdb": "Bei IMDb öffnen", "director": "Regie", "cast": "Besetzung & Crew", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index c8aa1d4..ce7d554 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -98,6 +98,14 @@ "customize": "Customize view", "prefArtBg": "Faint backdrop", "prefCast": "Cast & crew", + "stripSource": { + "collection": "Collections", + "imdb": "IMDb", + "tmdb": "TMDb", + "tvdb": "TVDb", + "trakt": "Trakt", + "smart": "Smart lists" + }, "openImdb": "Open on IMDb", "director": "Director", "cast": "Cast & crew", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index f9b6f95..3868d93 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -98,6 +98,14 @@ "customize": "Nézet testreszabása", "prefArtBg": "Halvány háttér", "prefCast": "Szereplők", + "stripSource": { + "collection": "Kollekciók", + "imdb": "IMDb", + "tmdb": "TMDb", + "tvdb": "TVDb", + "trakt": "Trakt", + "smart": "Smart listák" + }, "openImdb": "Megnyitás az IMDb-n", "director": "Rendező", "cast": "Szereplők & stáb", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fbac7ba..44a99c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -714,7 +714,9 @@ export interface PlexItemDetail { episode_number?: number | null; prev_id?: string | null; next_id?: string | null; - collections?: { id: string; title: string; items: PlexCard[] }[]; + // `source` buckets the strip so each type can be shown/hidden independently on the info page: + // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart". + collections?: { id: string; title: string; source: string; items: PlexCard[] }[]; } export interface PlexCollection { id: string; From f037a4563ce860b5f77c4dc7ad30adff389d9fd1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 6 Jul 2026 06:50:03 +0200 Subject: [PATCH 4/4] =?UTF-8?q?feat(plex):=20info-page=20polish=20?= =?UTF-8?q?=E2=80=94=20per-source=20strip=20toggles,=20glassy=20look,=20sc?= =?UTF-8?q?roll=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From UAT feedback on the collections info page: - Per-source show/hide toggles in the customize menu, one per collection-strip type present on the item (Collections/IMDb/TMDb/…), persisted per account. - Customize menu closes on Escape / outside click (reuses useDismiss). - Preserve the info-page scroll position when returning from "Browse collection". - Glassy refresh: content floats as .glass/.glass-card panels over a faint FIXED art backdrop on the
scroller (HTPC-style, toggleable); frosted menu; wider ~90% layout. Uses the existing glass surface system. --- frontend/src/components/PlexBrowse.tsx | 17 ++-- frontend/src/components/PlexInfo.tsx | 116 ++++++++++++++++++++----- frontend/src/lib/releaseNotes.ts | 2 + 3 files changed, 110 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index bc8476d..d5e5fbf 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -52,12 +52,17 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt // restore it when we come back — so the browser/mouse Back from the player lands on the same card // instead of the top of the library. The scroller is App's
(the page's overflow-y-auto). const scrollRef = useRef(0); + // Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered + // grid, and browser Back returns to this same info page — restore where the user had scrolled it + // (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a + // different title's info starts at the top. + const infoScrollRef = useRef(0); const scroller = () => document.querySelector("main"); useLayoutEffect(() => { - if (sub.view.kind === "grid" && scrollRef.current) { - const el = scroller(); - if (el) el.scrollTop = scrollRef.current; - } + const el = scroller(); + if (!el) return; + if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current; + else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current; }, [sub.view.kind]); const browseQ = useInfiniteQuery({ @@ -121,6 +126,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt } function onInfo(card: PlexCard) { scrollRef.current = scroller()?.scrollTop ?? 0; + infoScrollRef.current = 0; // fresh info page starts at the top sub.open({ kind: "info", id: card.id }); } async function toggleWatched(card: PlexCard) { @@ -158,6 +164,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt } } setFilters(merged as unknown as PlexFilters); + infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page sub.open({ kind: "grid" }); }} /> @@ -373,7 +380,7 @@ function PlexInfoView({ const { t } = useTranslation(); const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) }); return ( -
+
{customizing && ( -
+
+ {presentSources.map((src) => ( + toggleSource(src)} + /> + ))}
)}
@@ -273,7 +337,7 @@ export default function PlexInfo({ {detail.status === "watched" ? (
+
+ {/* End hero panel. */} {/* Cast row (toggleable), circular photos. */} {cast.length > 0 && ( -
+

{t("plex.info.cast")}

@@ -357,9 +426,16 @@ export default function PlexInfo({
)} - {/* Collection strips — the other titles in this movie's collection(s), directly playable. */} - {detail.collections?.map((col) => ( -
+ {/* Collection strips — the other titles in this movie's collection(s), directly playable. + Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */} + {detail.collections + ?.filter((col) => !hiddenSources.includes(col.source)) + .map((col) => ( +

{col.title}

{onFilter && ( diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 1a6ba9a..f94d6ce 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -22,6 +22,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [ "Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.", "Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.", "Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).", + "Plex info page: show or hide each collection-strip type independently — keep genuine franchises (Avatar, James Bond) while hiding the big auto-lists (IMDb Top 250, TMDb Trending), remembered per account.", + "Plex info page: a refreshed, wider layout — content floats as frosted-glass panels over a faint, fixed backdrop of the title's own art (toggleable), matching the app's glassy look.", ], }, {