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