PlexLibrary, PlexShow, PlexSeason, PlexItem, PlexPerson, PlexCollection, PlexState, PlexLink, PlexPlaylist and PlexPlaylistItem leave app/models.py for app/plex/models.py; app.tables registers them. Every import is re-pointed; no schema change.
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
"""Catalog prune: 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 User
|
|
from app.plex.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
|
|
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)
|
|
|
|
|
|
class _TruncatedPlex:
|
|
"""Simulates a truncated listing (a transient Plex hiccup): it reports a large `total` but returns
|
|
only one short page, so pagination stops BEFORE reaching total. Prune must treat this as an
|
|
incomplete fetch and delete nothing — the un-listed rows are present, just not mentioned."""
|
|
|
|
def __init__(self, items: list[dict], claimed_total: int):
|
|
self._items = items
|
|
self._total = claimed_total
|
|
|
|
def section_items(self, key, item_type, start, size):
|
|
if start == 0:
|
|
return list(self._items), self._total # one short page, total says there's more
|
|
return [], self._total
|
|
|
|
|
|
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_truncated_listing_prunes_nothing(db):
|
|
"""A partial fetch (Plex reports 5 items but the listing returns only 1 then stops) must NOT prune
|
|
the 4 it failed to mention — the completeness guard, not just the empty-listing guard."""
|
|
lib, m1_id, _m2_id, _user_id = _seed_movies(db)
|
|
# Only m1 is listed, but total claims 5 → the walk never reaches total → incomplete → no prune.
|
|
stub = _TruncatedPlex([{"ratingKey": "m1", "title": "Kept Movie"}], claimed_total=5)
|
|
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"}, "a truncated listing must not prune the un-listed rows"
|
|
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
|