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:
@@ -72,13 +72,17 @@ def _rk_to_id(db: Session) -> dict[str, int]:
|
||||
|
||||
def _scan_plex_states(
|
||||
db: Session, plex: PlexClient
|
||||
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]:
|
||||
) -> 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, plus the scanned-leaf count.
|
||||
Shared by the one-time import and the full reconcile (both need Plex's whole current picture)."""
|
||||
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
|
||||
Plex hiccup does not mean (see `_paginate`)."""
|
||||
item_id_by_rk = _rk_to_id(db)
|
||||
wanted = _enabled_section_keys(db)
|
||||
scanned = 0
|
||||
complete = True
|
||||
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
|
||||
for s in plex.sections():
|
||||
stype = s.get("type")
|
||||
@@ -88,7 +92,8 @@ def _scan_plex_states(
|
||||
if wanted is not None and key not in wanted:
|
||||
continue
|
||||
item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE
|
||||
for meta in _paginate(plex, key, item_type):
|
||||
progress: dict = {}
|
||||
for meta in _paginate(plex, key, item_type, progress):
|
||||
scanned += 1
|
||||
item_id = item_id_by_rk.get(str(meta.get("ratingKey") or ""))
|
||||
if item_id is None:
|
||||
@@ -98,7 +103,8 @@ def _scan_plex_states(
|
||||
)
|
||||
if res is not None:
|
||||
out[item_id] = res
|
||||
return out, scanned
|
||||
complete = complete and bool(progress.get("complete"))
|
||||
return out, scanned, complete
|
||||
|
||||
|
||||
def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
|
||||
@@ -111,7 +117,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
|
||||
stats = {"watched": 0, "in_progress": 0, "scanned": 0}
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
scanned_states, stats["scanned"] = _scan_plex_states(db, plex)
|
||||
# A partial scan is still safe here: the import only ever ADDS what Plex reports.
|
||||
scanned_states, stats["scanned"], _complete = _scan_plex_states(db, plex)
|
||||
# One row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert is
|
||||
# idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice
|
||||
# while the multi-second import runs) can't raise a duplicate-key error — it re-updates.
|
||||
@@ -590,6 +597,20 @@ def run_plex_watch_sync(db: Session) -> dict:
|
||||
return total
|
||||
|
||||
|
||||
def _clear_unwatched(db: Session, user_id: int, plex_states: dict) -> int:
|
||||
"""Clear the user's rows Plex no longer has a record for — the mirrored ones (Plex un-watched
|
||||
them); hidden and dirty rows stay (a dirty one is re-pushed by `_repush_dirty`). Called only
|
||||
after a COMPLETE scan: a walk a Plex hiccup cut short leaves out rows it merely failed to reach,
|
||||
and clearing those un-watched them here until the next complete run (E32 S2)."""
|
||||
n = 0
|
||||
for st in db.execute(select(PlexState).where(PlexState.user_id == user_id)).scalars().all():
|
||||
if st.item_id in plex_states or st.status == "hidden" or not st.synced_to_plex:
|
||||
continue
|
||||
db.delete(st)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def run_plex_watch_reconcile(db: Session) -> dict:
|
||||
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
|
||||
per item, uses `synced_to_plex` to settle what the incremental feed can't:
|
||||
@@ -608,7 +629,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
_resolve_account_id(db, plex, link)
|
||||
plex_states, scanned = _scan_plex_states(db, plex)
|
||||
plex_states, scanned, complete = _scan_plex_states(db, plex)
|
||||
total["scanned"] += scanned
|
||||
|
||||
for item_id, (status, pos, watched_at, prog_at) in plex_states.items():
|
||||
@@ -616,18 +637,14 @@ def run_plex_watch_reconcile(db: Session) -> dict:
|
||||
if ch:
|
||||
total["applied"] += 1
|
||||
|
||||
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored
|
||||
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below).
|
||||
for st in (
|
||||
db.execute(select(PlexState).where(PlexState.user_id == link.user_id))
|
||||
.scalars()
|
||||
.all()
|
||||
):
|
||||
if st.item_id in plex_states or st.status == "hidden":
|
||||
continue
|
||||
if st.synced_to_plex:
|
||||
db.delete(st)
|
||||
total["cleared"] += 1
|
||||
if complete:
|
||||
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",
|
||||
extra=logs.event("plex.watch.reconcile_incomplete", user_id=link.user_id),
|
||||
)
|
||||
|
||||
total["repushed"] += _repush_dirty(db, plex, link)
|
||||
link.last_watch_sync_at = datetime.now(timezone.utc)
|
||||
|
||||
Reference in New Issue
Block a user