"""Plex playlists: the current user's personal, orderable playlists of playable items.""" from datetime import datetime, timezone from fastapi import APIRouter, Depends from typing import Annotated from pydantic import BaseModel, Field, model_validator from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app.auth import current_user from app.db import get_db from app.models import ( PlexItem, PlexPlaylist, PlexPlaylistItem, PlexShow, PlexState, User, ) from app.plex.cards import leaf_card from app.plex.lookups import by_rating_key from app.routes._common import owned_or_404, not_found from app.refusal import refused 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 _playlist_item_query(pid: int): return ( select(PlexPlaylistItem, PlexItem) .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) .where(PlexPlaylistItem.playlist_id == pid) .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ) @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 = None if contains: contains_id = db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(contains))) group_ids: set[int] | None = None if contains_group is not None: rks = [s for s in (contains_group or "").split(",") if s] group_ids = set( db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""]))).scalars() ) out = [] for pl in db.execute( select(PlexPlaylist) .where(PlexPlaylist.user_id == user.id) .order_by(PlexPlaylist.updated_at.desc()) ).scalars(): count = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id) ) or 0 ) first = db.execute(_playlist_item_query(pl.id)).first() card = _playlist_card(pl, count, first[1].rating_key if first else None) if contains_id is not None: card["has_item"] = ( db.execute( select(PlexPlaylistItem.id).where( PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id == contains_id, ) ).first() is not None ) if group_ids is not None: card["group_in"] = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where( PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(group_ids or [0]), ) ) or 0 ) 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") pl = PlexPlaylist(user_id=user.id, title=title) db.add(pl) db.flush() seed = body.item_rating_key count = 0 thumb_rk = None if seed: it = by_rating_key(db, PlexItem, seed) if it is not None: db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) count, thumb_rk = 1, it.rating_key db.commit() return _playlist_card(pl, count, thumb_rk) @router.get("/playlists/{pid}") def playlist_detail( pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") rows = db.execute(_playlist_item_query(pid)).all() items = [it for (_pi, it) in rows] states = { s.item_id: s for s in db.execute( select(PlexState).where( PlexState.user_id == user.id, PlexState.item_id.in_([it.id for it in items] or [0]), ) ).scalars() } shows = { sh.id: sh for sh in db.execute( select(PlexShow).where( PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) ) ).scalars() } cards = [ leaf_card( it, states.get(it.id), shows[it.show_id].title if it.show_id in shows else None, shows[it.show_id].rating_key if it.show_id in shows else None, ) 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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") title = body.title.strip() if not title: raise refused(400, "title_required") pl.title = title db.commit() count = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id) ) or 0 ) first = db.execute(_playlist_item_query(pl.id)).first() return _playlist_card(pl, count, first[1].rating_key if first else None) @router.delete("/playlists/{pid}") def delete_playlist( pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") db.delete(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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True) it = by_rating_key(db, PlexItem, body.item_rating_key) if it is None: raise not_found("item") exists = ( db.execute( select(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id ) ) .scalars() .first() ) if exists is None: maxpos = db.scalar( select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( PlexPlaylistItem.playlist_id == pid ) ) db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1)) pl.updated_at = datetime.now(timezone.utc) 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: pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") it = by_rating_key(db, PlexItem, item_rating_key) if it is not None: db.execute( delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id ) ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} class PlexItemKeysIn(BaseModel): item_rating_keys: list[str] = [] def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: """Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys).""" order = [str(k) for k in rks if str(k)] if not order: return [] by_rk = { it.rating_key: it for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars() } seen: set[str] = set() out: list[PlexItem] = [] for rk in order: it = by_rk.get(rk) if it is not None and rk not in seen: seen.add(rk) out.append(it) return out class PlexItemAddBulkIn(PlexItemKeysIn): # Where each key goes back (E28 S3e2, 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 def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int: """Insert new rows at their indices (ascending, so each index is where the row stood when the rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item already in the list does not move: the add stays idempotent.""" rows = list( db.execute( select(PlexPlaylistItem) .where(PlexPlaylistItem.playlist_id == pid) .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ).scalars() ) present = {r.item_id for r in rows} added = 0 for at, it in sorted(placed, key=lambda p: p[0]): if it.id in present: continue row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0) db.add(row) rows.insert(min(at, len(rows)), row) present.add(it.id) added += 1 for i, r in enumerate(rows): r.position = i return added @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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True) if body.positions is not None: by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)} 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 = _insert_at(db, pid, placed) if added: pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid) ) or 0 ) return {"ok": True, "added": added, "item_count": total} items = _items_by_rks(db, body.item_rating_keys) existing = set( db.execute( select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid) ).scalars() ) pos = int( db.scalar( select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( PlexPlaylistItem.playlist_id == pid ) ) ) added = 0 for it in items: if it.id in existing: continue pos += 1 db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos)) existing.add(it.id) added += 1 if added: pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid) ) or 0 ) return {"ok": True, "added": added, "item_count": total} @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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist") ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)] removed = 0 if ids: removed = db.execute( delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) ), execution_options={"synchronize_session": False}, ).rowcount if removed: pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( db.scalar( select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid) ) or 0 ) return {"ok": True, "removed": removed, "item_count": total} @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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True) order = body.item_rating_keys id_by_rk = { it.rating_key: it.id for it in db.execute( select(PlexItem).where(PlexItem.rating_key.in_(order or [""])) ).scalars() } rows = { r.item_id: r for r in db.execute( select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid) ).scalars() } pos = 0 seen: set[int] = set() for rk in order: row = rows.get(id_by_rk.get(rk, -1)) if row is not None and row.item_id not in seen: row.position = pos pos += 1 seen.add(row.item_id) # Rows the payload omitted (added in another tab meanwhile) keep their relative order after # the given ones, so no two rows share a position (the YouTube reorder does the same). for row in sorted( (r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id) ): row.position = pos pos += 1 pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True}