From 418a874851c1b2d13ba7cc37343d58f8455f0251 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 6 Jul 2026 02:25:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(plex):=20collections=20=E2=80=94=20sync,?= =?UTF-8?q?=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",