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:
parent
0385b13a28
commit
418a874851
14 changed files with 386 additions and 15 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue