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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user