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.
389 lines
15 KiB
Python
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)
|