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 {t("plex.loading")}