feat(plex): Collections Phase 2 — admin collection editing (write-back to Plex)

Admins can curate Plex collections from a movie's info page ("Collections"
button → PlexCollectionEditor dialog): create a collection (seeded with the
movie), add/remove the movie to/from editable collections, delete a collection,
and "take over" an existing plain Plex collection (mark it editable). All writes
go to Plex (POST/PUT/DELETE via new PlexClient methods) and are reflected on
every client; a targeted single-collection re-sync (sync.resync_collection /
delete_collection_local) updates the local mirror without the full ~4-min sync.

Gating (per the design decisions): editing is ADMIN-ONLY (collections are shared
library-wide); only plain manual collections are editable — smart + external
auto-lists (IMDb/TMDb/…) are always read-only (can_edit = editable && !smart &&
source=="collection"). New admin endpoints under /api/plex/collections
(create/items add+remove/rename/delete/editable). Verified end-to-end incl. the
live Plex write API (create/add/rename/remove/delete all 200, self-cleaned) and
the editor UI (create + delete with confirm) on localdev.
This commit is contained in:
npeter83 2026-07-06 17:33:16 +02:00
parent 4b053a55da
commit 8291f06525
12 changed files with 602 additions and 13 deletions

View file

@ -106,6 +106,59 @@ class PlexClient:
mc = self._get(f"/library/collections/{rating_key}/children")
return mc.get("Metadata", []) or []
# --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) ---
@property
def machine_id(self) -> str:
"""Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached."""
if not getattr(self, "_machine_id", None):
self._machine_id = self._get("/identity").get("machineIdentifier") or ""
return self._machine_id
def _write(self, method: str, path: str) -> dict:
try:
r = self._http.request(method, f"{self.base}{path}")
r.raise_for_status()
except httpx.HTTPError as e:
raise PlexError(str(e)) from e
try:
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
except ValueError:
return {}
def _metadata_uri(self, *rating_keys: str) -> str:
keys = ",".join(str(k) for k in rating_keys)
return f"server://{self.machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
def create_collection(self, section_key: str, title: str, item_rating_key: str) -> dict:
"""Create a (dumb) collection seeded with one item; returns its Metadata (incl. ratingKey)."""
uri = quote(self._metadata_uri(item_rating_key), safe="")
mc = self._write(
"POST",
f"/library/collections?type=1&smart=0&sectionId={section_key}"
f"&title={quote(title, safe='')}&uri={uri}",
)
items = mc.get("Metadata", []) or []
if not items:
raise PlexError("Plex did not return the created collection")
return items[0]
def add_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
uri = quote(self._metadata_uri(item_rating_key), safe="")
self._write("PUT", f"/library/collections/{collection_rating_key}/items?uri={uri}")
def remove_collection_item(self, collection_rating_key: str, item_rating_key: str) -> None:
self._write("DELETE", f"/library/collections/{collection_rating_key}/children/{item_rating_key}")
def rename_collection(self, section_key: str, collection_rating_key: str, title: str) -> None:
self._write(
"PUT",
f"/library/sections/{section_key}/all?type=18&id={collection_rating_key}"
f"&title.value={quote(title, safe='')}&title.locked=1",
)
def delete_collection(self, collection_rating_key: str) -> None:
self._write("DELETE", f"/library/collections/{collection_rating_key}")
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
"""Full metadata for one item; include intro/credit markers when requested."""
params = {"includeMarkers": 1} if markers else None

View file

