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:
parent
bdf35c3375
commit
bbbcf4ff5a
7 changed files with 332 additions and 43 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue