Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
392 lines
12 KiB
Python
392 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 _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist:
|
|
return owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
|
|
|
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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 = _own_playlist_or_404(db, pid, user)
|
|
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}
|