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:
parent
f90c12beaa
commit
28061353ec
4 changed files with 164 additions and 115 deletions
|
|
@ -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 ""))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue