diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 9ed6cad2..a914e30b 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -5,9 +5,14 @@ Playback is from the local physical file, so this only mirrors METADATA + the ph (no ffprobe needed) to classify each item's browser playability. Movies + episodes become playable LEAF rows (plex_items); shows/seasons are mirrored for the drill-down + episode ordering. -The sync is a full reconcile of the enabled sections (upsert by rating_key). Pruning of items that -disappeared from Plex is intentionally left for later (a follow-up can diff rating_keys per library). -Cast + intro/credit markers are fetched lazily on the item-detail/player path (P2), not here. +The sync is a full reconcile of the enabled sections (upsert by rating_key) AND prunes rows for +titles that disappeared from Plex: each section diffs the rating_keys it just listed against the ones +stored, and deletes the stale ones (their watch-state / playlist links cascade). The prune is guarded +on a NON-empty listing — a section that returns zero items is left untouched, so a transient Plex +glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental +mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the +dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the +item-detail/player path (P2), not here. """ import logging from datetime import datetime, timezone @@ -150,7 +155,7 @@ def sync(db: Session) -> dict: return {"skipped": "disabled"} stats = { "libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, - "collections": 0, "overlaid": 0, + "collections": 0, "overlaid": 0, "pruned": 0, } wanted = _enabled_section_keys(db) overlay_map = _overlay_map(db) # {huKey: enKey} @@ -351,6 +356,21 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: row.playable = classify_playable(mf.get("codec_video"), mf.get("codec_audio"), mf.get("container")) +def _prune_stale(db: Session, existing: dict, seen: set[str], stats: dict) -> None: + """Delete stored rows whose rating_key is no longer in the freshly-listed `seen` set — the titles + that disappeared from Plex (their watch-state / playlist links cascade via the FKs). GUARDED: an + empty `seen` (a section that listed zero items — a real empty library, or a transient Plex glitch + we can't tell apart) prunes NOTHING, so a hiccup can never wipe a whole library. Callers pass the + rating_key -> row map they already built for the upsert.""" + if not seen: + return + for rk, row in existing.items(): + if rk not in seen: + db.delete(row) + stats["pruned"] += 1 + db.flush() + + def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: existing = { r.rating_key: r @@ -358,6 +378,7 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) - select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") ).scalars() } + seen: set[str] = set() for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE): rk = str(meta.get("ratingKey") or "") if not rk: @@ -366,8 +387,10 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) - if row.id is None: db.add(row) _apply_item(row, lib.id, "movie", meta) + seen.add(rk) stats["movies"] += 1 db.flush() + _prune_stale(db, existing, seen, stats) def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: @@ -378,10 +401,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> select(PlexShow).where(PlexShow.library_id == lib.id) ).scalars() } + shows_seen: set[str] = set() for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW): rk = str(meta.get("ratingKey") or "") if not rk: continue + shows_seen.add(rk) sh = shows.get(rk) or PlexShow(rating_key=rk) if sh.id is None: db.add(sh) @@ -407,11 +432,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id) ).scalars() } + seasons_seen: set[str] = set() for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON): rk = str(meta.get("ratingKey") or "") sid = show_id.get(str(meta.get("parentRatingKey") or "")) if not rk or sid is None: continue + seasons_seen.add(rk) se = seasons.get(rk) or PlexSeason(rating_key=rk) if se.id is None: db.add(se) @@ -431,10 +458,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode") ).scalars() } + episodes_seen: set[str] = set() for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE): rk = str(meta.get("ratingKey") or "") if not rk: continue + episodes_seen.add(rk) row = episodes.get(rk) or PlexItem(rating_key=rk) if row.id is None: db.add(row) @@ -446,6 +475,13 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> stats["episodes"] += 1 db.flush() + # Prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly, before its + # parent's cascade would reach it — avoids deleting the same ORM object twice. Each level is + # independently guarded on a non-empty listing by _prune_stale. + _prune_stale(db, episodes, episodes_seen, stats) + _prune_stale(db, seasons, seasons_seen, stats) + _prune_stale(db, shows, shows_seen, stats) + def _sync_overlay( db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict diff --git a/backend/tests/test_plex_sync_prune.py b/backend/tests/test_plex_sync_prune.py new file mode 100644 index 00000000..d80f37eb --- /dev/null +++ b/backend/tests/test_plex_sync_prune.py @@ -0,0 +1,131 @@ +"""E3 S1 catalog-prune (B-sub-6): a sync now deletes rows for titles that disappeared from Plex, +diffing each section's freshly-listed rating_keys against the stored ones. The prune is GUARDED on a +non-empty listing so a transient Plex glitch (an empty page) can never wipe a whole library. + +DB lane (`db` fixture) — skipped without Postgres. Drives `_sync_movies` / `_sync_shows` directly +with a stub Plex (the same seam the overlay test uses), so no live server is needed. +""" +from sqlalchemy import select + +from app.models import ( + PlexItem, + PlexLibrary, + PlexSeason, + PlexShow, + PlexState, + User, +) +from app.plex.sync import ( + _TYPE_EPISODE, + _TYPE_MOVIE, + _TYPE_SEASON, + _TYPE_SHOW, + _sync_movies, + _sync_shows, +) + + +class _StubPlex: + """Returns canned section listings per Plex item type — the current 'truth' the sync reconciles + the stored rows against. `_paginate` is the only method the sync calls on it.""" + + def __init__(self, by_type: dict[int, list[dict]]): + self.by_type = by_type + + def section_items(self, key, item_type, start, size): + items = self.by_type.get(item_type, []) + return items[start : start + size], len(items) + + +def _seed_movies(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + m1 = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Kept Movie") + m2 = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Gone Movie") + db.add_all([m1, m2]) + user = User(email="prune@example.com") + db.add(user) + db.flush() + # A watch-state on EACH movie: the kept one's must survive, the pruned one's must cascade away. + db.add_all([ + PlexState(user_id=user.id, item_id=m1.id, status="watched"), + PlexState(user_id=user.id, item_id=m2.id, status="watched"), + ]) + db.commit() + return lib, m1.id, m2.id, user.id + + +def test_movie_prune_deletes_disappeared_titles_and_cascades_state(db): + lib, m1_id, _m2_id, _user_id = _seed_movies(db) + # Plex now lists only m1 — m2 disappeared. + stub = _StubPlex({_TYPE_MOVIE: [{"ratingKey": "m1", "title": "Kept Movie"}]}) + stats = {"movies": 0, "pruned": 0} + + _sync_movies(db, stub, lib, stats) + db.commit() + + live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()} + assert live == {"m1"}, "the disappeared movie must be pruned" + assert stats["pruned"] == 1 + # The kept movie's watch-state survives; the pruned movie's cascaded away. + states = {s.item_id for s in db.execute(select(PlexState)).scalars()} + assert states == {m1_id} + + +def test_empty_listing_prunes_nothing(db): + """The safety guard: a section that lists ZERO items (a real empty library, or a transient Plex + glitch we can't distinguish) must leave the stored rows untouched — never mass-delete.""" + lib, _m1_id, _m2_id, _user_id = _seed_movies(db) + stub = _StubPlex({_TYPE_MOVIE: []}) # Plex returned nothing this run + stats = {"movies": 0, "pruned": 0} + + _sync_movies(db, stub, lib, stats) + db.commit() + + live = {r.rating_key for r in db.execute(select(PlexItem)).scalars()} + assert live == {"m1", "m2"}, "an empty listing must not prune anything" + assert stats["pruned"] == 0 + + +def test_show_prune_removes_stale_show_season_and_episode(db): + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + # Stored: one kept show (s1 -> se1 -> e1) and one stale show (s2 -> se2 -> e2) that Plex dropped. + s1 = PlexShow(rating_key="s1", library_id=lib.id, title="Kept Show") + s2 = PlexShow(rating_key="s2", library_id=lib.id, title="Gone Show") + db.add_all([s1, s2]) + db.flush() + se1 = PlexSeason(rating_key="se1", show_id=s1.id, season_number=1) + se2 = PlexSeason(rating_key="se2", show_id=s2.id, season_number=1) + db.add_all([se1, se2]) + db.flush() + e1 = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Kept Ep", + show_id=s1.id, season_id=se1.id, season_number=1, episode_number=1) + e2 = PlexItem(rating_key="e2", library_id=lib.id, kind="episode", title="Gone Ep", + show_id=s2.id, season_id=se2.id, season_number=1, episode_number=1) + db.add_all([e1, e2]) + db.commit() + + # Plex now lists only s1's subtree. + stub = _StubPlex({ + _TYPE_SHOW: [{"ratingKey": "s1", "title": "Kept Show"}], + _TYPE_SEASON: [{"ratingKey": "se1", "parentRatingKey": "s1", "index": 1, "title": "Season 1"}], + _TYPE_EPISODE: [{ + "ratingKey": "e1", "grandparentRatingKey": "s1", "parentRatingKey": "se1", + "parentIndex": 1, "index": 1, "title": "Kept Ep", + }], + }) + stats = {"shows": 0, "seasons": 0, "episodes": 0, "pruned": 0} + + _sync_shows(db, stub, lib, stats) + db.commit() + + shows = {r.rating_key for r in db.execute(select(PlexShow)).scalars()} + seasons = {r.rating_key for r in db.execute(select(PlexSeason)).scalars()} + eps = {r.rating_key for r in db.execute(select(PlexItem)).scalars()} + assert shows == {"s1"}, "the stale show must be pruned" + assert seasons == {"se1"}, "the stale season must be pruned" + assert eps == {"e1"}, "the stale episode must be pruned" + assert stats["pruned"] == 3 # s2 + se2 + e2