feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)
- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset, hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…" beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset. - Plex playlist page: a title, a show group and a season each open the page's one row menu (a title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo toast whose Undo puts every row back at its own index in one request (bulk add `positions`); j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…". - notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -285,16 +287,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
||||
return out
|
||||
|
||||
|
||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
|
||||
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
|
||||
# at the end whenever the second call fails, and the order it sends would overwrite anything
|
||||
# another tab changed in between.
|
||||
positions: list[Annotated[int, Field(ge=0)]] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_position_per_key(self):
|
||||
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
|
||||
raise ValueError("positions must pair one-to-one with item_rating_keys")
|
||||
return self
|
||||
|
||||
|
||||
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
||||
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||
already in the list does not move: the add stays idempotent."""
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
present = {r.item_id for r in rows}
|
||||
added = 0
|
||||
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||
if it.id in present:
|
||||
continue
|
||||
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
||||
db.add(row)
|
||||
rows.insert(min(at, len(rows)), row)
|
||||
present.add(it.id)
|
||||
added += 1
|
||||
for i, r in enumerate(rows):
|
||||
r.position = i
|
||||
return added
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/bulk")
|
||||
def playlist_add_bulk(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
body: PlexItemAddBulkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
||||
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
||||
instead of appended. Returns how many were added + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
if body.positions is not None:
|
||||
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
||||
placed = [
|
||||
(at, by_rk[str(rk)])
|
||||
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
||||
if str(rk) in by_rk
|
||||
]
|
||||
added = _insert_at(db, pid, placed)
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo".
|
||||
|
||||
A removal can take one row, a season or a whole show, and a season's episodes need not sit together
|
||||
(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED
|
||||
rows, since positions have gaps after a removal. One request: an append plus a second reorder call
|
||||
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
|
||||
"""
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||
|
||||
|
||||
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
|
||||
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
items = {}
|
||||
for rk in [*rks, *extra]:
|
||||
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper())
|
||||
db.add(it)
|
||||
items[rk] = it
|
||||
pl = PlexPlaylist(user_id=user.id, title="L")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, rk in enumerate(rks):
|
||||
pos = (positions or range(99))[i]
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _order(db, pid):
|
||||
db.expire_all()
|
||||
rows = db.execute(
|
||||
PlexPlaylistItem.__table__.select()
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).all()
|
||||
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
|
||||
return [by_id[r.item_id] for r in rows], [r.position for r in rows]
|
||||
|
||||
|
||||
def _add(client, pid, keys, positions=None):
|
||||
body = {"item_rating_keys": keys}
|
||||
if positions is not None:
|
||||
body["positions"] = positions
|
||||
return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body)
|
||||
|
||||
|
||||
def test_one_row_goes_back_to_its_index(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
r = _add(as_user(user), pl.id, ["x"], [1])
|
||||
assert r.status_code == 200
|
||||
assert r.json()["added"] == 1
|
||||
assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3])
|
||||
|
||||
|
||||
def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user):
|
||||
# A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of
|
||||
# a, x, b, y, c. Given in any order, each lands where it stood.
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
_add(as_user(user), pl.id, ["y", "x"], [3, 1])
|
||||
assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"]
|
||||
|
||||
|
||||
def test_indices_count_rows_not_stored_numbers(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20])
|
||||
_add(as_user(user), pl.id, ["x"], [2])
|
||||
assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3])
|
||||
|
||||
|
||||
def test_an_index_past_the_end_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a"])
|
||||
_add(as_user(user), pl.id, ["x"], [50])
|
||||
assert _order(db, pl.id)[0] == ["a", "x"]
|
||||
|
||||
|
||||
def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
r = _add(as_user(user), pl.id, ["c"], [0])
|
||||
assert r.json()["added"] == 0
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_without_positions_it_still_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b"])
|
||||
_add(as_user(user), pl.id, ["y", "x"])
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "y", "x"]
|
||||
|
||||
|
||||
def test_positions_must_pair_with_the_keys(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a"])
|
||||
assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422
|
||||
assert _add(as_user(user), pl.id, ["x"], [-1]).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=())
|
||||
client = as_user(user)
|
||||
client.post(
|
||||
f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
|
||||
)
|
||||
assert _order(db, pl.id)[0] == ["a", "c"]
|
||||
_add(client, pl.id, ["b", "d"], [1, 3])
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "c", "d"]
|
||||
Reference in New Issue
Block a user