Plex backend pass (#9): watch_sync bugs + backend dedup

Closes the Plex #9 backend backlog (frontend was the prior follow-up). Behavior-
neutral cleanup + two two-way-sync bug fixes; PW2 verified a non-bug.

fix(plex): watch-sync PW1 pagination + PW3 lost-update guard
- PW1 watch_history: the history is a GLOBAL cross-account feed read as a single
  500-row page, then filtered to the owner — on a busy family server the owner's
  recent views can sit past page 1 and be missed. Now paginate (desc) until the
  since-cutoff, bounded by max_pages (daily reconcile is the backstop; logs if hit).
- PW3 push_state_to_plex: it flagged synced_to_plex=True unconditionally after a
  push, so a local edit landing between the push and the flag-set was marked clean
  and never pushed (lost update). Capture the row's freshest timestamp when the
  push is scheduled (_push_watch) and only settle the flag if the row is unchanged.
- PW2 was flagged as "accountID hardcoded to 1" but is NOT a bug: on a PMS account 1
  is reserved for the owner/admin and an owner link always holds the admin token —
  added a clarifying comment so it isn't re-flagged.

chore(plex): backend dedup
- PC2 unified_library + facets shared a ~13-field filter Query signature + p-dict →
  one LibraryFilters dataclass injected via Depends(); as_dict() feeds the builders.
- PS-C1 sync.py collection upsert dup (resync_collection ≈ _sync_collections) →
  _upsert_collection().
- PS-C2 sync.py 8-field filterable-metadata block dup (_apply_item ≈ _sync_shows) →
  _apply_facet_fields().
- PW-C1 _repush_dirty read duration from the mirrored PlexItem.duration_s instead of
  a per-row plex.metadata() round-trip.
- PW-C2 rk→id map built inline in two places → _rk_to_id() helper.
This commit is contained in:
npeter83 2026-07-11 23:48:35 +02:00
parent f90c12beaa
commit 28061353ec
4 changed files with 164 additions and 115 deletions

View file

@ -59,15 +59,18 @@ def _plex_watch_to_state(
return None
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)}
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)
}
item_id_by_rk = _rk_to_id(db)
wanted = _enabled_section_keys(db)
scanned = 0
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
@ -165,6 +168,15 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None:
return None
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)
def push_state_to_plex(
user_id: int,
item_id: int,
@ -172,6 +184,7 @@ def push_state_to_plex(
action: str,
time_ms: int = 0,
duration_ms: int = 0,
expect_ts: datetime | None = None,
) -> 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
@ -179,7 +192,10 @@ def push_state_to_plex(
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)."""
`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)."""
with SessionLocal() as db:
if link_for_push(db, user_id) is None:
return
@ -199,13 +215,24 @@ def push_state_to_plex(
)
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).
# 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).
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
if st is not None:
if st is not None and _row_matches(st, expect_ts):
st.synced_to_plex = True
db.commit()
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
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
@ -271,8 +298,10 @@ def _active_sync_links(db: Session) -> list[PlexLink]:
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."""
"""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."""
if link.plex_account_id:
return link.plex_account_id
acct_id, username = 1, None
@ -335,7 +364,12 @@ 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))
# 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)
}
dirty = (
db.query(PlexState)
.filter(
@ -347,15 +381,15 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int:
)
n = 0
for st in dirty:
rk = rk_by_id.get(st.item_id)
if rk is None:
info = info_by_id.get(st.item_id)
if info is None:
continue
rk, dur_s = info
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)
plex.set_timeline(rk, st.position_seconds * 1000, int(dur_s or 0) * 1000)
else:
plex.unscrobble(rk)
st.synced_to_plex = True
@ -382,7 +416,7 @@ def run_plex_watch_sync(db: Session) -> dict:
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)}
rk_to_id = _rk_to_id(db)
for row in plex.watch_history(min_viewed_at=since, account_id=acct):
iid = rk_to_id.get(str(row.get("ratingKey") or ""))