"""Plex ↔ Siftlode watch-state sync. Plex records watch state PER PLEX ACCOUNT; Siftlode records it per Siftlode user in `plex_states`. A `PlexLink` row maps one Siftlode user to a Plex account. For the owner (MVP) that account is the server admin (`uses_admin=True`), so the existing admin token already reads/writes the owner's state — no separate Plex login. Phase A (this module today): the one-time **"Plex is master" import** — read the owner account's viewCount / viewOffset / lastViewedAt (present in the same cheap section listing the catalog mirror already pages) and upsert them into the owner's `plex_states`. Plex wins on overlap; states that exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection). Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch history + onDeck, last-write-wins) build on this and land in later ships. """ import logging from datetime import datetime, timedelta, timezone from sqlalchemy import func from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session from app import sysconfig from app.db import SessionLocal from app.models import PlexItem, PlexLink, PlexState from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.sync import ( _TYPE_EPISODE, _TYPE_MOVIE, _enabled_section_keys, _epoch, _paginate, ) log = logging.getLogger("siftlode.plex.watch") # A resume position below this many seconds isn't worth mirroring — matches the player's own # _PROGRESS_MIN_S (a stray few-second seek is not "in progress"). _PROGRESS_MIN_S = 5 def _plex_watch_to_state( view_count, view_offset_ms, last_viewed_at ) -> tuple[str, int, datetime | None, datetime | None] | None: """Translate a Plex leaf's per-account watch fields into a Siftlode state tuple ``(status, position_seconds, watched_at, progress_updated_at)``, or None when Plex holds no meaningful watch signal for it (so the import skips it and never clears a Siftlode-only state). In Siftlode's model (mirrors item_progress/item_state): a *watched* item is ``status="watched"`` with position 0; an *in-progress* item keeps the default status and carries ``position_seconds`` plus ``progress_updated_at``.""" ts = _epoch(last_viewed_at) if view_count and int(view_count) > 0: return ("watched", 0, ts, ts) if view_offset_ms: pos = int(view_offset_ms) // 1000 if pos >= _PROGRESS_MIN_S: return ("new", pos, None, ts) return None def _scan_plex_states( db: Session, plex: PlexClient ) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]: """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).""" item_id_by_rk: dict[str, int] = { rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id) } wanted = _enabled_section_keys(db) scanned = 0 out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {} for s in plex.sections(): stype = s.get("type") if stype not in ("movie", "show"): continue key = str(s.get("key")) 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): scanned += 1 item_id = item_id_by_rk.get(str(meta.get("ratingKey") or "")) if item_id is None: continue res = _plex_watch_to_state( meta.get("viewCount"), meta.get("viewOffset"), meta.get("lastViewedAt") ) if res is not None: out[item_id] = res return out, scanned def import_owner_watch_state(db: Session, link: PlexLink) -> dict: """One-time "Plex is master" import of the linked user's Plex watch state into their `plex_states`. Idempotent (safe to re-run — Plex simply re-wins on the intersection). Only touches items Plex has a watch record for; Siftlode-only states are preserved.""" if not sysconfig.get_bool(db, "plex_enabled"): return {"skipped": "disabled"} stats = {"watched": 0, "in_progress": 0, "scanned": 0} try: with PlexClient(db) as plex: scanned_states, stats["scanned"] = _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. rows = [] for item_id, (status, pos, watched_at, prog_at) in scanned_states.items(): rows.append( { "user_id": link.user_id, "item_id": item_id, "status": status, "position_seconds": pos, "watched_at": watched_at, "progress_updated_at": prog_at, # Mirrors Plex now — so Phase B's push doesn't bounce it straight back. "synced_to_plex": True, } ) stats["watched" if status == "watched" else "in_progress"] += 1 # Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not # scanned) are never referenced, so they're preserved untouched (union). for i in range(0, len(rows), 1000): stmt = pg_insert(PlexState).values(rows[i : i + 1000]) stmt = stmt.on_conflict_do_update( index_elements=[PlexState.user_id, PlexState.item_id], set_={ "status": stmt.excluded.status, "position_seconds": stmt.excluded.position_seconds, "watched_at": stmt.excluded.watched_at, "progress_updated_at": stmt.excluded.progress_updated_at, "synced_to_plex": stmt.excluded.synced_to_plex, "updated_at": func.now(), }, ) db.execute(stmt) link.initial_import_done = True link.last_watch_sync_at = datetime.now(timezone.utc) db.commit() except PlexNotConfigured as e: return {"skipped": str(e)} except PlexError as e: db.rollback() log.warning("Plex watch import failed (user %s): %s", link.user_id, e) return {"error": str(e)} log.info("Plex watch import (user %s): %s", link.user_id, stats) return stats # --- Phase B: Siftlode → Plex push ----------------------------------------------------------------- def link_for_push(db: Session, user_id: int) -> PlexLink | None: """The active owner watch-push link for this user, or None when push is off / not the owner. Push is gated on `initial_import_done` so we never push a half-known local state over Plex's authoritative one before the one-time "Plex is master" import has reconciled them.""" link = db.query(PlexLink).filter_by(user_id=user_id).first() if link and link.uses_admin and link.sync_enabled and link.initial_import_done: return link return None def push_state_to_plex( user_id: int, item_id: int, rating_key: str, action: str, time_ms: int = 0, duration_ms: int = 0, ) -> None: """Best-effort Siftlode→Plex push, run in a FastAPI BackgroundTask (its OWN DB session — the request's session is already closed by the time this runs). Never raises: Plex being down or slow must not break the user's action. On success it flags `synced_to_plex` so Phase C's incremental pull won't bounce the change straight back; on failure the row stays dirty (synced_to_plex=False) for a later retry/reconcile. `action` is one of ``watched`` (scrobble), ``unwatched`` (unscrobble), ``resume`` (timeline).""" with SessionLocal() as db: if link_for_push(db, user_id) is None: return try: with PlexClient(db) as plex: if action == "watched": plex.scrobble(rating_key) elif action == "unwatched": plex.unscrobble(rating_key) elif action == "resume": plex.set_timeline(rating_key, time_ms, duration_ms) else: return except PlexError as e: log.warning( "Plex watch push failed (user %s, item %s, %s): %s", user_id, item_id, action, e ) return # Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be # nothing to flag; that's fine (unscrobble still happened). st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() if st is not None: st.synced_to_plex = True db.commit() def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None: """Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background task for the entire batch: it reuses a single DB session and a single keep-alive PlexClient across all `changes` instead of scheduling N tasks that each rebuild a session + HTTP client. Plex has no batch-scrobble endpoint, so the per-item HTTP calls remain (looped over the shared connection), but the O(N) task/session/client setup is gone. `changes` is a list of (item_id, rating_key, action) with action ``watched`` (scrobble) or ``unwatched`` (unscrobble).""" if not changes: return with SessionLocal() as db: if link_for_push(db, user_id) is None: return synced_ids: list[int] = [] try: with PlexClient(db) as plex: for item_id, rating_key, action in changes: try: if action == "watched": plex.scrobble(rating_key) elif action == "unwatched": plex.unscrobble(rating_key) else: continue except PlexError as e: # One bad item mustn't abort the rest of the batch; leave its row dirty # (synced_to_plex=False) for a later reconcile. log.warning( "Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e ) continue synced_ids.append(item_id) except PlexError as e: # Couldn't even open the client — nothing pushed; everything stays dirty for reconcile. log.warning("Plex bulk push could not connect (user %s): %s", user_id, e) return # Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so # only the "watched" changes have a row to flag). if synced_ids: db.query(PlexState).filter( PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids) ).update({PlexState.synced_to_plex: True}, synchronize_session=False) db.commit() # --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------ # Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just # pushed (Plex stamps viewedAt a beat after our watched_at) must not read as "newer on Plex" and get # pulled back with a different shape. Genuine Plex-side edits are minutes from our writes — far # outside this — so the window only absorbs skew, never real changes. _LWW_TOLERANCE_S = 90 def _active_sync_links(db: Session) -> list[PlexLink]: """Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so P5b friend links slot in later without touching this loop.""" return ( db.query(PlexLink) .filter_by(uses_admin=True, sync_enabled=True, initial_import_done=True) .all() ) def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int: """The owner's Plex accountID, cached on the link. The server owner is accountID 1 on their own server; we confirm + grab their username from /accounts, falling back to 1 if unavailable.""" if link.plex_account_id: return link.plex_account_id acct_id, username = 1, None try: for a in plex.accounts(): if int(a.get("id") or -1) == 1: username = a.get("name") break except PlexError: pass link.plex_account_id = acct_id if username: link.plex_username = username db.commit() return acct_id def _sift_ts(st: PlexState) -> datetime | None: """The freshest Siftlode-side timestamp on a row, for last-write-wins.""" return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None) def _same_state(st: PlexState | None, status: str, pos: int) -> bool: """Whether a row already matches a target (status, pos) — the core ping-pong guard: when nothing would change we skip the write entirely (no row churn, no timestamp bump, no re-pull).""" if st is None: return False if status == "watched": return st.status == "watched" return st.status != "watched" and abs((st.position_seconds or 0) - pos) <= _PROGRESS_MIN_S def _pull_apply( db: Session, user_id: int, item_id: int, status: str, pos: int, plex_ts: datetime | None ) -> str | None: """Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns "watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the full reconcile's job) — this only pulls in watched/resume.""" st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() if _same_state(st, status, pos): if st is not None and not st.synced_to_plex: st.synced_to_plex = True # already matches Plex → just settle the flag, no churn return None # Conflict: keep Siftlode when it's newer than Plex (plus skew slack). sts = _sift_ts(st) if st else None if sts and plex_ts and plex_ts <= sts + timedelta(seconds=_LWW_TOLERANCE_S): return None if st is None: st = PlexState(user_id=user_id, item_id=item_id) db.add(st) if status == "watched": st.status, st.position_seconds, st.watched_at, st.progress_updated_at = "watched", 0, plex_ts, plex_ts else: st.status, st.position_seconds, st.progress_updated_at = "new", pos, plex_ts st.synced_to_plex = True return "watched" if status == "watched" else "in_progress" def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int: """Belt-and-suspenders: push local states that never reached Plex (synced_to_plex=False) — e.g. an immediate Phase B push that failed while Plex was down. `hidden` is excluded (Siftlode-only, never goes to Plex). Returns the count re-pushed.""" rk_by_id = dict(db.query(PlexItem.id, PlexItem.rating_key)) dirty = ( db.query(PlexState) .filter( PlexState.user_id == link.user_id, PlexState.synced_to_plex.is_(False), PlexState.status != "hidden", ) .all() ) n = 0 for st in dirty: rk = rk_by_id.get(st.item_id) if rk is None: continue try: if st.status == "watched": plex.scrobble(rk) elif st.position_seconds and st.position_seconds >= _PROGRESS_MIN_S: dur = int((plex.metadata(rk) or {}).get("duration") or 0) plex.set_timeline(rk, st.position_seconds * 1000, dur) else: plex.unscrobble(rk) st.synced_to_plex = True n += 1 except PlexError as e: log.warning( "Plex dirty re-push failed (user %s, item %s): %s", link.user_id, st.item_id, e ) return n def run_plex_watch_sync(db: Session) -> dict: """Scheduler job (default 30 min) — the cheap incremental pass. Pulls Plex-side changes since the high-water mark (watch history + on-deck, filtered to the owner account) into Siftlode under last-write-wins, then re-pushes any still-unsynced local states. Does NOT propagate un-watches (the history feed only carries positive views) — that's `run_plex_watch_reconcile`'s job.""" if not sysconfig.get_bool(db, "plex_enabled"): return {"skipped": "disabled"} total = {"links": 0, "watched": 0, "in_progress": 0, "repushed": 0} for link in _active_sync_links(db): total["links"] += 1 try: with PlexClient(db) as plex: acct = _resolve_account_id(db, plex, link) since = int(link.last_watch_sync_at.timestamp()) if link.last_watch_sync_at else 0 now = datetime.now(timezone.utc) rk_to_id = {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)} for row in plex.watch_history(min_viewed_at=since, account_id=acct): iid = rk_to_id.get(str(row.get("ratingKey") or "")) if iid is None: continue ch = _pull_apply(db, link.user_id, iid, "watched", 0, _epoch(row.get("viewedAt"))) if ch: total[ch] += 1 for row in plex.on_deck(): iid = rk_to_id.get(str(row.get("ratingKey") or "")) if iid is None: continue off = int(row.get("viewOffset") or 0) // 1000 if off < _PROGRESS_MIN_S: continue ch = _pull_apply(db, link.user_id, iid, "new", off, _epoch(row.get("lastViewedAt"))) if ch: total[ch] += 1 total["repushed"] += _repush_dirty(db, plex, link) link.last_watch_sync_at = now db.commit() except PlexNotConfigured as e: return {"skipped": str(e)} except PlexError as e: db.rollback() log.warning("Plex watch sync failed (user %s): %s", link.user_id, e) log.info("Plex watch sync: %s", total) return total 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: - Plex has a state, Siftlode differs → last-write-wins (Plex usually the newer here). - Siftlode has a state, Plex has NO record: · synced_to_plex=True → Plex un-watched it since our last sync → clear it (un-watch). · synced_to_plex=False → a local change that never reached Plex → re-push it (via `_repush_dirty`), never dropped. - `hidden` rows are Siftlode-only and never cleared. Union-preserving: a never-synced Siftlode-only state is pushed up, not lost.""" if not sysconfig.get_bool(db, "plex_enabled"): return {"skipped": "disabled"} total = {"links": 0, "applied": 0, "cleared": 0, "repushed": 0, "scanned": 0} for link in _active_sync_links(db): total["links"] += 1 try: with PlexClient(db) as plex: _resolve_account_id(db, plex, link) plex_states, scanned = _scan_plex_states(db, plex) total["scanned"] += scanned for item_id, (status, pos, watched_at, prog_at) in plex_states.items(): ch = _pull_apply(db, link.user_id, item_id, status, pos, watched_at or prog_at) 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.query(PlexState).filter_by(user_id=link.user_id).all(): if st.item_id in plex_states or st.status == "hidden": continue if st.synced_to_plex: db.delete(st) total["cleared"] += 1 total["repushed"] += _repush_dirty(db, plex, link) link.last_watch_sync_at = datetime.now(timezone.utc) db.commit() except PlexNotConfigured as e: return {"skipped": str(e)} except PlexError as e: db.rollback() log.warning("Plex watch reconcile failed (user %s): %s", link.user_id, e) log.info("Plex watch reconcile: %s", total) return total