fix(plex): the watch reconcile clears only what it walked, and doubts a mirrored 0

A library left out of the allowlist is never scanned, so its watch states were cleared on
every reconcile; clearing is now limited to the sections walked. A section we mirror items
from that says it holds 0 is not believed complete. The page size is no longer read as the
section total when totalSize is missing (measured: the server always sends totalSize on a
paged request, 0 for an empty one).
This commit is contained in:
2026-10-04 04:16:52 +02:00
parent 79704b0001
commit 12a39f5291
4 changed files with 85 additions and 14 deletions
+31 -12
View File
@@ -29,7 +29,7 @@ from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.db import SessionLocal
from app.models import PlexItem, PlexLink, PlexState
from app.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.sync import (
_TYPE_EPISODE,
@@ -72,17 +72,25 @@ 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, bool]:
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int, bool, set[str]]:
"""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 (or the server said it holds none).
prog_at)}`` for every mirrored leaf Plex holds a watch signal for, the scanned-leaf count,
whether EVERY section's walk reached the server's total (or the server said it holds none), and
the keys of the sections walked — the only ones whose absences mean anything.
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)
# The sections we mirror something from: a total of 0 from one of those contradicts the mirror.
mirrored = set(
db.execute(
select(PlexLibrary.plex_key).join(PlexItem, PlexItem.library_id == PlexLibrary.id)
).scalars()
)
scanned = 0
complete = True
walked: set[str] = set()
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
for s in plex.sections():
stype = s.get("type")
@@ -92,6 +100,7 @@ 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
walked.add(key)
progress: dict = {}
before = scanned
for meta in _paginate(plex, key, item_type, progress):
@@ -106,9 +115,11 @@ def _scan_plex_states(
out[item_id] = res
# 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
# Believed only for a section we mirror nothing from: one whose items we hold answering 0 is
# a Plex that lost the library for a moment, not one that un-watched all of it (E32 review).
said_empty = progress.get("total") == 0 and scanned == before and key not in mirrored
complete = complete and (bool(progress.get("complete")) or said_empty)
return out, scanned, complete
return out, scanned, complete, walked
def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
@@ -122,7 +133,7 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
try:
with PlexClient(db) as 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)
scanned_states, stats["scanned"], _complete, _walked = _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.
@@ -601,13 +612,21 @@ def run_plex_watch_sync(db: Session) -> dict:
return total
def _clear_unwatched(db: Session, user_id: int, plex_states: dict) -> int:
def _clear_unwatched(db: Session, user_id: int, plex_states: dict, walked: set[str]) -> 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)."""
and clearing those un-watched them here until the next complete run (E32 S2). Only rows of the
sections the scan WALKED: an item of a library the allowlist leaves out is absent from every
scan, and was cleared on every run (E32 review)."""
n = 0
for st in db.execute(select(PlexState).where(PlexState.user_id == user_id)).scalars().all():
rows = db.execute(
select(PlexState)
.join(PlexItem, PlexItem.id == PlexState.item_id)
.join(PlexLibrary, PlexLibrary.id == PlexItem.library_id)
.where(PlexState.user_id == user_id, PlexLibrary.plex_key.in_(walked))
)
for st in rows.scalars().all():
if st.item_id in plex_states or st.status == "hidden" or not st.synced_to_plex:
continue
db.delete(st)
@@ -633,7 +652,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
try:
with PlexClient(db) as plex:
_resolve_account_id(db, plex, link)
plex_states, scanned, complete = _scan_plex_states(db, plex)
plex_states, scanned, complete, walked = _scan_plex_states(db, plex)
total["scanned"] += scanned
for item_id, (status, pos, watched_at, prog_at) in plex_states.items():
@@ -644,7 +663,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
# 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)
total["cleared"] += _clear_unwatched(db, link.user_id, plex_states, walked)
else:
total["incomplete"] = total.get("incomplete", 0) + 1
log.warning(