"""Plex playlists: the current user's personal, orderable playlists of playable items.""" from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import delete, func, select, update 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 router = APIRouter(prefix="/api/plex", tags=["plex"]) def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist: return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found") 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 HTTPException(status_code=400, detail="title is 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 = _own_playlist_or_404(db, pid, user) 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 = _own_playlist_or_404(db, pid, user) title = body.title.strip() if not title: raise HTTPException(status_code=400, detail="title is 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 = _own_playlist_or_404(db, pid, user) 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: pl = _own_playlist_or_404(db, pid, user) it = by_rating_key(db, PlexItem, body.item_rating_key) if it is None: raise HTTPException(status_code=404, detail="Unknown 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 = _own_playlist_or_404(db, pid, user) 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 @router.post("/playlists/{pid}/items/bulk") def playlist_add_bulk( pid: int, body: PlexItemKeysIn, 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. Returns how many were added + the new total.""" pl = _own_playlist_or_404(db, pid, user) 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 {"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 = _own_playlist_or_404(db, pid, user) 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 {"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 = _own_playlist_or_404(db, pid, user) 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() } for pos, rk in enumerate(order): iid = id_by_rk.get(rk) if iid is not None: db.execute( update(PlexPlaylistItem) .where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid ) .values(position=pos) ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True}