"""Plex collections: browse mirrored collections + the admin manual-collection editor.""" from datetime import datetime from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session from app.mailtext import lang_for_user from app.collation import sorted_words from app import logs from app.auth import admin_user, current_user from app.db import get_db from app.models import User from app.plex import queries, rollup, service from app.plex import sync as plex_sync from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.collections import ( DEFAULT_MEMBER_SORT, Member, collection_card, collection_cert, collection_editable, collection_source, sort_members, ) from app.refusal import refused from app.routes._common import not_found if TYPE_CHECKING: from app.plex.models import PlexCollection, PlexState log = logs.get_logger(__name__) router = APIRouter(prefix="/api/plex", tags=["plex"]) @router.get("/collections") def collections( library: str | None = None, q: str | None = None, keys: 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). `keys` (CSV of rating_keys) narrows to just those. It exists because a Plex link carries the collection FILTER as bare rating_keys — nothing in the URL says what "17045" is called — so the sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them through this endpoint costs one small row each; pulling the whole list to find two titles is ~200 KB.""" wanted = [k for k in keys.split(",") if k] if keys is not None else None rows = queries.collections_page(db, library, q, wanted, lang_for_user(user)) # 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). art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows]) 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/for/{item_rating_key}") def collections_for_item( item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """What a movie or show card's "Add to collection" lists: the editable collections of the title's library (a collection lives in one library, and only a manual one can take a title — the same `can_edit` the editor offers), each saying whether the title is in it already, by name.""" movie = queries.movie(db, item_rating_key) title = movie or queries.show(db, item_rating_key) if title is None: raise not_found("item") member_of = set(title.collection_keys or []) rows = queries.library_collections(db, title.library_id, lang_for_user(user)) return { "collections": [ {**collection_card(c), "contains": c.rating_key in member_of} for c in rows if collection_editable(c) ] } @router.get("/collections/{rating_key}") def collection_detail( rating_key: str, sort: str = DEFAULT_MEMBER_SORT, sort_dir: str = "asc", lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """One collection's hero data + its members (movies + shows) in the requested order — the Collection info page. The sort vocabulary is the browse grid's (`PLEX_SORT_KEYS`) so the same Sort control means the same thing on both surfaces; the default is release year ascending, which is what this page did before it had a control. Read-only here; edits go through the admin mutation endpoints below.""" lang = norm_lang(lang) col = queries.collection(db, rating_key) if col is None: raise not_found("collection") movies, shows = queries.collection_members(db, rating_key, lang) mstate = rollup.item_state_map(db, user.id, [m.id for m in movies]) # ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` — # the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The # cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on # a show collection while the control still offered it. show_rows = queries.show_rollups(db, user.id, [s.id for s in shows]) sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows} sresumed = {r.show_id: r.resumed_at for r in show_rows} def _movie_last_watched(st: "PlexState | None") -> datetime | None: # Same definition as the browse grid's: when it was last resumed, else when it was marked # watched (coalesce(progress_updated_at, watched_at)). return (st.progress_updated_at or st.watched_at) if st else None # Merge the two type-homogeneous lists into ONE member list, then order it. members = [ Member( kind="movie", obj=m, title=display_title(m, lang).lower(), year=m.year, rating=m.rating, added=m.added_at, release=m.originally_available_at, duration=m.duration_s, last_watched=_movie_last_watched(mstate.get(m.id)), ) for m in movies ] + [ Member( kind="show", obj=sh, title=display_title(sh, lang).lower(), year=sh.year, rating=sh.rating, added=sh.added_at, release=sh.originally_available_at, duration=None, last_watched=sresumed.get(sh.id), ) for sh in shows ] alpha = {w: i for i, w in enumerate(sorted_words(db, [m.title for m in members], lang))} members = sort_members(members, sort, sort_dir, alpha) items = [ movie_card(m.obj, mstate.get(m.obj.id), lang=lang) if m.kind == "movie" else show_card(m.obj, sstate.get(m.obj.id, SHOW_NEW), lang=lang) for m in members ] years = [m.year for m in members if m.year 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 = queries.collection(db, rating_key) if col is None: raise not_found("collection") if not collection_editable(col): raise refused(403, "collection_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 = queries.collection_named(db, library_id, title) if dup is not None and dup.rating_key != exclude_rk: # Structured, so the bilingual UI says it in the reader's language (it was toasted in # Hungarian beside an English error dialog saying the same thing). raise HTTPException(status_code=409, detail={"code": "collection", "reason": "name_taken"}) 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.""" log.warning( "a Plex collection write did not settle", extra=logs.event( "plex.collections.write_failed", wrote=wrote, reason=type(e).__name__, error=str(e) ), ) return refused(422, "collection_pending_sync") if wrote else refused(422, "plex_write_failed") 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 refused(400, "collection_fields_required") seed_show = queries.show(db, seed) if library: lib = queries.library_by_key(db, library) else: # The "New collection" flow only knows the seed title → derive its library. seed_item = queries.item(db, seed) or seed_show lib = queries.library(db, seed_item.library_id) if seed_item else None if lib is None: raise not_found("library") _reject_duplicate_name(db, lib.id, title) wrote = False new_rk = "" try: with PlexClient(db) as plex: # A show collection is made as one (Plex type 2): as a movie one it stayed empty. new = plex.create_collection(lib.plex_key, title, seed, 2 if seed_show else 1) wrote = True new_rk = str(new.get("ratingKey") or "") col = plex_sync.resync_collection(db, plex, lib, new_rk) service.set_collection_editable(col, True) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() if wrote and new_rk: # It is on Plex now, and only the follow-up read failed: keep its row (see the service). service.keep_created_collection(db, lib, new_rk, title, seed) db.commit() raise _write_or_pending_error(wrote, e) from 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 = queries.library(db, 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) from 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 = queries.library(db, 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) from 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 refused(400, "title_required") _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) lib = queries.library(db, 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) from 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 = queries.library(db, 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 _write_or_pending_error(False, e) from None 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 = queries.collection(db, rating_key) if col is None: raise not_found("collection") want = body.editable if want and (col.smart or collection_source(col) != "collection"): raise refused(400, "collection_smart") service.set_collection_editable(col, want) db.commit() return collection_card(col)