feat(plex): prune orphaned people after each sync (S4)

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)
This commit is contained in:
2026-07-19 03:50:00 +02:00
parent 05105197e9
commit 33c60965d9
+27
View File
@@ -12,6 +12,7 @@ Cast + intro/credit markers are fetched lazily on the item-detail/player path (P
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@@ -135,10 +136,36 @@ def sync(db: Session) -> dict:
db.rollback() db.rollback()
log.warning("Plex sync failed: %s", e) log.warning("Plex sync failed: %s", e)
return {"error": str(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) log.info("Plex sync done: %s", stats)
return 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: def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
lib = db.query(PlexLibrary).filter_by(plex_key=key).first() lib = db.query(PlexLibrary).filter_by(plex_key=key).first()
if lib is None: if lib is None: