feat(playlists): remove and move many videos at once, and undo either, in one transaction

This commit is contained in:
2026-11-01 20:22:34 +01:00
parent 242f00671d
commit cd773e8f33
3 changed files with 584 additions and 1 deletions
+113
View File
@@ -482,6 +482,119 @@ def move_item(db: Session, src: Playlist, dst: Playlist, video_id: str) -> None:
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."""
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]
for row in rows:
db.delete(row)
if rows:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return len(rows)
def _insert_at(db: Session, pl: Playlist, placed: list[tuple[int, str]]) -> 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."""
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
for at, vid in sorted(placed, key=lambda p: p[0]):
if vid in present or vid not in known:
continue
row = PlaylistItem(playlist_id=pl.id, video_id=vid, position=0)
db.add(row)
rows.insert(min(at, len(rows)), row)
present.add(vid)
added += 1
for pos, row in enumerate(rows):
row.position = pos
return added
def restore_items(db: Session, pl: Playlist, video_ids: list[str], positions: list[int]) -> int:
"""Put removed videos back at their places, in ONE request: an append plus a whole-list reorder
leaves them at the end whenever the second call fails. Says how many came back."""
lock_playlists(db, pl.id)
added = _insert_at(db, pl, list(zip(positions, video_ids, strict=True)))
if added:
recompute_dirty(db, pl)
touch(pl)
db.flush()
return added
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."""
if src.id == dst.id:
raise MoveRefused("same_playlist")
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]
held = queries.in_playlist(db, dst.id, [r.video_id for r in rows])
position = next_item_position(db, dst.id)
moved: list[str] = []
already_there: list[str] = []
for row in rows:
if row.video_id in held:
already_there.append(row.video_id)
db.delete(row)
else:
moved.append(row.video_id)
# Re-parented, as `move_item` says why: `added_at` survives the move.
row.playlist_id = dst.id
row.position = position
position += 1
if rows:
recompute_dirty(db, src)
touch(src)
if moved:
recompute_dirty(db, dst)
touch(dst)
db.flush()
return moved, already_there
def undo_move(
db: Session,
src: Playlist,
dst: Playlist,
moved: list[str],
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)
wanted = set(moved)
rows = [it for it in queries.items(db, dst.id) if it.video_id in wanted]
for row in rows:
db.delete(row)
db.flush()
back = _insert_at(db, src, list(zip(positions, [*moved, *already_there], strict=True)))
if rows:
recompute_dirty(db, dst)
touch(dst)
if back:
recompute_dirty(db, src)
touch(src)
db.flush()
return back
def reorder_items(db: Session, pl: Playlist, order: list[str]) -> int:
"""Number the listed videos 0..n in the listed order. Items the list left out (added in another
tab meanwhile) keep their relative order after them, so no two items share a position. The
+119 -1
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
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Session
from app import quota
@@ -459,6 +459,124 @@ def move_item(
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
class VideoIdsIn(BaseModel):
video_ids: list[str] = []
def _paired(video_count: int, positions: list[int]) -> None:
"""One place per video, none before the start: both undo bodies come back from the client."""
if len(positions) != video_count:
raise ValueError("positions must pair one-to-one with the videos")
if any(at < 0 for at in positions):
raise ValueError("positions cannot be negative")
class RestoreItemsIn(VideoIdsIn):
# Where each video goes back: an index over the ordered rows, one per video.
positions: list[int] = []
@model_validator(mode="after")
def _one_position_per_video(self):
_paired(len(self.video_ids), self.positions)
return self
class MoveItemsIn(VideoIdsIn):
target_playlist_id: int
class UndoMoveIn(BaseModel):
from_playlist_id: int
moved: list[str] = []
already_there: list[str] = []
# One per video of `moved` + `already_there`, in that order.
positions: list[int] = []
@model_validator(mode="after")
def _one_position_per_video(self):
_paired(len(self.moved) + len(self.already_there), self.positions)
return self
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."""
other = queries.own_playlist(db, user, playlist_id)
if other is None:
raise not_found("move_target")
return other
@router.post("/{playlist_id}/items/remove-bulk")
def remove_items(
playlist_id: int,
body: VideoIdsIn,
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)
removed = service.remove_items(db, pl, body.video_ids)
db.commit()
return {"ok": True, "playlist_id": pl.id, "removed": removed}
@router.post("/{playlist_id}/items/restore")
def restore_items(
playlist_id: int,
body: RestoreItemsIn,
user: User = Depends(current_user),
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)
added = service.restore_items(db, pl, body.video_ids, body.positions)
db.commit()
return {"ok": True, "playlist_id": pl.id, "added": added}
@router.post("/{playlist_id}/items/move-bulk")
def move_items(
playlist_id: int,
body: MoveItemsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Move many videos into another of the caller's playlists, in one transaction, for the reason
`move_item` gives. Says which landed there and which it already held, for the "Undo"."""
src = _own_playlist(db, user, playlist_id)
dst = _other_list(db, user, body.target_playlist_id)
try:
moved, already_there = service.move_items(db, src, dst, body.video_ids)
except service.MoveRefused as e:
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": e.reason}
) from None
db.commit()
return {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"moved": moved,
"already_there": already_there,
}
@router.post("/{playlist_id}/items/undo-move")
def undo_move(
playlist_id: int,
body: UndoMoveIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""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)
db.commit()
return {"ok": True, "playlist_id": src.id, "added": added}
class ReorderItemsIn(BaseModel):
video_ids: list[str] = []