fix(playlists): overlapping writes, and what the second review found

A read from before a write no longer brings a row back: a write calls off the
read on its way, what lands while one is out stays off the screen, and the last
write out does the refresh. A failure puts back its own rows, an accepted reorder
brings back none, and an Undo reads past the cache. A list the cache has dropped
is read before a removal, so its Undo has places.

undo-move refuses the same list twice, positions cannot repeat, and undo-move
and the Plex key bodies refuse a body without their videos. Focus goes to the
header's box when the last shown row or Save this order goes.
This commit is contained in:
2026-11-02 22:15:13 +01:00
parent 195b5f3917
commit b636ef38bc
12 changed files with 578 additions and 71 deletions
+55
View File
@@ -437,6 +437,7 @@ def test_a_one_list_write_finds_its_list_under_the_lock(
[
("items/remove-bulk", {"ids": ["a"]}),
("items/move-bulk", {"ids": ["a"], "target_playlist_id": 1}),
("items/undo-move", {"ids": ["a"], "from_playlist_id": 1}),
],
)
def test_a_body_without_its_videos_is_refused_rather_than_acting_on_none(
@@ -448,6 +449,60 @@ def test_a_body_without_its_videos_is_refused_rather_than_acting_on_none(
assert as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body).status_code == 422
def test_an_undo_of_only_videos_the_target_held_names_no_moved_ones(db, make_user, as_user):
"""An empty `moved` is a real body: every video of the move was already in the target."""
user = make_user()
_seed_videos(db, ["a"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", ["a"])
r = as_user(user).post(
f"/api/playlists/{src.id}/items/undo-move",
json={"from_playlist_id": dst.id, "moved": [], "already_there": ["a"], "positions": [0]},
)
assert r.json()["added"] == 1
assert _ids(db, src.id) == ["a"]
assert _ids(db, dst.id) == ["a"]
def test_an_undo_within_one_list_is_refused_and_keeps_its_videos(db, make_user, as_user):
"""A move refuses the same list twice, so no Undo of one exists; a row must not go for asking."""
user = make_user()
_seed_videos(db, ["a", "b"])
pl = _playlist(db, user, "L", ["a", "b"])
r = as_user(user).post(
f"/api/playlists/{pl.id}/items/undo-move",
json={"from_playlist_id": pl.id, "moved": ["a"], "positions": [0]},
)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
assert _ids(db, pl.id) == ["a", "b"]
@pytest.mark.parametrize(
("path", "key", "extra"),
[
("items/restore", "video_ids", {}),
("items/undo-move", "moved", {"from_playlist_id": None}),
],
)
def test_two_videos_cannot_ask_for_one_place(db, make_user, as_user, path, key, extra):
user = make_user()
_seed_videos(db, ["a", "b", "c"])
pl = _playlist(db, user, "L", ["c"])
other = _playlist(db, user, "Other", ["a", "b"])
body = {key: ["a", "b"], "positions": [0, 0]}
body.update({k: other.id for k in extra})
r = as_user(user).post(f"/api/playlists/{pl.id}/{path}", json=body)
assert r.status_code == 422
assert _ids(db, pl.id) == ["c"]
def test_a_reorder_without_its_videos_is_refused(db, make_user, as_user):
user = make_user()
pl = _playlist(db, user, "L", [])
@@ -6,6 +6,8 @@ rows, since positions have gaps after a removal. One request: an append plus a s
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
"""
import pytest
from app.plex.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
@@ -101,6 +103,22 @@ def test_positions_must_pair_with_the_keys(db, make_user, as_user):
assert _order(db, pl.id)[0] == ["a"]
@pytest.mark.parametrize(
("method", "path"),
[("post", "items/bulk"), ("post", "items/remove-bulk"), ("put", "order")],
)
def test_a_body_without_its_keys_is_refused_rather_than_acting_on_none(
db, make_user, as_user, method, path
):
user = make_user()
pl = _seed(db, user, ["a"], extra=())
r = getattr(as_user(user), method)(f"/api/plex/playlists/{pl.id}/{path}", json={"keys": ["a"]})
assert r.status_code == 422
assert _order(db, pl.id)[0] == ["a"]
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())