fix(playlists): a racing move is not a 500; ownership through owned_or_404 everywhere

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.
This commit is contained in:
2026-09-19 07:01:18 +02:00
parent 2df45b27d3
commit e74986cc9a
10 changed files with 166 additions and 85 deletions
+8 -12
View File
@@ -24,10 +24,6 @@ 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,
@@ -140,7 +136,7 @@ def create_playlist(
def playlist_detail(
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
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 = {
@@ -183,7 +179,7 @@ def rename_playlist(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
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")
@@ -203,7 +199,7 @@ def rename_playlist(
def delete_playlist(
pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
db.delete(pl)
db.commit()
return {"deleted": pid}
@@ -220,7 +216,7 @@ def playlist_add_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
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")
@@ -252,7 +248,7 @@ def playlist_remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist_or_404(db, pid, user)
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(
@@ -297,7 +293,7 @@ def playlist_add_bulk(
) -> 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)
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
items = _items_by_rks(db, body.item_rating_keys)
existing = set(
db.execute(
@@ -340,7 +336,7 @@ def playlist_remove_bulk(
) -> 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)
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:
@@ -370,7 +366,7 @@ def reorder_playlist(
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)
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
order = body.item_rating_keys
id_by_rk = {
it.rating_key: it.id