The Browse-by-genre tiles and the Collections browse tiles were flat color-hash
gradients. Give each a 2×2 collage of its top films' poster art, with the color hash
kept as the fallback so a group with no matching art (a show-only or empty collection)
never renders blank.
Backend: a shared `top_art_keys` windowed query returns, per key (a genre value or a
collection rating_key), the top few items' rating_keys — best-rated then newest, in one
query. Wired into the Discover genre tiles and the collections union endpoint; the client
renders the posters from the existing /api/plex/image/{key} endpoint, so no new image
plumbing. Frontend: a shared PosterTile component (collage + scrim + label, color-hash
fallback) replaces the two ad-hoc colored-tile implementations.
Verified live on localdev: genre tiles and collection tiles render real poster collages,
with the color fallback correctly kicking in for groups without art.
Gate: tsc/lint/knip/prettier clean, 423 vitest; backend ruff clean, 11 discover tests
(incl. a new genre-art assertion).
260 lines
9.1 KiB
Python
260 lines
9.1 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,
|
||
User,
|
||
)
|
||
from app.plex import sync as plex_sync
|
||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||
from app.plex.lookups import by_rating_key
|
||
from app.plex.query import like_escape, top_art_keys
|
||
from app.routes.plex._shared import _content_libs
|
||
|
||
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 2×2 poster collage per tile: the top movies in each collection (color hash is the client
|
||
# fallback for show-only / empty collections). One windowed query across the content movie libs.
|
||
movie_ids, _show_ids = _content_libs(db)
|
||
art = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, [c.rating_key for c in rows])
|
||
return {"collections": [{**_collection_card(c), "art": art.get(c.rating_key, [])} for c in rows]}
|
||
|
||
|
||
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
|
||
|
||
|
||
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"
|
||
)
|
||
lib = db.execute(
|
||
select(PlexLibrary).where(PlexLibrary.plex_key == library)
|
||
).scalars().first()
|
||
if lib is None:
|
||
raise HTTPException(status_code=404, detail="Unknown library")
|
||
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")
|
||
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)
|