Two follow-ups after testing the collection editing against real Plex, which revealed that Plex CONFLATES same-named collections (two rating_keys resolve to one merged member list) — a genuine footgun. - Duplicate-name guard: create and rename now reject (409) a title that already exists in the target section (case-insensitive), before any Plex write. create() can also derive the library from the seed item, so the caller needn't know the section key. The create dialogs surface the 409, and the New-collection panel warns inline before submit. - A "New collection" button on the Collections browse surface (admin only) opens a panel: a name field (with the live duplicate check) + a search to pick the FIRST title — Plex can't create an empty collection, it needs a seed. Creating opens the new collection's detail page straight away, so you can keep adding titles there. This is the B-route the user wanted, vs. the only prior path (a movie's "add to collection"). Verified live: the 409 guard (creating an existing name is blocked, no Plex write), and the full New-collection flow (name + first title → create → lands on the detail page); test collections created and deleted, real account left clean. Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 7 collections tests (+ a duplicate-name-rejection assertion).
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""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")
|
|
|
|
|
|
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)
|
|
try:
|
|
with PlexClient(db) as plex:
|
|
new = plex.create_collection(lib.plex_key, title, seed)
|
|
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 HTTPException(status_code=422, detail=f"Plex write failed: {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)
|
|
try:
|
|
with PlexClient(db) as plex:
|
|
plex.add_collection_item(rating_key, str(item_rating_key))
|
|
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
|
db.commit()
|
|
except (PlexError, PlexNotConfigured) as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=422, detail=f"Plex write failed: {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)
|
|
try:
|
|
with PlexClient(db) as plex:
|
|
plex.remove_collection_item(rating_key, str(item_rating_key))
|
|
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
|
db.commit()
|
|
except (PlexError, PlexNotConfigured) as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=422, detail=f"Plex write failed: {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)
|
|
try:
|
|
with PlexClient(db) as plex:
|
|
plex.rename_collection(lib.plex_key, rating_key, title)
|
|
col = plex_sync.resync_collection(db, plex, lib, rating_key)
|
|
db.commit()
|
|
except (PlexError, PlexNotConfigured) as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=422, detail=f"Plex write failed: {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)
|