From e6b22f971a8cbe3d4251371f2dbfc1b8f710a8ba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 6 Jul 2026 22:14:40 +0200 Subject: [PATCH] feat(plex): playlist bulk add/remove + group-aware playlist cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- backend/app/routes/plex.py | 101 ++++++++++++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 0fd000b..9da6260 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -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: - """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.""" +def _leaf_card( + 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": card = _episode_card(it, st) card["show_title"] = show_title + card["show_id"] = show_rk return card return _movie_card(it, st) @@ -639,15 +643,23 @@ def _playlist_item_query(db: Session, pid: int): @router.get("/playlists") def playlists( contains: str | None = None, + contains_group: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """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 if contains: row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() 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 = [] 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 @@ -658,8 +670,18 @@ def playlists( db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() 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) - return {"playlists": out} + resp: dict = {"playlists": out} + if group_ids is not None: + resp["group_size"] = len(group_ids) + return resp @router.post("/playlists") @@ -694,10 +716,18 @@ def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = ) } 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])) } - 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} @@ -748,6 +778,65 @@ def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(cu 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") 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)."""