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.
257 lines
8.6 KiB
Python
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}
|