@ -271,6 +271,46 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
db.flush()
def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) -> PlexCollection:
"""Targeted re-sync of ONE collection after a write-back (create/add/remove/rename) — avoids the
full ~4-min library sync. Upserts the row (preserving `editable`) and reconciles ONLY this
collection's membership on the affected member rows."""
meta = plex.metadata(rk) or {}
col = db.query(PlexCollection).filter_by(rating_key=rk).first() or PlexCollection(rating_key=rk)
if col.id is None:
db.add(col)
col.library_id = lib.id
col.title = (meta.get("title") or "").strip() or "Untitled"
col.summary = meta.get("summary")
col.thumb_key = meta.get("thumb")
col.child_count = meta.get("childCount")
col.smart = str(meta.get("smart")) == "1"
col.synced_at = datetime.now(timezone.utc)
Model = PlexItem if lib.kind == "movie" else PlexShow
new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")}
current = db.query(Model).filter(Model.collection_keys.contains([rk])).all()
for row in current: # drop rk from rows that are no longer members
if row.rating_key not in new_members:
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
to_add = new_members - {r.rating_key for r in current}
if to_add:
for row in db.query(Model).filter(Model.rating_key.in_(to_add)):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk]))
db.flush()
return col
def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
"""Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row."""
Model = PlexItem if lib.kind == "movie" else PlexShow
for row in db.query(Model).filter(Model.collection_keys.contains([rk])):
row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None
col = db.query(PlexCollection).filter_by(rating_key=rk).first()
if col is not None:
db.delete(col)
db.flush()
def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
"""Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:

View file

@ -429,6 +429,12 @@ def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
return None
def _collection_editable(c: PlexCollection) -> bool:
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
smart, never an external auto-list (IMDb/TMDb/), which are managed outside Plex."""
return bool(c.editable) and not c.smart and _collection_source(c) == "collection"
def _collection_card(c: PlexCollection) -> dict:
return {
"id": c.rating_key,
@ -437,7 +443,9 @@ def _collection_card(c: PlexCollection) -> dict:
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
"child_count": c.child_count,
"smart": c.smart,
"source": _collection_source(c),
"editable": c.editable,
"can_edit": _collection_editable(c), # whether the edit controls should be offered (admin UI)
}
@ -462,6 +470,127 @@ def collections(
return {"collections": [_collection_card(c) for c in rows]}
# --- Collection editing (P2, admin-only — writes back to Plex; changes are shared across all clients).
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first()
if col is None:
raise HTTPException(status_code=404, detail="Unknown collection")
if not _collection_editable(col):
raise HTTPException(status_code=403, detail="This collection is read-only")
return col
@router.post("/collections")
def create_collection(payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Create a manual collection (seeded with one item — Plex needs at least one). Editable by default."""
library = str(payload.get("library") or "")
title = (payload.get("title") or "").strip()
seed = str(payload.get("item_rating_key") or "")
if not title or not seed:
raise HTTPException(status_code=422, detail="title and item_rating_key are required")
lib = db.query(PlexLibrary).filter_by(plex_key=library).first()
if lib is None:
raise HTTPException(status_code=404, detail="Unknown library")
try:
with PlexClient(db) as plex:
new = plex.create_collection(lib.plex_key, title, seed)
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
col.editable = True
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
return _collection_card(col)
@router.post("/collections/{rating_key}/items/{item_rating_key}")
def collection_add_item(
rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
try:
with PlexClient(db) as plex:
plex.add_collection_item(rating_key, str(item_rating_key))
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
return _collection_card(col)
@router.delete("/collections/{rating_key}/items/{item_rating_key}")
def collection_remove_item(
rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
try:
with PlexClient(db) as plex:
plex.remove_collection_item(rating_key, str(item_rating_key))
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
return _collection_card(col)
@router.patch("/collections/{rating_key}")
def collection_rename(
rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
title = (payload.get("title") or "").strip()
if not title:
raise HTTPException(status_code=422, detail="title is required")
lib = db.get(PlexLibrary, col.library_id)
try:
with PlexClient(db) as plex:
plex.rename_collection(lib.plex_key, rating_key, title)
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
return _collection_card(col)
@router.delete("/collections/{rating_key}")
def collection_delete(
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
try:
with PlexClient(db) as plex:
plex.delete_collection(rating_key)
plex_sync.delete_collection_local(db, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
raise HTTPException(status_code=502, detail=f"Plex write failed: {e}")
return {"deleted": rating_key}
@router.post("/collections/{rating_key}/editable")
def collection_set_editable(
rating_key: str, payload: dict, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict:
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections
(IMDb/TMDb/) can never be made editable they're regenerated outside Plex."""
col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first()
if col is None:
raise HTTPException(status_code=404, detail="Unknown collection")
want = bool(payload.get("editable"))
if want and (col.smart or _collection_source(col) != "collection"):
raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable")
col.editable = want
db.commit()
return _collection_card(col)
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,