Files
siftlode/backend/app/routes/plex/collections.py
T
peter 3b0e365e26 refactor(plex): the browse, detail, people and collection pages read through queries and write through the service
The HTTP files browse, detail, people, collections and _shared do no database work of their own
now. New plex/queries.py (the rows a page shows, a user's watch state through userscope),
plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a
failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the
unified browse feed, its search and facets) and plex/collections.py (a collection's source,
editability, card, certificate and member order). Every watch-state join in the moved code names
its owner through owned_by.

Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
2026-10-19 22:11:20 +02:00

389 lines
15 KiB
Python

"""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)