fix(sync): an empty answer the remote's own count contradicts is not believed, one level down
E32 review round 1 (B1, B2, B3, B8). - Plex watch reconcile: a section the server SAYS is empty (totalSize 0, nothing listed) is a complete listing. It read as "cut short" forever, so one empty library stopped un-watches from ever syncing. A listing with no total at all stays incomplete (the client now returns None for it instead of 0), and a whole scan that listed nothing still clears nothing. Tests now cover completeness across two sections, both orders. - Plex collections: no members from a collection whose listing counts some (childCount) keeps the membership we hold, in the full sync and in the targeted resync after a write. - YouTube playlists: no items for a playlist whose listing counts some (itemCount) keeps the mirror in the bulk pull, and is refused as retryable in "reset to YouTube" and in the push plan/merge, where it read as "YouTube deleted them all". The rule sits beside vanished(). - A playlist deleted on YouTube answered "reconnect your account"; it now says it is gone (errors.youtube.gone, HU+EN). - The pure 3-way merge moved to sync/playlist_merge.py; playlists.py is back under 800 lines and off the size list.
This commit is contained in:
@@ -13,11 +13,13 @@ from app.plex import watch_sync
|
||||
|
||||
class _FakePlex:
|
||||
"""A PlexClient stand-in: one movie section whose listing reports `total` (more than it hands
|
||||
over = a walk cut short), and a record of every write the reconcile sends back to Plex."""
|
||||
over = a walk cut short), plus any `more` sections as `(type, leaves, total)` — listed AFTER
|
||||
the movies — and a record of every write the reconcile sends back to Plex."""
|
||||
|
||||
def __init__(self, leaves: list[dict], total: int | None = None):
|
||||
self.leaves = leaves
|
||||
self.total = len(leaves) if total is None else total
|
||||
def __init__(self, leaves: list[dict], total: int | None = None, more=()):
|
||||
self.listings = {"1": ("movie", leaves, len(leaves) if total is None else total)}
|
||||
for i, (stype, extra, extra_total) in enumerate(more, start=2):
|
||||
self.listings[str(i)] = (stype, extra, extra_total)
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
def __enter__(self):
|
||||
@@ -27,10 +29,11 @@ class _FakePlex:
|
||||
return False
|
||||
|
||||
def sections(self):
|
||||
return [{"type": "movie", "key": "1"}]
|
||||
return [{"type": stype, "key": key} for key, (stype, _l, _t) in self.listings.items()]
|
||||
|
||||
def section_items(self, key, item_type, start, size):
|
||||
return self.leaves[start : start + size], self.total
|
||||
_stype, leaves, total = self.listings[key]
|
||||
return leaves[start : start + size], total
|
||||
|
||||
def scrobble(self, rk):
|
||||
self.calls.append(("scrobble", rk))
|
||||
@@ -136,6 +139,45 @@ def test_a_scan_cut_short_clears_nothing(db, monkeypatch, world):
|
||||
assert out["cleared"] == 0 and out["incomplete"] == 1
|
||||
|
||||
|
||||
_LISTED = [
|
||||
{"ratingKey": "m3"},
|
||||
{"ratingKey": "m4", "viewCount": 1, "lastViewedAt": 1_700_000_000},
|
||||
{"ratingKey": "m5", "viewOffset": 120_000, "lastViewedAt": 1_700_000_000},
|
||||
]
|
||||
|
||||
|
||||
def test_a_genuinely_empty_second_section_still_lets_the_reconcile_clear(db, monkeypatch, world):
|
||||
# The server SAYS the shows section holds 0: that is a complete listing, not a hiccup — before
|
||||
# the E32 review it read as "incomplete" forever and un-watches never synced again.
|
||||
state, rows = world
|
||||
_local_states(state)
|
||||
db.commit()
|
||||
out = _run(db, monkeypatch, _FakePlex(_LISTED, more=[("show", [], 0)]))
|
||||
assert "m1" not in rows()
|
||||
assert out["cleared"] == 1 and "incomplete" not in out
|
||||
|
||||
|
||||
def test_a_cut_short_section_is_not_rescued_by_a_complete_one_after_it(db, monkeypatch, world):
|
||||
# Completeness is every section's, not the last one's: movies cut short (5 said, 3 handed),
|
||||
# then a complete shows listing — m1 may simply not have been reached.
|
||||
state, rows = world
|
||||
_local_states(state)
|
||||
db.commit()
|
||||
shows = [{"ratingKey": "s1"}]
|
||||
out = _run(db, monkeypatch, _FakePlex(_LISTED, total=5, more=[("show", shows, 1)]))
|
||||
assert rows()["m1"] == ("watched", 0, True)
|
||||
assert out["cleared"] == 0 and out["incomplete"] == 1
|
||||
|
||||
|
||||
def test_a_listing_without_a_total_is_never_complete(db, monkeypatch, world):
|
||||
state, rows = world
|
||||
_local_states(state)
|
||||
db.commit()
|
||||
out = _run(db, monkeypatch, _FakePlex(_LISTED, more=[("show", [], None)]))
|
||||
assert rows()["m1"] == ("watched", 0, True)
|
||||
assert out["cleared"] == 0 and out["incomplete"] == 1
|
||||
|
||||
|
||||
def test_an_empty_listing_is_not_a_library_of_unwatched_titles(db, monkeypatch, world):
|
||||
state, rows = world
|
||||
state("m1", status="watched", synced_to_plex=True)
|
||||
|
||||
Reference in New Issue
Block a user