fix(playlists): lock the playlist row before reading and writing item positions

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.
This commit is contained in:
2026-09-27 13:58:23 +02:00
parent c9e95ecea6
commit b456232d00
4 changed files with 180 additions and 21 deletions
+9 -5
View File
@@ -28,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
def get_or_404(
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
) -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
requests that read-then-write its children (a playlist's item positions) run one after the other."""
obj = db.get(model, id_, with_for_update=True if lock else None)
if obj is None:
raise not_found(what)
return obj
@@ -45,12 +48,13 @@ def owned_or_404(
*,
owner_attr: str = "user_id",
what: str = "generic",
lock: bool = False,
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what)
obj = get_or_404(db, model, id_, what=what, lock=lock)
if getattr(obj, owner_attr) != user.id:
raise not_found(what)
return obj
+9 -4
View File
@@ -79,7 +79,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
db.execute(select(Playlist.id).where(Playlist.id == playlist_id).with_for_update())
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
@@ -427,7 +430,8 @@ def get_playlist(
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
# `id` breaks a position tie the same way on every read (the renumbering add does too).
.order_by(PlaylistItem.position, PlaylistItem.id)
).all()
return {
"id": pl.id,
@@ -532,7 +536,7 @@ def add_item(
db.execute(
select(PlaylistItem)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
.order_by(PlaylistItem.position, PlaylistItem.id)
).scalars()
)
new = next(r for r in rows if r.video_id == video_id)
@@ -642,7 +646,8 @@ def reorder_items(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: a concurrent add between reading the items and writing the order would collide.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
order = body.video_ids
items = {
it.video_id: it
+26 -12
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -219,7 +219,8 @@ def playlist_add_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# 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")
@@ -337,7 +338,7 @@ def playlist_add_bulk(
"""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")
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 = [
@@ -428,7 +429,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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
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
@@ -436,14 +437,27 @@ def reorder_playlist(
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)
)
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}