fix(sync): an empty answer no longer empties a mirror; maintenance checks each video once a run
The catalog prune and the subscription import have long refused to reconcile against an empty listing that contradicts what we hold (should_prune: a 200 with no items is a blip far more often than a user who removed everything). Two mirrors did not: the YouTube playlist mirrors (dropped every mirror) and the Plex collections (dropped every collection with its user-set `editable` flag, and every member's keys — the guard landed in plex/sync.py with the previous commit). Both now hold the same rule; test_empty_listing_guards covers each way (seen red). sync/maintenance (19% covered) gets a lifecycle test: recover, grace, delete + one notification per user, live stream kept, abandoned premiere. It found the rolling revalidation re-fetching the same videos until `checked` reached the batch (15000 by default) whenever fewer videos were available — quota spent on answers it already had; a run now skips what it has checked.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""An empty answer is not an emptied library (E32 S2).
|
||||
|
||||
Every mirror here reconciles: what the remote no longer lists is removed locally. The catalog
|
||||
(`_prune_stale`) and the subscriptions (`should_prune`) had long refused to do that on an empty
|
||||
listing that contradicts what we hold — a 200 with no items is a blip far more often than a user
|
||||
who removed everything. Two mirrors did not: the Plex collections (a row carries the user-set
|
||||
`editable` flag, and every member its keys) and the YouTube playlist mirrors. These hold both.
|
||||
"""
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Playlist, PlexCollection, PlexItem, PlexLibrary
|
||||
from app.plex import sync as plex_sync
|
||||
from app.sync import playlists as plsync
|
||||
|
||||
|
||||
class _Plex:
|
||||
def __init__(self, collections: list[dict], children: dict[str, list[dict]]):
|
||||
self._collections = collections
|
||||
self._children = children
|
||||
|
||||
def collections(self, section_key):
|
||||
return self._collections
|
||||
|
||||
def collection_children(self, rk):
|
||||
return self._children.get(rk, [])
|
||||
|
||||
|
||||
def _library_with_a_collection(db):
|
||||
lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="Bond", editable=True))
|
||||
movie = PlexItem(
|
||||
rating_key="m1", library_id=lib.id, kind="movie", title="Dr. No", collection_keys=["c1"]
|
||||
)
|
||||
db.add(movie)
|
||||
db.commit()
|
||||
return lib, movie
|
||||
|
||||
|
||||
def test_an_empty_collection_listing_keeps_the_collections_and_their_members(db):
|
||||
lib, movie = _library_with_a_collection(db)
|
||||
stats = {"collections": 0}
|
||||
plex_sync._sync_collections(db, _Plex([], {}), lib, stats)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
col = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "c1")).scalar_one()
|
||||
assert col.editable is True
|
||||
assert db.get(PlexItem, movie.id).collection_keys == ["c1"]
|
||||
|
||||
|
||||
def test_a_listing_with_something_in_it_still_prunes_what_it_no_longer_has(db):
|
||||
lib, movie = _library_with_a_collection(db)
|
||||
stats = {"collections": 0}
|
||||
other = {"ratingKey": "c2", "title": "Alien"}
|
||||
plex_sync._sync_collections(db, _Plex([other], {"c2": []}), lib, stats)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
keys = set(db.execute(select(PlexCollection.rating_key)).scalars())
|
||||
assert keys == {"c2"}
|
||||
assert db.get(PlexItem, movie.id).collection_keys is None
|
||||
|
||||
|
||||
class _YT:
|
||||
def __init__(self, playlists: list[dict]):
|
||||
self._playlists = playlists
|
||||
|
||||
def __call__(self, *_):
|
||||
return self
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def iter_my_playlists(self):
|
||||
return self._playlists
|
||||
|
||||
def iter_playlist_items_with_ids(self, _ytid):
|
||||
return []
|
||||
|
||||
def iter_my_playlist_video_ids(self, _ytid):
|
||||
return []
|
||||
|
||||
|
||||
def test_an_empty_playlist_answer_keeps_the_youtube_mirrors(db, make_user, monkeypatch):
|
||||
user = make_user()
|
||||
db.add(Playlist(user_id=user.id, name="Mixes", source="youtube", yt_playlist_id="PL1"))
|
||||
db.commit()
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", _YT([]))
|
||||
plsync.pull_user_playlists(db, user)
|
||||
db.expire_all()
|
||||
names = db.execute(select(Playlist.name).where(Playlist.user_id == user.id)).scalars().all()
|
||||
assert "Mixes" in names
|
||||
|
||||
|
||||
def test_a_playlist_answer_with_something_in_it_still_drops_a_vanished_mirror(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
db.add(Playlist(user_id=user.id, name="Mixes", source="youtube", yt_playlist_id="PL1"))
|
||||
db.commit()
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", _YT([{"id": "PL2", "title": "New"}]))
|
||||
plsync.pull_user_playlists(db, user)
|
||||
db.expire_all()
|
||||
ytids = set(
|
||||
db.execute(select(Playlist.yt_playlist_id).where(Playlist.source == "youtube")).scalars()
|
||||
)
|
||||
assert "PL1" not in ytids
|
||||
Reference in New Issue
Block a user