fix(plex): a listing cut short no longer clears watch state or Hungarian text

The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the
row — but it walked the sections without asking _paginate whether the walk reached the server's
total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach
until the next complete run. The scan now reports completeness; an incomplete one still pulls in
what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete).

The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now
reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py
(plex/sync.py drops under the 800-line limit and off the size ratchet's list).

Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's
clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
This commit is contained in:
2026-10-03 19:33:30 +02:00
parent c73761ae39
commit 5806adda7e
7 changed files with 499 additions and 141 deletions
+52 -11
View File
@@ -16,18 +16,21 @@ from app import sysconfig
from app.db import engine
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex.genres import hu_genre, hu_genres
from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay
from app.plex.overlay import sync_overlay
from app.plex.sync import _apply_item, _canonical_guid
from app.plex.query import content_libs
class _StubPlex:
"""Minimal PlexClient stand-in: _sync_overlay only calls section_items via _paginate."""
"""Minimal PlexClient stand-in: sync_overlay only calls section_items via _paginate. `total`
over the item count is a listing cut short (the server said more than it handed over)."""
def __init__(self, items: list[dict]):
def __init__(self, items: list[dict], total: int | None = None):
self._items = items
self._total = len(items) if total is None else total
def section_items(self, key, item_type, start, size):
return self._items[start : start + size], len(self._items)
return self._items[start : start + size], self._total
def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict:
@@ -71,7 +74,7 @@ def test_sync_overlay_writes_hu_onto_matched_en_rows(db):
]
)
stats = {"overlaid": 0}
_sync_overlay(db, stub, hu_lib, en_lib.id, stats)
sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(matched)
@@ -185,13 +188,51 @@ def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db):
)
db.add(row)
db.commit()
# The HU section no longer lists this film (empty overlay).
_sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {"overlaid": 0})
# The HU section, read in full, no longer lists this film (only another one).
other = _hu_movie("h9", "Más", "más", [], "tmdb://99")
sync_overlay(db, _StubPlex([other]), hu_lib, en_lib.id, {"overlaid": 0})
db.commit()
db.refresh(row)
assert row.title_hu is None and row.summary_hu is None and row.genres_hu is None
def test_a_listing_cut_short_keeps_the_hungarian_text_it_did_not_reach(db):
"""E32 S2: the reconcile clears HU text only after a COMPLETE listing. A walk a Plex hiccup cut
short (an empty page before the server's total), or an empty section, says nothing about the
titles it did not reach — clearing them dropped their Hungarian text until the next full sync."""
en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie")
hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu")
db.add_all([en_lib, hu_lib])
db.flush()
hu_lib.overlay_of = en_lib.id
kept = PlexItem(
rating_key="m1",
library_id=en_lib.id,
kind="movie",
title="Heat",
guid="tmdb:1",
title_hu="Szemtől szemben",
summary_hu="régi",
genres_hu=["Akció"],
)
fresh = PlexItem(rating_key="m2", library_id=en_lib.id, kind="movie", title="Up", guid="tmdb:2")
db.add_all([kept, fresh])
db.commit()
stats: dict = {}
reached = _hu_movie("h2", "Fel", "fel", [], "tmdb://2")
sync_overlay(db, _StubPlex([reached], total=2), hu_lib, en_lib.id, stats)
db.commit()
db.refresh(kept)
db.refresh(fresh)
assert kept.title_hu == "Szemtől szemben" and kept.summary_hu == "régi"
assert fresh.title_hu == "Fel" # what the listing did reach is still applied
assert stats["overlay_incomplete"] == 1
sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {}) # an empty section: the same answer
db.commit()
db.refresh(kept)
assert kept.title_hu == "Szemtől szemben"
def test_apply_item_keeps_prior_guid_when_listing_lacks_guid():
row = PlexItem(rating_key="m1", kind="movie", guid="tmdb:1")
_apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat"}) # no Guid array
@@ -225,7 +266,7 @@ def test_overlay_applies_to_all_editions_sharing_a_guid(db):
]
)
stats = {"overlaid": 0}
_sync_overlay(db, stub, hu_lib, en_lib.id, stats)
sync_overlay(db, stub, hu_lib, en_lib.id, stats)
db.commit()
db.refresh(ed1)
db.refresh(ed2)
@@ -291,7 +332,7 @@ def test_a_show_overlay_writes_hungarian_episode_titles_and_summaries(db):
],
}
)
_sync_overlay(db, stub, hu_lib, en_lib.id, {})
sync_overlay(db, stub, hu_lib, en_lib.id, {})
db.commit()
db.refresh(ep)
db.refresh(show)
@@ -328,7 +369,7 @@ def test_the_overlay_gives_every_row_one_hungarian_word_per_genre(db):
"h1", "Szemtől szemben", "HU.", ["Action", "Bűnügyi", "Drama", "Dráma"], "tmdb://1"
)
meta["tagline"] = "Magyar mottó."
_sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {})
sync_overlay(db, _StubPlex([meta]), hu_lib, en_lib.id, {})
db.commit()
db.refresh(twin)
db.refresh(lone)
@@ -353,7 +394,7 @@ def test_a_twin_without_genres_keeps_the_rows_own_hungarian_list(db):
)
db.add(row)
db.commit()
_sync_overlay(
sync_overlay(
db,
_StubPlex([_hu_movie("h1", "Szemtől szemben", "HU.", [], "tmdb://1")]),
hu_lib,