feat(playlists): act on the selected rows from the toolbar, and undo either

While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
This commit is contained in:
2026-11-01 22:07:09 +01:00
parent 3b85881af4
commit efbbb23525
24 changed files with 1114 additions and 236 deletions
+13 -41
View File
@@ -292,7 +292,7 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
MoveReason = Literal["same_playlist"]
class MoveRefused(Exception):
@@ -447,41 +447,6 @@ def delete_playlist(db: Session, pl: Playlist) -> None:
db.flush()
def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
"""Move one video from `src` into `dst`, in the caller's one transaction.
The client-side shape — add to the target, then remove from the source — leaves the video in
BOTH lists whenever the second call fails. Idempotent: a video already in the target is simply
dropped from the source (moving into a list that has it is a removal, not a duplicate). Raises
`MoveRefused` for the same list twice, or a video a concurrent edit already took out."""
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)
row = queries.item(db, src.id, video_id)
if row is None:
raise MoveRefused("not_in_playlist")
dst_changed = False
if queries.item(db, dst.id, video_id) is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
row.playlist_id = dst.id
row.position = next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(row)
# The source always changed; the target only when the row landed in it. Touching a target
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
recompute_dirty(db, src)
touch(src)
if dst_changed:
recompute_dirty(db, dst)
touch(dst)
db.flush()
def remove_items(db: Session, pl: Playlist, video_ids: list[str]) -> int:
"""Remove these videos from a playlist, in one transaction; a video not in it any more is simply
not counted. Says how many went."""
@@ -534,12 +499,14 @@ def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: li
def move_items(
db: Session, src: Playlist, dst: Playlist, video_ids: list[str]
) -> tuple[list[str], list[str]]:
"""Move these videos from `src` into `dst`, in the source's order whatever order they were picked
in, as `move_item` does one. Returns the ones that landed in the target and the ones it already
held, which only left the source: the Undo needs to tell them apart. A video a concurrent edit
already took out of the source is in neither."""
"""Move these videos from `src` into `dst`, in the caller's one transaction, in the source's
order whatever order they were picked in. Returns the ones that landed in the target and the ones
it already held, which only left the source: the Undo needs to tell them apart. A video a
concurrent edit already took out of the source is in neither. Raises `MoveRefused` for the same
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)
wanted = set(video_ids)
rows = [it for it in queries.items(db, src.id) if it.video_id in wanted]
@@ -553,13 +520,18 @@ def move_items(
db.delete(row)
else:
moved.append(row.video_id)
# Re-parented, as `move_item` says why: `added_at` survives the move.
# Re-parented rather than deleted and inserted: `added_at` ("when I saved this") survives
# the move, and there is no window where the video belongs to neither list. `held` stays
# true: both lists are locked, and an insert into the target (another tab's add, a
# YouTube merge) waits for the lock, as its foreign key needs the target row.
row.playlist_id = dst.id
row.position = position
position += 1
if rows:
recompute_dirty(db, src)
touch(src)
# Only when a row landed in it: touching a target that already had the video promoted an untouched
# list in the rail's "recently changed" sort.
if moved:
recompute_dirty(db, dst)
touch(dst)