2026-07-09 14:42:18 +02:00
|
|
|
"""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
|
2026-07-10 00:46:22 +02:00
|
|
|
from datetime import datetime, timedelta, timezone
|
2026-07-09 14:42:18 +02:00
|
|
|
|
2026-07-09 15:18:19 +02:00
|
|
|
from sqlalchemy import func
|
|
|
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
2026-07-09 14:42:18 +02:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app import sysconfig
|
2026-07-10 00:17:56 +02:00
|
|
|
from app.db import SessionLocal
|
2026-07-09 14:42:18 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
def _rk_to_id(db: Session) -> dict[str, int]:
|
|
|
|
|
"""The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row."""
|
|
|
|
|
return {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)}
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 00:46:22 +02:00
|
|
|
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)."""
|
2026-07-11 23:48:35 +02:00
|
|
|
item_id_by_rk = _rk_to_id(db)
|
2026-07-10 00:46:22 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-07-09 14:42:18 +02:00
|
|
|
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:
|
2026-07-10 00:46:22 +02:00
|
|
|
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(
|
|
|
|
|
{
|
2026-07-09 15:18:19 +02:00
|
|
|
"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,
|
|
|
|
|
}
|
2026-07-10 00:46:22 +02:00
|
|
|
)
|
|
|
|
|
stats["watched" if status == "watched" else "in_progress"] += 1
|
2026-07-09 15:18:19 +02:00
|
|
|
|
|
|
|
|
# Bulk upsert in chunks. Plex wins on the intersection; Siftlode-only states (item_ids not
|
2026-07-10 00:46:22 +02:00
|
|
|
# scanned) are never referenced, so they're preserved untouched (union).
|
2026-07-09 15:18:19 +02:00
|
|
|
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)
|
2026-07-09 14:42:18 +02:00
|
|
|
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
|
2026-07-10 00:17:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
def _state_marker(st: PlexState | None) -> datetime | None:
|
|
|
|
|
"""The freshest local-edit timestamp on a state row (watched_at / progress_updated_at). A push
|
|
|
|
|
captures it at schedule time so it can tell, when it later settles the `synced_to_plex` flag,
|
|
|
|
|
whether the row has been re-edited in the meantime (see `push_state_to_plex`)."""
|
|
|
|
|
if st is None:
|
|
|
|
|
return None
|
|
|
|
|
return max([t for t in (st.watched_at, st.progress_updated_at) if t], default=None)
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 00:17:56 +02:00
|
|
|
def push_state_to_plex(
|
|
|
|
|
user_id: int,
|
|
|
|
|
item_id: int,
|
|
|
|
|
rating_key: str,
|
|
|
|
|
action: str,
|
|
|
|
|
time_ms: int = 0,
|
|
|
|
|
duration_ms: int = 0,
|
2026-07-11 23:48:35 +02:00
|
|
|
expect_ts: datetime | None = None,
|
2026-07-10 00:17:56 +02:00
|
|
|
) -> 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.
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
`action` is one of ``watched`` (scrobble), ``unwatched`` (unscrobble), ``resume`` (timeline).
|
|
|
|
|
`expect_ts` is the row's freshest timestamp captured when this push was scheduled: if the row has
|
|
|
|
|
since been re-edited (a newer local change with its own pending push), we must NOT mark it clean —
|
|
|
|
|
doing so would strand that newer value, never pushing it to Plex (lost update)."""
|
2026-07-10 00:17:56 +02:00
|
|
|
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
|
2026-07-11 23:48:35 +02:00
|
|
|
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
|
|
|
|
|
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
|
2026-07-10 00:17:56 +02:00
|
|
|
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
2026-07-11 23:48:35 +02:00
|
|
|
if st is not None and _row_matches(st, expect_ts):
|
2026-07-10 00:17:56 +02:00
|
|
|
st.synced_to_plex = True
|
|
|
|
|
db.commit()
|
2026-07-10 00:46:22 +02:00
|
|
|
|
|
|
|
|
|
2026-07-11 23:48:35 +02:00
|
|
|
def _row_matches(st: PlexState, expect_ts: datetime | None) -> bool:
|
|
|
|
|
"""Whether a state row is still the one a push was scheduled for — true when no marker was given
|
|
|
|
|
(legacy/edge), the row carries no timestamp, or its freshest timestamp is within a second of the
|
|
|
|
|
captured marker (a genuine newer edit is always many seconds later)."""
|
|
|
|
|
if expect_ts is None:
|
|
|
|
|
return True
|
|
|
|
|
cur = _state_marker(st)
|
|
|
|
|
return cur is None or abs((cur - expect_ts).total_seconds()) < 1.0
|
|
|
|
|
|
|
|
|
|
|
2026-07-11 03:10:02 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 00:46:22 +02:00
|
|
|
# --- 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:
|
2026-07-11 23:48:35 +02:00
|
|
|
"""The owner's Plex accountID, cached on the link. On a Plex Media Server, accountID 1 is reserved
|
|
|
|
|
for the server owner/admin (managed + shared users get id > 1), and an owner link (`uses_admin`)
|
|
|
|
|
always holds the admin token — so 1 is correct here, not an assumption to second-guess. We still
|
|
|
|
|
confirm + grab the owner's username from /accounts, falling back to 1 if that call is unavailable."""
|
2026-07-10 00:46:22 +02:00
|
|
|
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."""
|
2026-07-11 23:48:35 +02:00
|
|
|
# id → (rating_key, duration_s): duration comes straight from the mirrored row, so a resume
|
|
|
|
|
# re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's
|
|
|
|
|
# duration argument.
|
|
|
|
|
info_by_id: dict[int, tuple[str, int | None]] = {
|
|
|
|
|
iid: (rk, dur) for iid, rk, dur in db.query(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s)
|
|
|
|
|
}
|
2026-07-10 00:46:22 +02:00
|
|
|
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:
|
2026-07-11 23:48:35 +02:00
|
|
|
info = info_by_id.get(st.item_id)
|
|
|
|
|
if info is None:
|
2026-07-10 00:46:22 +02:00
|
|
|
continue
|
2026-07-11 23:48:35 +02:00
|
|
|
rk, dur_s = info
|
2026-07-10 00:46:22 +02:00
|
|
|
try:
|
|
|
|
|
if st.status == "watched":
|
|
|
|
|
plex.scrobble(rk)
|
|
|
|
|
elif st.position_seconds and st.position_seconds >= _PROGRESS_MIN_S:
|
2026-07-11 23:48:35 +02:00
|
|
|
plex.set_timeline(rk, st.position_seconds * 1000, int(dur_s or 0) * 1000)
|
2026-07-10 00:46:22 +02:00
|
|
|
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)
|
2026-07-11 23:48:35 +02:00
|
|
|
rk_to_id = _rk_to_id(db)
|
2026-07-10 00:46:22 +02:00
|
|
|
|
|
|
|
|
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
|