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
+7 -4
View File
@@ -51,7 +51,8 @@ def _ids(db, pl_id):
def _move(client, src, video_id, dst):
return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
f"/api/playlists/{src}/items/move-bulk",
json={"video_ids": [video_id], "target_playlist_id": dst},
)
@@ -67,7 +68,8 @@ def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"video_id": "v1",
"moved": ["v1"],
"already_there": [],
}
db.expire_all()
assert _ids(db, src.id) == ["v2"]
@@ -116,8 +118,9 @@ def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user)
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
assert r.status_code == 200
assert (r.json()["moved"], r.json()["already_there"]) == ([], [])
assert _ids(db, dst.id) == []
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
+6 -3
View File
@@ -107,7 +107,8 @@ PLEX = PlexPlaylist.__table__.name
),
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
],
@@ -173,7 +174,8 @@ PLEX_ITEMS = PlexPlaylistItem.__table__.name
),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
# the ORM cascade deletes the items before the list row
@@ -199,7 +201,8 @@ def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user
pid, other = _yt(db, user)
statements.clear()
r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
f"/api/playlists/{pid}/items/move-bulk",
json={"video_ids": ["v1"], "target_playlist_id": other},
)
assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS)
+5 -6
View File
@@ -235,14 +235,13 @@ def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
service.add_item(db, dst, "b", mark_dirty=False)
db.commit()
with pytest.raises(service.MoveRefused, match="same_playlist"):
service.move_item(db, src, src, "a")
service.move_item(db, src, dst, "a")
service.move_item(db, src, dst, "b") # already in the target: dropped from the source
service.move_items(db, src, src, ["a"])
assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
# already in the target: dropped from the source
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
db.commit()
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
with pytest.raises(service.MoveRefused, match="not_in_playlist"):
service.move_item(db, src, dst, "a")
db.rollback()
assert service.move_items(db, src, dst, ["a"]) == ([], [])
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):