Files
siftlode/backend/app/routes/plex/playlists.py
T
peter 531eb2eef1 refactor(plex): playlists, playback, Discover and admin through the unit; the plex package is fenced
The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds
SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a
user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a
season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py.

The plex package joins FENCED: it reads no other unit's tables, says whose a row is through
userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in
its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes.
A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins.

Pins 178 -> 0; basedpyright baseline 118 -> 106.
2026-10-19 22:42:46 +02:00

257 lines
8.6 KiB
Python

"""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}