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:
2026-10-04 02:13:53 +02:00
parent 5d10a521a8
commit df4a79a1f1
15 changed files with 381 additions and 127 deletions
+12 -6
View File
@@ -75,9 +75,9 @@ def _scan_plex_states(
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int, bool]:
"""Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at,
prog_at)}`` for every mirrored leaf Plex holds a watch signal for, the scanned-leaf count, and
whether EVERY section's walk reached the server's total. Shared by the one-time import and the
full reconcile (both need Plex's whole current picture). The last one matters to the reconcile
alone: it reads "absent from the scan" as "Plex un-watched it", which a walk cut short by a
whether EVERY section's walk reached the server's total (or the server said it holds none).
Shared by the one-time import and the full reconcile (both need Plex's whole current picture).
The last one matters to the reconcile alone: it reads "absent from the scan" as "Plex un-watched it", which a walk cut short by a
Plex hiccup does not mean (see `_paginate`)."""
item_id_by_rk = _rk_to_id(db)
wanted = _enabled_section_keys(db)
@@ -93,6 +93,7 @@ def _scan_plex_states(
continue
item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE
progress: dict = {}
before = scanned
for meta in _paginate(plex, key, item_type, progress):
scanned += 1
item_id = item_id_by_rk.get(str(meta.get("ratingKey") or ""))
@@ -103,7 +104,10 @@ def _scan_plex_states(
)
if res is not None:
out[item_id] = res
complete = complete and bool(progress.get("complete"))
# A section the server SAYS is empty (total 0, nothing listed) is complete too: without this
# one genuinely empty library kept every run "incomplete" and un-watches never synced again.
said_empty = progress.get("total") == 0 and scanned == before
complete = complete and (bool(progress.get("complete")) or said_empty)
return out, scanned, complete
@@ -637,12 +641,14 @@ def run_plex_watch_reconcile(db: Session) -> dict:
if ch:
total["applied"] += 1
if complete:
# A whole scan that listed nothing at all is not "Plex un-watched everything" either:
# every section can say 0 only when the server lost its libraries, never as news.
if complete and scanned:
total["cleared"] += _clear_unwatched(db, link.user_id, plex_states)
else:
total["incomplete"] = total.get("incomplete", 0) + 1
log.warning(
"the Plex scan ended before the server's total; nothing is cleared this run",
"the Plex scan was cut short or listed nothing; nothing is cleared this run",
extra=logs.event("plex.watch.reconcile_incomplete", user_id=link.user_id),
)