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

@ -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: