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",