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:
@@ -559,7 +559,9 @@ def undo_move(
|
||||
`dst`, which held them before. A moved video `dst` no longer holds has gone on from there, and
|
||||
bringing it back would leave it in lists the reader never put it in, so it stays out, as a
|
||||
move skips a video a concurrent edit took out. `positions` pairs with `moved` + `already_there`.
|
||||
Says how many came back."""
|
||||
Says how many came back. Raises `MoveRefused` for the same list twice, as a move does."""
|
||||
if src.id == dst.id:
|
||||
raise MoveRefused("same_playlist")
|
||||
_lock_both(db, src, dst)
|
||||
wanted = set(moved)
|
||||
rows = {it.video_id: it for it in queries.items(db, dst.id) if it.video_id in wanted}
|
||||
|
||||
@@ -420,11 +420,14 @@ class VideoIdsIn(BaseModel):
|
||||
|
||||
|
||||
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."""
|
||||
"""One place per video, none before the start and none twice: both undo bodies come back from
|
||||
the client, and the insert's arithmetic counts on places that only rise."""
|
||||
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")
|
||||
if len(set(positions)) != len(positions):
|
||||
raise ValueError("positions cannot repeat")
|
||||
|
||||
|
||||
class RestoreItemsIn(VideoIdsIn):
|
||||
@@ -443,10 +446,11 @@ class MoveItemsIn(VideoIdsIn):
|
||||
|
||||
class UndoMoveIn(BaseModel):
|
||||
from_playlist_id: int
|
||||
moved: list[str] = []
|
||||
# Required, as `video_ids` is. An empty one is still a real body: the target held them all.
|
||||
moved: list[str]
|
||||
already_there: list[str] = []
|
||||
# One per video of `moved` + `already_there`, in that order.
|
||||
positions: list[int] = []
|
||||
positions: list[int]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_position_per_video(self):
|
||||
@@ -459,6 +463,10 @@ def _list_gone(e: service.ListGone) -> HTTPException:
|
||||
return not_found("playlist") if e.source else not_found("move_target")
|
||||
|
||||
|
||||
def _move_refused(e: service.MoveRefused) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail={"code": "playlist_move", "reason": e.reason})
|
||||
|
||||
|
||||
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."""
|
||||
@@ -515,9 +523,7 @@ def move_items(
|
||||
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
|
||||
raise _move_refused(e) from None
|
||||
except service.ListGone as e:
|
||||
raise _list_gone(e) from None
|
||||
db.commit()
|
||||
@@ -542,6 +548,8 @@ def undo_move(
|
||||
dst = _other_list(db, user, body.from_playlist_id)
|
||||
try:
|
||||
added = service.undo_move(db, src, dst, body.moved, body.already_there, body.positions)
|
||||
except service.MoveRefused as e:
|
||||
raise _move_refused(e) from None
|
||||
except service.ListGone as e:
|
||||
raise _list_gone(e) from None
|
||||
db.commit()
|
||||
|
||||
@@ -183,7 +183,9 @@ def playlist_remove_item(
|
||||
|
||||
|
||||
class PlexItemKeysIn(BaseModel):
|
||||
item_rating_keys: list[str] = []
|
||||
# Required: with a default, a misnamed key answered 200 having acted on no item, and a reorder
|
||||
# took it for an empty order.
|
||||
item_rating_keys: list[str]
|
||||
|
||||
|
||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
|
||||
@@ -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=())
|
||||
|
||||
Reference in New Issue
Block a user