A read from before a write no longer brings a row back: a write calls off the read on its way, what lands while one is out stays off the screen, and the last write out does the refresh. A failure puts back its own rows, an accepted reorder brings back none, and an Undo reads past the cache. A list the cache has dropped is read before a removal, so its Undo has places. undo-move refuses the same list twice, positions cannot repeat, and undo-move and the Plex key bodies refuse a body without their videos. Focus goes to the header's box when the last shown row or Save this order goes.
259 lines
8.8 KiB
Python
259 lines
8.8 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, rollup, 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 = rollup.item_state_map(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):
|
|
# Required: with a default, a misnamed key answered 200 having acted on no item, and a reorder
|
|
# took it for an empty order.
|
|
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}
|