move_item checked "already in the target?" and then re-parented without a lock, so a second tab's add or the scheduler's YouTube merge committing the same video in between hit uq_playlist_video and answered a raw 500. The re-parent now runs in a savepoint and a conflict falls back to the already-present outcome. The target is touched only when the row landed in it (an untouched list was promoted in the rail's "recently changed" sort), and _touch stamps the database clock like the mixin's own onupdate. The canonical-patterns table names owned_or_404 against ad-hoc ownership helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view, _own_tag, notifications._owned, downloads._own_job), two of them hand-rolled with a bare English 404. All call sites now use owned_or_404, and the download/link/notification 404s are structured like the rest. The contract test reads every "what is gone" the routes name, and the YouTube refusal reasons, and requires both languages to name each.
388 lines
12 KiB
Python
388 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, HTTPException
|
|
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
|
|
|
|
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 HTTPException(status_code=400, detail="title is 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 HTTPException(status_code=400, detail="title is 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 HTTPException(status_code=404, detail="Unknown 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 {"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 {"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}
|