feat(plex): collections — sync, browse, filter, and info-page strips (Phase 1, read)

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.
This commit is contained in:
npeter83 2026-07-06 02:25:48 +02:00
parent 0385b13a28
commit 418a874851
14 changed files with 386 additions and 15 deletions

View file

@ -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).

View file

@ -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

View file

@ -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()

View file

@ -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),
}