feat(plex): Phase C — incremental + full Plex↔Siftlode watch reconcile

Completes the two-way watch-state sync with two scheduler jobs:

- plex_watch_sync (default 30m): pull recent Plex-side changes (watch history + on-deck, filtered
  to the owner account) into Siftlode under last-write-wins (_pull_apply + a _same_state ping-pong
  guard + skew tolerance), then re-push any still-unsynced local states.
- plex_watch_reconcile (default daily): full section rescan; uses synced_to_plex to settle what the
  incremental feed can't — notably propagating a Plex-side un-watch (clear a previously-mirrored row
  Plex no longer has) — while re-pushing never-synced local states and never touching hidden
  (Siftlode-only) rows. Union-preserving.

PlexClient gains accounts/watch_history/on_deck; _scan_plex_states is factored out and shared with
the one-time import. Owner accountID is resolved once and cached on the link. Both jobs are
registered in the scheduler (pause-skip, activity tracking, run-now, admin-tunable intervals) with
trilingual (HU/EN/DE) labels + descriptions. New config default plex_watch_reconcile_interval_min.

Verified live against the real Plex server: read feeds, last-write-wins, dirty re-push, the
incremental job end-to-end, and a full reconcile that cleared exactly the one un-watched item with
zero collateral across 17976 scanned.
This commit is contained in:
npeter83 2026-07-10 00:46:22 +02:00
parent bdf35c3375
commit bbbcf4ff5a
7 changed files with 332 additions and 43 deletions

View file

@ -198,6 +198,41 @@ class PlexClient:
f"&state={state}&time={int(time_ms)}&duration={int(duration_ms)}",
)
# --- Watch-state read for incremental sync (P5 Phase C) ---
def accounts(self) -> list[dict]:
"""Server accounts (the owner + any managed/home users): id, name. Used to resolve the
owner's accountID so the shared watch-history feed can be filtered to just their views."""
return self._get("/accounts").get("Account", []) or []
def watch_history(
self, min_viewed_at: int = 0, account_id: int | None = None, size: int = 500
) -> list[dict]:
"""The most recent watch-history rows (ratingKey / viewedAt / accountID / type), newest
first works without Plex Pass. Filtered client-side to `account_id` and to rows at/after
`min_viewed_at` (epoch seconds): the efficient "what changed since T" feed. Sorted desc, so
we stop at the first row older than the cutoff."""
mc = self._get(
"/status/sessions/history/all",
params={
"sort": "viewedAt:desc",
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": size,
},
)
out = []
for r in mc.get("Metadata", []) or []:
if int(r.get("viewedAt") or 0) < min_viewed_at:
break
if account_id is not None and int(r.get("accountID") or -1) != account_id:
continue
out.append(r)
return out
def on_deck(self) -> list[dict]:
"""The token account's Continue-Watching list (ratingKey / viewOffset / lastViewedAt /
type) the incremental source for resume-position changes made on the Plex side."""
return self._get("/library/onDeck").get("Metadata", []) or []
def raw_get(self, path: str) -> bytes:
"""Raw bytes of an arbitrary Plex resource path (e.g. an external subtitle stream `key` like
``/library/streams/553184``). Keeps the admin token server-side."""