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

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