Files
siftlode/backend/app/routes/plex/playlists.py
T
peter 99df44d7fb fix(api): mutations answer ok; route helpers flush and leave the commit to the handler
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:

- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
  `{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
  reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
  flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
  mark, and the invite decision + account activation — which an approval used to land in separate
  transactions. The other six commit on purpose (a token durable before its link is mailed, a
  single-use token burned as its own unit, the Google callback's tail, a background registration
  with its own session, the staged search ingest around YouTube calls), each listed with its reason.

`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
2026-09-19 18:21:06 +02:00

389 lines
12 KiB
Python

"""Plex playlists: the current user's personal, orderable playlists of playable items."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
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, 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:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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
@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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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")
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}