Two adds read the same max(position) and a reorder racing an add collided with it. Every add/insert/reorder takes the parent row FOR UPDATE; the YouTube list breaks position ties by id and a Plex reorder renumbers rows it was not sent.
464 lines
15 KiB
Python
464 lines
15 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 typing import Annotated
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
|
from sqlalchemy import delete, func, select
|
|
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:
|
|
# Locked (like every add / reorder here): two adds otherwise read the same max position.
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
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
|
|
|
|
|
|
class PlexItemAddBulkIn(PlexItemKeysIn):
|
|
# Where each key goes back (E28 S3e2, 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
|
|
|
|
|
|
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
|
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
|
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
|
already in the list does not move: the add stays idempotent."""
|
|
rows = list(
|
|
db.execute(
|
|
select(PlexPlaylistItem)
|
|
.where(PlexPlaylistItem.playlist_id == pid)
|
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
|
).scalars()
|
|
)
|
|
present = {r.item_id for r in rows}
|
|
added = 0
|
|
for at, it in sorted(placed, key=lambda p: p[0]):
|
|
if it.id in present:
|
|
continue
|
|
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
|
db.add(row)
|
|
rows.insert(min(at, len(rows)), row)
|
|
present.add(it.id)
|
|
added += 1
|
|
for i, r in enumerate(rows):
|
|
r.position = i
|
|
return added
|
|
|
|
|
|
@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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
if body.positions is not None:
|
|
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
|
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 = _insert_at(db, pid, placed)
|
|
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}
|
|
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", lock=True)
|
|
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()
|
|
}
|
|
rows = {
|
|
r.item_id: r
|
|
for r in db.execute(
|
|
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
|
|
).scalars()
|
|
}
|
|
pos = 0
|
|
seen: set[int] = set()
|
|
for rk in order:
|
|
row = rows.get(id_by_rk.get(rk, -1))
|
|
if row is not None and row.item_id not in seen:
|
|
row.position = pos
|
|
pos += 1
|
|
seen.add(row.item_id)
|
|
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
|
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
|
for row in sorted(
|
|
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
|
):
|
|
row.position = pos
|
|
pos += 1
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return {"ok": True}
|