"""Plex playlists: the current user's personal, orderable playlists of playable items.""" from typing import TYPE_CHECKING, Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel, Field, model_validator from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db from app.models import User from app.plex import queries, service from app.plex.cards import display_title, leaf_card, norm_lang from app.refusal import refused from app.routes._common import not_found if TYPE_CHECKING: from app.plex.models import PlexPlaylist router = APIRouter(prefix="/api/plex", tags=["plex"]) def _playlist_card(pl: "PlexPlaylist", count: int, thumb_rk: str | None) -> dict: return { "id": pl.id, "title": pl.title, "item_count": count, "thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None, } def _card_of(db: Session, pl: "PlexPlaylist") -> dict: return _playlist_card(pl, queries.playlist_count(db, pl.id), queries.playlist_cover(db, pl.id)) def _own_playlist(db: Session, user: User, pid: int, *, lock: bool = False) -> "PlexPlaylist": pl = queries.own_playlist(db, user, pid, lock=lock) if pl is None: raise not_found("playlist") return pl @router.get("/playlists") def playlists( contains: str | None = None, contains_group: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """The current user's playlists (newest-touched first), each with an item count + a cover thumb. `contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog). `contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog.""" contains_id = queries.item_id(db, contains) if contains else None group_ids: set[int] | None = None if contains_group is not None: group_ids = queries.item_ids(db, [s for s in (contains_group or "").split(",") if s]) out = [] for pl in queries.playlists_of(db, user): card = _card_of(db, pl) if contains_id is not None: card["has_item"] = queries.playlist_holds(db, pl.id, {contains_id}) > 0 if group_ids is not None: card["group_in"] = queries.playlist_holds(db, pl.id, group_ids) out.append(card) resp: dict = {"playlists": out} if group_ids is not None: resp["group_size"] = len(group_ids) return resp class CreatePlexPlaylistIn(BaseModel): title: str = "" item_rating_key: str = "" @router.post("/playlists") def create_playlist( body: CreatePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: title = body.title.strip() if not title: raise refused(400, "title_required") seed = queries.item(db, body.item_rating_key) if body.item_rating_key else None pl = service.create_playlist(db, user, title, seed) db.commit() return _playlist_card(pl, 1 if seed else 0, seed.rating_key if seed else None) @router.get("/playlists/{pid}") def playlist_detail( pid: int, lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: lang = norm_lang(lang) pl = _own_playlist(db, user, pid) items = queries.playlist_items(db, pl.id) states = queries.states(db, user, [it.id for it in items]) shows = queries.shows_by_ids(db, {it.show_id for it in items if it.show_id}) cards = [ leaf_card( it, states.get(it.id), display_title(shows[it.show_id], lang) if it.show_id in shows else None, shows[it.show_id].rating_key if it.show_id in shows else None, lang=lang, ) for it in items ] return {"id": pl.id, "title": pl.title, "items": cards} class RenamePlexPlaylistIn(BaseModel): title: str = "" @router.patch("/playlists/{pid}") def rename_playlist( pid: int, body: RenamePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, pid) title = body.title.strip() if not title: raise refused(400, "title_required") service.rename_playlist(pl, title) db.commit() return _playlist_card(pl, queries.playlist_count(db, pl.id), queries.playlist_cover(db, pl.id)) @router.delete("/playlists/{pid}") def delete_playlist( pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: pl = _own_playlist(db, user, pid) service.delete_playlist(db, pl) db.commit() return {"deleted": pid} class PlexItemRefIn(BaseModel): item_rating_key: str = "" @router.post("/playlists/{pid}/items") def playlist_add_item( pid: int, body: PlexItemRefIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: # Locked (like every add / reorder here): two adds otherwise read the same max position. pl = _own_playlist(db, user, pid, lock=True) it = queries.item(db, body.item_rating_key) if it is None: raise not_found("item") service.add_item(db, pl, it) db.commit() return {"ok": True} @router.delete("/playlists/{pid}/items/{item_rating_key}") def playlist_remove_item( pid: int, item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: # Locked like the adds and the reorder: the list row first, then its items — a remove that # locked an item row before a reorder locked the list deadlocked against it. pl = _own_playlist(db, user, pid, lock=True) it = queries.item(db, item_rating_key) if it is not None: service.remove_item(db, pl, it) db.commit() return {"ok": True} class PlexItemKeysIn(BaseModel): item_rating_keys: list[str] = [] class PlexItemAddBulkIn(PlexItemKeysIn): # Where each key goes back (the list page's "Undo" of a removal): an index over the # ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows # at the end whenever the second call fails, and the order it sends would overwrite anything # another tab changed in between. positions: list[Annotated[int, Field(ge=0)]] | None = None @model_validator(mode="after") def _one_position_per_key(self): if self.positions is not None and len(self.positions) != len(self.item_rating_keys): raise ValueError("positions must pair one-to-one with item_rating_keys") return self @router.post("/playlists/{pid}/items/bulk") def playlist_add_bulk( pid: int, body: PlexItemAddBulkIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Append many items at once (a whole season/show). Already-present items are skipped; the input order is preserved for the newly-added ones. With `positions`, each key is put back at its index instead of appended. Returns how many were added + the new total.""" pl = _own_playlist(db, user, pid, lock=True) items = queries.items_by_keys(db, body.item_rating_keys) if body.positions is not None: by_rk = {it.rating_key: it for it in items} placed = [ (at, by_rk[str(rk)]) for rk, at in zip(body.item_rating_keys, body.positions, strict=True) if str(rk) in by_rk ] added = service.insert_items_at(db, pl, placed) else: added = service.add_items(db, pl, items) db.commit() return {"ok": True, "added": added, "item_count": queries.playlist_count(db, pid)} @router.post("/playlists/{pid}/items/remove-bulk") def playlist_remove_bulk( pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Remove many items at once (a whole season/show, or a multi-select). Returns how many were removed + the new total.""" pl = _own_playlist(db, user, pid, lock=True) ids = [it.id for it in queries.items_by_keys(db, body.item_rating_keys)] removed = service.remove_items(db, pl, ids) db.commit() return {"ok": True, "removed": removed, "item_count": queries.playlist_count(db, pid)} @router.put("/playlists/{pid}/order") def reorder_playlist( pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Set the playlist order from an explicit list of item rating_keys (0-based positions).""" pl = _own_playlist(db, user, pid, lock=True) service.reorder_playlist(db, pl, body.item_rating_keys) db.commit() return {"ok": True}