feat(plex): playlist bulk add/remove + group-aware playlist cards

Add POST /playlists/{id}/items/bulk and /items/remove-bulk so a whole
season or show can be added/removed in one call (input order preserved,
duplicates skipped). Extend GET /playlists with contains_group=<rk,rk,…>
returning per-playlist group_in + a top-level group_size for the bulk
add-to-playlist dialog. Include the show's rating_key (show_id) on
playlist episode cards so the client can group a playlist by show/season.
This commit is contained in:
npeter83 2026-07-06 22:14:40 +02:00
parent 22f36d9d7f
commit e6b22f971a

View file

@ -186,11 +186,15 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
} }
def _leaf_card(it: PlexItem, st: PlexState | None, show_title: str | None = None) -> dict: def _leaf_card(
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.""" it: PlexItem, st: PlexState | None, show_title: str | None = None, show_rk: str | None = None
) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season."""
if it.kind == "episode": if it.kind == "episode":
card = _episode_card(it, st) card = _episode_card(it, st)
card["show_title"] = show_title card["show_title"] = show_title
card["show_id"] = show_rk
return card return card
return _movie_card(it, st) return _movie_card(it, st)
@ -639,15 +643,23 @@ def _playlist_item_query(db: Session, pid: int):
@router.get("/playlists") @router.get("/playlists")
def playlists( def playlists(
contains: str | None = None, contains: str | None = None,
contains_group: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The current user's playlists (newest-touched first), each with an item count + a cover thumb. """The current user's playlists (newest-touched first), each with an item count + a cover thumb.
`contains`=an item rating_key adds `has_item` per playlist (for the 'add to playlist' dialog).""" `contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog).
`contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the
group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
contains_id = None contains_id = None
if contains: if contains:
row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first()
contains_id = row[0] if row else None contains_id = row[0] if row else None
group_ids: set[int] | None = None
if contains_group is not None:
rks = [s for s in (contains_group or "").split(",") if s]
rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all()
group_ids = {r[0] for r in rows}
out = [] out = []
for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()): for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()):
count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0
@ -658,8 +670,18 @@ def playlists(
db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first()
is not None is not None
) )
if group_ids is not None:
card["group_in"] = (
db.query(func.count(PlexPlaylistItem.id))
.filter(PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(group_ids or [0]))
.scalar()
or 0
)
out.append(card) out.append(card)
return {"playlists": out} resp: dict = {"playlists": out}
if group_ids is not None:
resp["group_size"] = len(group_ids)
return resp
@router.post("/playlists") @router.post("/playlists")
@ -694,10 +716,18 @@ def playlist_detail(pid: int, user: User = Depends(current_user), db: Session =
) )
} }
shows = { shows = {
sh.id: sh.title sh.id: sh
for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])) for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]))
} }
cards = [_leaf_card(it, states.get(it.id), shows.get(it.show_id)) for it in items] cards = [
_leaf_card(
it,
states.get(it.id),
shows[it.show_id].title if it.show_id in shows else None,
shows[it.show_id].rating_key if it.show_id in shows else None,
)
for it in items
]
return {"id": pl.id, "title": pl.title, "items": cards} return {"id": pl.id, "title": pl.title, "items": cards}
@ -748,6 +778,65 @@ def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(cu
return {"ok": True} return {"ok": True}
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
order = [str(k) for k in rks if str(k)]
if not order:
return []
by_rk = {it.rating_key: it for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order))}
seen: set[str] = set()
out: list[PlexItem] = []
for rk in order:
it = by_rk.get(rk)
if it is not None and rk not in seen:
seen.add(rk)
out.append(it)
return out
@router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk(pid: int, payload: dict, 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."""
pl = _own_playlist_or_404(db, pid, user)
items = _items_by_rks(db, payload.get("item_rating_keys") or [])
existing = {row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid)}
pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar())
added = 0
for it in items:
if it.id in existing:
continue
pos += 1
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos))
existing.add(it.id)
added += 1
if added:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0
return {"added": added, "item_count": total}
@router.post("/playlists/{pid}/items/remove-bulk")
def playlist_remove_bulk(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = _own_playlist_or_404(db, pid, user)
ids = [it.id for it in _items_by_rks(db, payload.get("item_rating_keys") or [])]
removed = 0
if ids:
removed = (
db.query(PlexPlaylistItem)
.filter(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids))
.delete(synchronize_session=False)
)
if removed:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0
return {"removed": removed, "item_count": total}
@router.put("/playlists/{pid}/order") @router.put("/playlists/{pid}/order")
def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions).""" """Set the playlist order from an explicit list of item rating_keys (0-based positions)."""