feat(plex): catalog prune — delete titles that disappeared from Plex (E3 S1, B-sub-6)
A sync was upsert-only: a movie/show/season/episode removed from Plex stayed browsable forever and 404'd on playback. Each section now diffs the rating_keys it just listed against the stored ones and deletes the stale rows (watch-state / playlist links cascade via the existing FKs), mirroring the collections prune that already shipped. Safety: the prune is GUARDED on a non-empty listing (_prune_stale returns early when `seen` is empty), so a transient Plex glitch that yields an empty page can never mass-delete a library — the metadata is cheap to re-sync, an accidental wipe is the only real risk. Shows prune leaf-first (episodes -> seasons -> shows) so a stale row is deleted directly before its parent's cascade reaches it. Out of scope (separate follow-up): library-level reconcile of a whole removed section + retiring the dead `PlexLibrary.enabled` column. Tests: movie prune + watch-state cascade, the empty-listing guard (mutation- verified — removing the guard wipes everything), show/season/episode prune. 351 DB-lane pass, ruff clean.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user