fix(playlists): what the first review of bulk actions found

An Esc during a pointer drag is spent on the drag and leaves the selection.
A wheel over the rail re-reads the target each frame, so a drop lands on the
row under the pointer. After a bulk removal the focus goes to the next row
left, and to the list's menu when the list is emptied.

remove-bulk names the videos that went, and its Undo brings back only those.
An Undo of a move keeps each row (and when it was saved), leaves out a video
that moved on from the target, closes up its place, and says how many came
back. Delete with every tick hidden removes nothing and says why. A list gone
before the lock answers 404, and a body without its videos 422.

One removal path now: the single row goes through the bulk one, the same
toast and undo, which also retires the add-at-position route. railDrop reuses
the store and scrollParent, the drag image the overlay and the z-index token,
and the sensors keep their number. The count is read out from a region that
outlives the band. Tests fill the gaps the review named, each seen failing.
This commit is contained in:
2026-11-02 03:41:08 +01:00
parent d09b5d6abb
commit 1a1e9d9542
24 changed files with 1168 additions and 379 deletions
+57 -44
View File
@@ -406,30 +406,6 @@ def remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -
return True
def place_item(db: Session, pl: Playlist, video_id: str, position: int) -> None:
"""Put a video already in the list at `position` (clamped to the end), renumbering the whole
list: positions can have gaps after removals, so "index N" is only meaningful over the
ordered rows."""
rows = queries.items(db, pl.id)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
db.flush()
def add_to_playlist(db: Session, pl: Playlist, video_id: str, position: int | None) -> None:
"""Add a video to one of the user's lists, at `position` when given (the list page's "Undo" of
a removal puts the row back where it was in ONE request), else at the end. A video already in
the list stays where it is."""
added = add_item(db, pl, video_id, mark_dirty=False)
if added and position is not None:
place_item(db, pl, video_id, position)
recompute_dirty(db, pl)
db.flush()
def rename_playlist(db: Session, pl: Playlist, name: str) -> None:
"""A new name moves `dirty`; the caller holds the playlist's lock (the scheduled mirror refresh
reads `dirty` under it)."""
@@ -447,9 +423,9 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
db.flush()
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> list[str]:
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
not counted. Says how many went."""
not among them. Says which went: an Undo must not bring back one this removal never took."""
lock_playlists(db, pl.id)
wanted = set(video_ids)
rows = [it for it in queries.items(db, pl.id) if it.video_id in wanted]
@@ -459,24 +435,38 @@ def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return len(rows)
return [row.video_id for row in rows]
def _insert_at(db: Session, pl: Playlist, placed: list[tuple[int, str]]) -> int:
def _insert_at(
db: Session,
pl: Playlist,
placed: list[tuple[int, str]],
leaving: dict[str, PlaylistItem] | None = None,
staying_out: frozenset[str] = frozenset(),
) -> int:
"""Add videos at their indices (ascending, so each index is where the row stood when the rows
before it were back) and renumber the list 0..n-1, as positions can have gaps. A video already in
the list stays where it is, and one the catalog has lost is left out. Says how many were added;
the caller holds the playlist's lock."""
the list stays where it is, and one the catalog has lost or in `staying_out` is left out, the
ones after it closing up. A video with a row in `leaving` brings that row along, for the reason
`move_items` re-parents. Says how many were added; the caller holds the playlist's lock."""
leaving = leaving or {}
rows = queries.items(db, pl.id)
present = {r.video_id for r in rows}
known = queries.known_video_ids(db, [vid for _, vid in placed])
added = 0
left_out = 0
for at, vid in sorted(placed, key=lambda p: p[0]):
if vid in present or vid not in known:
if vid in present or vid not in known or vid in staying_out:
left_out += 1
continue
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
db.add(row)
rows.insert(min(at, len(rows)), row)
row = leaving.pop(vid, None)
if row is None:
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
db.add(row)
else:
row.playlist_id = pl.id
rows.insert(min(at - left_out, len(rows)), row)
present.add(vid)
added += 1
for pos, row in enumerate(rows):
@@ -496,6 +486,24 @@ def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: li
return added
class ListGone(Exception):
"""A list of a two-list write was deleted between the route's read and the lock."""
def __init__(self, *, source: bool):
super().__init__("source" if source else "target")
self.source = source
def _lock_both(db: Session, src: Playlist, dst: Playlist) -> None:
"""Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise. A list
already gone is said here: a write into it would answer with its foreign key's 500."""
found = lock_playlists(db, src.id, dst.id)
if src.id not in found:
raise ListGone(source=True)
if dst.id not in found:
raise ListGone(source=False)
def move_items(
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
) -> tuple[list[str], list[str]]:
@@ -506,8 +514,7 @@ def move_items(
list twice."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
_lock_both(db, src, dst)
wanted = set(video_ids)
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
@@ -547,17 +554,23 @@ def undo_move(
already_there: list[str],
positions: list[int],
) -> int:
"""Take a move back in one transaction: the `moved` videos leave `dst` and every video returns to
its place in `src`, while the `already_there` ones stay in `dst`, which held them before.
`positions` pairs with `moved` + `already_there`. Says how many came back."""
lock_playlists(db, src.id, dst.id)
"""Take a move back in one transaction: the `moved` videos leave `dst` and return to their places
in `src`, keeping their rows, as the move did. The `already_there` ones return too and stay in
`dst`, which held them before. A moved video `dst` no longer holds has gone on from there, and
bringing it back would leave it in lists the reader never put it in, so it stays out, as a
move skips a video a concurrent edit took out. `positions` pairs with `moved` + `already_there`.
Says how many came back."""
_lock_both(db, src, dst)
wanted = set(moved)
rows = [it for it in queries.items(db, dst.id) if it.video_id in wanted]
for row in rows:
rows = {it.video_id: it for it in queries.items(db, dst.id) if it.video_id in wanted}
changed = bool(rows)
placed = list(zip(positions, [*moved, *already_there], strict=True))
back = _insert_at(db, src, placed, leaving=rows, staying_out=frozenset(wanted - rows.keys()))
# What `src` holds again by now only leaves `dst`.
for row in rows.values():
db.delete(row)
db.flush()
back = _insert_at(db, src, list(zip(positions, [*moved, *already_there], strict=True)))
if rows:
if changed:
recompute_dirty(db, dst)
touch(dst)
if back:
+22 -22
View File
@@ -6,7 +6,7 @@ out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope
from typing import TYPE_CHECKING, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, model_validator
from sqlalchemy.orm import Session
from app import quota
@@ -386,24 +386,17 @@ def delete_playlist(
return {"deleted": playlist_id}
class AddItemIn(VideoRefIn):
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
position: int | None = Field(default=None, ge=0)
@router.post("/{playlist_id}/items")
def add_item(
playlist_id: int,
body: AddItemIn,
body: VideoRefIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
pl = _own_playlist(db, user, playlist_id, lock=True)
video_id = body.video_id
_video_or_404(db, video_id)
service.add_to_playlist(db, pl, video_id, body.position)
service.add_item(db, pl, video_id, mark_dirty=True)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
@@ -415,14 +408,15 @@ def remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = _own_playlist(db, user, playlist_id)
pl = _own_playlist(db, user, playlist_id, lock=True)
service.remove_item(db, pl, video_id, mark_dirty=True)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
class VideoIdsIn(BaseModel):
video_ids: list[str] = []
# Required: with a default, a misnamed key answered 200 having acted on no video.
video_ids: list[str]
def _paired(video_count: int, positions: list[int]) -> None:
@@ -460,6 +454,11 @@ class UndoMoveIn(BaseModel):
return self
def _list_gone(e: service.ListGone) -> HTTPException:
"""Each list of a two-list write under the reason the route's own read gives it."""
return not_found("playlist") if e.source else not_found("move_target")
def _other_list(db: Session, user: User, playlist_id: int) -> "Playlist":
"""The other list of a move. Its own reason: "this playlist doesn't exist any more" about a list
that is gone was read as the one on screen, which is still there."""
@@ -476,8 +475,8 @@ def remove_items(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Remove many videos from a playlist at once. Returns how many went."""
pl = _own_playlist(db, user, playlist_id)
"""Remove many videos from a playlist at once. Returns the ones that went."""
pl = _own_playlist(db, user, playlist_id, lock=True)
removed = service.remove_items(db, pl, body.video_ids)
db.commit()
return {"ok": True, "playlist_id": pl.id, "removed": removed}
@@ -491,7 +490,7 @@ def restore_items(
db: Session = Depends(get_db),
) -> dict:
"""The "Undo" of a removal: the videos back at their places. Returns how many came back."""
pl = _own_playlist(db, user, playlist_id)
pl = _own_playlist(db, user, playlist_id, lock=True)
added = service.restore_items(db, pl, body.video_ids, body.positions)
db.commit()
return {"ok": True, "playlist_id": pl.id, "added": added}
@@ -519,6 +518,8 @@ def move_items(
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": e.reason}
) from None
except service.ListGone as e:
raise _list_gone(e) from None
db.commit()
return {
"ok": True,
@@ -539,19 +540,18 @@ def undo_move(
"""The "Undo" of a move, back into this playlist in one transaction. Returns how many came back."""
src = _own_playlist(db, user, playlist_id)
dst = _other_list(db, user, body.from_playlist_id)
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
try:
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
except service.ListGone as e:
raise _list_gone(e) from None
db.commit()
return {"ok": True, "playlist_id": src.id, "added": added}
class ReorderItemsIn(BaseModel):
video_ids: list[str] = []
@router.put("/{playlist_id}/order")
def reorder_items(
playlist_id: int,
body: ReorderItemsIn,
body: VideoIdsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict: