From 33c60965d9297625f8b21fded8957fbfa9a069c0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 03:50:00 +0200 Subject: [PATCH] feat(plex): prune orphaned people after each sync (S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plex_people headshot cache can outlive its subjects — when a title leaves the Plex library, cast/crew who appear in nothing else are orphaned. prune_orphan_people deletes rows whose name no longer appears in ANY movie/show cast_names/directors, run at the end of every plex_sync (best-effort; a jsonb_typeof='array' guard skips scalar/null JSONB values). The filterable cast_names self-clean with their row; this cleans the only per-person cache. Verified: an injected orphan is dropped, real people kept. (Plex cast-explore S4) --- backend/app/plex/sync.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index cd4201f..d565584 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -12,6 +12,7 @@ Cast + intro/credit markers are fetched lazily on the item-detail/player path (P import logging from datetime import datetime, timezone +from sqlalchemy import text from sqlalchemy.orm import Session from app import sysconfig @@ -135,10 +136,36 @@ def sync(db: Session) -> dict: db.rollback() log.warning("Plex sync failed: %s", e) return {"error": str(e)} + # Prune the headshot cache: a reconcile may have removed titles, orphaning people who no longer + # appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata. + try: + stats["people_pruned"] = prune_orphan_people(db) + except Exception as e: # best-effort housekeeping — never fail the sync over it + log.warning("Plex people prune failed: %s", e) log.info("Plex sync done: %s", stats) return stats +# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside +# this set are orphans left by a library removal. +_REFERENCED_PEOPLE_SQL = """ + SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array' + UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array' + UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array' + UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array' +""" + + +def prune_orphan_people(db: Session) -> int: + """Delete plex_people (headshot cache) rows whose name no longer appears in any movie/show cast or + directors — orphaned by a library removal. Returns the number pruned. Commits its own work.""" + res = db.execute( + text(f"DELETE FROM plex_people WHERE name NOT IN ({_REFERENCED_PEOPLE_SQL})") + ) + db.commit() + return res.rowcount or 0 + + def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary: lib = db.query(PlexLibrary).filter_by(plex_key=key).first() if lib is None: