"""Plex collections: browse mirrored collections + the admin manual-collection editor.""" from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import admin_user, current_user from app.db import get_db from app.models import ( PlexCollection, PlexItem, PlexLibrary, PlexShow, User, ) from app.plex import rollup from app.plex import sync as plex_sync from app.plex.cards import movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key from app.plex.query import like_escape, runtime_by_key, top_art_keys from app.routes.plex._shared import _content_libs # The most-restrictive → least ordering used to pick a collection's effective content rating from its # members (Plex shows one cert on the collection; we don't store it, so derive it). _CERT_RANK = { "G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1, "PG": 2, "TV-PG": 2, "PG-13": 3, "TV-14": 3, "R": 4, "TV-MA": 4, "NC-17": 5, } def _collection_cert(certs: list[str | None]) -> str | None: ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c] return max(ranked, key=lambda x: x[1])[0] if ranked else None router = APIRouter(prefix="/api/plex", tags=["plex"]) _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") def _collection_source(col: PlexCollection) -> str: first = (col.title or "").strip().split(" ", 1)[0].lower() if first in _STRIP_PROVIDERS: return first if col.smart: return "smart" return "collection" def _collection_editable(c: PlexCollection) -> bool: """Effective editability: the admin marked it editable AND it's a plain manual collection — never smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex.""" return bool(c.editable) and not c.smart and _collection_source(c) == "collection" 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, "source": _collection_source(c), "editable": c.editable, "can_edit": _collection_editable( c ), # whether the edit controls should be offered (admin UI) } @router.get("/collections") def collections( library: str | None = None, q: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a `library` plex_key → that one library's collections (used by the collection editor). WITHOUT one → the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single library selected).""" if library is not None: lib = ( db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library))) .scalars() .first() ) if lib is None: return {"collections": []} query = select(PlexCollection).where(PlexCollection.library_id == lib.id) else: enabled_ids = list( db.execute( select(PlexLibrary.id).where( PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) ) ).scalars() ) if not enabled_ids: return {"collections": []} query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids)) term = (q or "").strip() if term: query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) rows = ( db.execute( query.order_by( PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title), ) ) .scalars() .all() ) # A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW # members (a collection lives in one library, so exactly one side has art) — a show collection would # otherwise render blank; the collection's own poster is the next fallback, then the colour hash. # Runtime is movie-only (shows have no single duration). movie_ids, show_ids = _content_libs(db) keys = [c.rating_key for c in rows] art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2) art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2) runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys) def _art(c: PlexCollection) -> list[str]: member = art_m.get(c.rating_key) or art_s.get(c.rating_key) return member or ([c.rating_key] if c.thumb_key else []) return { "collections": [ { **_collection_card(c), "art": _art(c), "total_runtime_s": runtime.get(c.rating_key, 0), } for c in rows ] } @router.get("/collections/{rating_key}") def collection_detail( rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """One collection's hero data + its members (movies + shows), ordered by release year then title — the Collection info page. Read-only here; edits go through the admin mutation endpoints below.""" col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") movies = ( db.execute( select(PlexItem) .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key])) .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) ) .scalars() .all() ) shows = ( db.execute( select(PlexShow) .where(PlexShow.collection_keys.contains([rating_key])) .order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title)) ) .scalars() .all() ) mstate = rollup.item_state_map(db, user.id, [m.id for m in movies]) sstate = rollup.state_map(db, user.id, [s.id for s in shows]) # Merge the two type-homogeneous lists into ONE member list ordered by (year, title). tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [ (s.year, (s.title or "").lower(), "show", s) for s in shows ] tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1])) items = [ movie_card(o, mstate.get(o.id)) if kind == "movie" else show_card(o, sstate.get(o.id, "new")) for _, _, kind, o in tagged ] years = [y for y, _, _, _ in tagged if y is not None] certs = [m.content_rating for m in movies] + [s.content_rating for s in shows] return { **_collection_card(col), "content_rating": _collection_cert(certs), "year_min": min(years) if years else None, "year_max": max(years) if years else None, "total_runtime_s": sum(m.duration_s or 0 for m in movies), "items": items, } def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") if not _collection_editable(col): raise HTTPException(status_code=403, detail="This collection is read-only") return col def _reject_duplicate_name( db: Session, library_id: int, title: str, exclude_rk: str | None = None ) -> None: """No two collections in a section may share a name: Plex CONFLATES same-named collections' membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up front on create + rename rather than mirror the mess.""" dup = ( db.execute( select(PlexCollection).where( PlexCollection.library_id == library_id, func.lower(PlexCollection.title) == title.lower(), ) ) .scalars() .first() ) if dup is not None and dup.rating_key != exclude_rk: raise HTTPException(status_code=409, detail="A collection with this name already exists") def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException: """Map a PlexError raised inside a mutate-then-resync block to the honest message. If the mutating call itself failed (``wrote`` False) the write never landed → "Plex write failed". If the write succeeded but the follow-up resync READ (Plex metadata/children) failed, the change IS on the Plex server → don't claim a write failure; say the local copy will reconcile on the next sync. Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary action-error toast, which is what both of these are — the app answered, it didn't go unreachable.""" if wrote: detail = ( "Collection updated in Plex, but refreshing the local copy failed; " f"it will reconcile on the next sync ({e})" ) else: detail = f"Plex write failed: {e}" return HTTPException(status_code=422, detail=detail) class CreateCollectionIn(BaseModel): library: str = "" title: str = "" item_rating_key: str = "" @router.post("/collections") def create_collection( body: CreateCollectionIn, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Create a manual collection (seeded with one item — Plex needs at least one). Editable by default.""" library = body.library title = body.title.strip() seed = body.item_rating_key if not title or not seed: raise HTTPException(status_code=400, detail="title and item_rating_key are required") if library: lib = ( db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() ) else: # The "New collection" flow only knows the seed title → derive its library. seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed) lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None if lib is None: raise HTTPException(status_code=404, detail="Unknown library") _reject_duplicate_name(db, lib.id, title) wrote = False try: with PlexClient(db) as plex: new = plex.create_collection(lib.plex_key, title, seed) wrote = True col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) col.editable = True db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) return _collection_card(col) @router.post("/collections/{rating_key}/items/{item_rating_key}") def collection_add_item( rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) wrote = False try: with PlexClient(db) as plex: plex.add_collection_item(rating_key, str(item_rating_key)) wrote = True col = plex_sync.resync_collection(db, plex, lib, rating_key) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) return _collection_card(col) @router.delete("/collections/{rating_key}/items/{item_rating_key}") def collection_remove_item( rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) wrote = False try: with PlexClient(db) as plex: plex.remove_collection_item(rating_key, str(item_rating_key)) wrote = True col = plex_sync.resync_collection(db, plex, lib, rating_key) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) return _collection_card(col) class CollectionRenameIn(BaseModel): title: str = "" @router.patch("/collections/{rating_key}") def collection_rename( rating_key: str, body: CollectionRenameIn, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) title = body.title.strip() if not title: raise HTTPException(status_code=400, detail="title is required") _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) lib = db.get(PlexLibrary, col.library_id) wrote = False try: with PlexClient(db) as plex: plex.rename_collection(lib.plex_key, rating_key, title) wrote = True col = plex_sync.resync_collection(db, plex, lib, rating_key) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) return _collection_card(col) @router.delete("/collections/{rating_key}") def collection_delete( rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) try: with PlexClient(db) as plex: plex.delete_collection(rating_key) plex_sync.delete_collection_local(db, lib, rating_key) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return {"deleted": rating_key} class CollectionEditableIn(BaseModel): editable: bool = False @router.post("/collections/{rating_key}/editable") def collection_set_editable( rating_key: str, body: CollectionEditableIn, _: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable if want and (col.smart or _collection_source(col) != "collection"): raise HTTPException( status_code=400, detail="Smart / auto-list collections can't be made editable", ) col.editable = want db.commit() return _collection_card(col)