Channel, Subscription, Video, SearchFind, ExploredChannel and BlockedChannel (and LIVE_OR_UPCOMING) leave app/models.py for app/catalog/models.py; app.tables registers them. The User↔Subscription relationship names its other side as a type only. No schema change.
254 lines
9.1 KiB
Python
254 lines
9.1 KiB
Python
"""An empty answer is not an emptied library.
|
|
|
|
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 — and,
|
|
one level down, a collection's members and a playlist's items: an empty answer that
|
|
contradicts the remote's own count is not believed either.
|
|
"""
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.models import PlexCollection, PlexItem, PlexLibrary
|
|
from app.catalog.models import Channel, Video
|
|
from app.reader.models import Playlist, PlaylistItem
|
|
from app.plex import sync as plex_sync
|
|
from app.sync import playlists as plsync
|
|
from app.youtube.client import YouTubeError
|
|
|
|
|
|
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 metadata(self, rk):
|
|
return next((c for c in self._collections if c.get("ratingKey") == 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
|
|
|
|
|
|
def test_no_members_from_a_collection_plex_counts_as_non_empty_keeps_its_members(db):
|
|
lib, movie = _library_with_a_collection(db)
|
|
stats = {"collections": 0}
|
|
bond = {"ratingKey": "c1", "title": "Bond", "childCount": 2}
|
|
plex_sync._sync_collections(db, _Plex([bond], {"c1": []}), lib, stats)
|
|
db.commit()
|
|
db.expire_all()
|
|
assert db.get(PlexItem, movie.id).collection_keys == ["c1"]
|
|
|
|
|
|
def test_a_collection_plex_counts_as_empty_does_lose_its_members(db):
|
|
lib, movie = _library_with_a_collection(db)
|
|
stats = {"collections": 0}
|
|
bond = {"ratingKey": "c1", "title": "Bond", "childCount": 0}
|
|
plex_sync._sync_collections(db, _Plex([bond], {"c1": []}), lib, stats)
|
|
db.commit()
|
|
db.expire_all()
|
|
assert db.get(PlexItem, movie.id).collection_keys is None
|
|
|
|
|
|
@pytest.mark.parametrize(("count", "kept"), [(2, ["c1"]), (0, None)])
|
|
def test_a_collection_resync_believes_an_empty_member_list_only_with_a_zero_count(db, count, kept):
|
|
lib, movie = _library_with_a_collection(db)
|
|
bond = {"ratingKey": "c1", "title": "Bond", "childCount": count}
|
|
plex_sync.resync_collection(db, _Plex([bond], {"c1": []}), lib, "c1")
|
|
db.commit()
|
|
db.expire_all()
|
|
assert db.get(PlexItem, movie.id).collection_keys == kept
|
|
|
|
|
|
class _YT:
|
|
def __init__(self, playlists: list[dict], snippet: dict | None = None):
|
|
self._playlists = playlists
|
|
self._snippet = snippet
|
|
|
|
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 get_playlist_snippet(self, _ytid):
|
|
return self._snippet
|
|
|
|
def get_videos(self, _ids):
|
|
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
|
|
|
|
|
|
class _YTOneGone(_YT):
|
|
"""A list deleted on YouTube between the listing and the read of its items."""
|
|
|
|
def iter_my_playlist_video_ids(self, ytid):
|
|
if ytid == "PLgone":
|
|
raise YouTubeError("m", reason="playlistNotFound", path="playlistItems")
|
|
return []
|
|
|
|
|
|
def test_a_list_deleted_mid_pull_is_skipped_and_the_rest_still_sync(db, make_user, monkeypatch):
|
|
# Not "this playlist no longer exists" for a bulk pull the user never named a list in, and not
|
|
# a failure for every other list either: the next pull drops the mirror.
|
|
user = make_user()
|
|
db.commit()
|
|
listing = [
|
|
{"id": "PLgone", "title": "Gone", "item_count": 3},
|
|
{"id": "PL2", "title": "Kept", "item_count": 0},
|
|
]
|
|
monkeypatch.setattr(plsync, "YouTubeClient", _YTOneGone(listing))
|
|
assert plsync.pull_user_playlists(db, user) == {"synced": 1}
|
|
db.expire_all()
|
|
ytids = set(
|
|
db.execute(select(Playlist.yt_playlist_id).where(Playlist.user_id == user.id)).scalars()
|
|
)
|
|
assert ytids == {"PL2"}
|
|
|
|
|
|
def _mirror_with_an_item(db, user, **kw):
|
|
db.add(Channel(id="UC_pl", title="C"))
|
|
db.flush()
|
|
db.add(Video(id="v1", channel_id="UC_pl", title="One"))
|
|
pl = Playlist(user_id=user.id, name="Mixes", yt_playlist_id="PL1", **kw)
|
|
db.add(pl)
|
|
db.flush()
|
|
db.add(PlaylistItem(playlist_id=pl.id, video_id="v1", position=0))
|
|
db.commit()
|
|
return pl
|
|
|
|
|
|
def _items(db, pl):
|
|
db.expire_all()
|
|
return (
|
|
db.execute(select(PlaylistItem.video_id).where(PlaylistItem.playlist_id == pl.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
def test_no_items_from_a_playlist_youtube_counts_as_non_empty_keeps_the_mirror_items(
|
|
db, make_user, monkeypatch
|
|
):
|
|
user = make_user()
|
|
pl = _mirror_with_an_item(db, user, source="youtube")
|
|
listing = [{"id": "PL1", "title": "Mixes", "item_count": 1}]
|
|
monkeypatch.setattr(plsync, "YouTubeClient", _YT(listing))
|
|
plsync.pull_user_playlists(db, user)
|
|
assert _items(db, pl) == ["v1"]
|
|
|
|
|
|
def test_a_playlist_youtube_counts_as_empty_is_emptied(db, make_user, monkeypatch):
|
|
user = make_user()
|
|
pl = _mirror_with_an_item(db, user, source="youtube")
|
|
listing = [{"id": "PL1", "title": "Mixes", "item_count": 0}]
|
|
monkeypatch.setattr(plsync, "YouTubeClient", _YT(listing))
|
|
plsync.pull_user_playlists(db, user)
|
|
assert _items(db, pl) == []
|
|
|
|
|
|
def test_a_reset_to_youtube_refuses_a_blank_listing_and_keeps_the_items(db, make_user, monkeypatch):
|
|
user = make_user()
|
|
pl = _mirror_with_an_item(db, user, source="youtube")
|
|
monkeypatch.setattr(plsync, "YouTubeClient", _YT([], {"title": "Mixes", "item_count": 1}))
|
|
with pytest.raises(YouTubeError) as err:
|
|
plsync.repull_playlist(db, user, pl)
|
|
db.rollback()
|
|
assert err.value.reason == "emptyAnswer"
|
|
assert _items(db, pl) == ["v1"]
|
|
|
|
|
|
def test_a_reset_of_a_deleted_playlist_says_it_is_gone(db, make_user, monkeypatch):
|
|
user = make_user()
|
|
pl = _mirror_with_an_item(db, user, source="youtube")
|
|
monkeypatch.setattr(plsync, "YouTubeClient", _YT([], None))
|
|
with pytest.raises(YouTubeError) as err:
|
|
plsync.repull_playlist(db, user, pl)
|
|
assert err.value.reason == "playlistNotFound"
|
|
|
|
|
|
def test_a_push_plan_refuses_a_blank_listing_rather_than_reading_it_as_all_deleted(db, make_user):
|
|
user = make_user()
|
|
pl = _mirror_with_an_item(db, user, source="local")
|
|
with pytest.raises(YouTubeError) as err:
|
|
plsync.plan_merge(db, user, pl, live=([], {"title": "Mixes", "item_count": 1}))
|
|
assert err.value.reason == "emptyAnswer"
|