fix(plex): a checkpoint sent just before the finish no longer lands on the watched row

The 10 s tick in flight when the player finishes (or a refusal rollback whose state write in fact
committed) wrote its mid-item position onto the freshly watched row and scheduled a resume push
after the scrobble, so Plex showed the item in progress again. The checkpoint is now a conditional
write that skips a row marked watched in the last 30 s and answers like a finish; a rewatch past
that is checkpointed as before. Marking an item (or a season/show) watched also supersedes resume
pushes still in flight, as the near-end finish already did.
This commit is contained in:
2026-10-28 18:56:12 +01:00
parent e96a562f14
commit 24190e4c16
4 changed files with 112 additions and 18 deletions
+30 -9
View File
@@ -6,9 +6,9 @@ The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's
after a write-back, its local cleanup after a delete.
"""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult, delete, func, select, update
from sqlalchemy import CursorResult, delete, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
@@ -271,6 +271,10 @@ def reorder_playlist(db: Session, pl: PlexPlaylist, rating_keys: list[str]) -> N
# --- a user's watch state --------------------------------------------------------------------
# How long after a watched mark a mid-item position is read as the finishing player's late tick
# (`checkpoint`): the tick is every 10 s, plus the request's own latency and a retry.
_LATE_CHECKPOINT_S = 30
def mark_finished(db: Session, user_id: int, item_id: int, now: datetime) -> bool:
"""Mark the item watched (position cleared) and say whether THIS call made it so. Two near-end
@@ -352,16 +356,33 @@ def checkpoint(
now: datetime,
*,
dirty: bool,
) -> PlexState:
"""Record a resume position. `dirty` marks it not yet mirrored to Plex — set before a push, so
the reconcile re-pushes it if the push fails and the push can settle it once Plex confirms."""
) -> bool:
"""Record a resume position, and say whether it was recorded. `dirty` marks it not yet mirrored
to Plex — set before a push, so the reconcile re-pushes it if the push fails and the push can
settle it once Plex confirms.
Not onto a row marked watched in the last `_LATE_CHECKPOINT_S`: that position is the player's
tick sent just before its own finish (or a mark from another tab), and writing it made a watched
item in progress again — on Plex too, through the resume push it carried. Decided by the write
itself, since the mark can commit between any read here and the write. A rewatch is checkpointed
as before once the mark is that far behind it."""
st = state_row(db, user, item_id, st)
st.position_seconds = position
st.progress_updated_at = now
values: dict[str, object] = {"position_seconds": position, "progress_updated_at": now}
if dirty:
st.synced_to_plex = False
values["synced_to_plex"] = False
not_just_finished = or_(
PlexState.status != "watched",
PlexState.watched_at.is_(None),
PlexState.watched_at <= now - timedelta(seconds=_LATE_CHECKPOINT_S),
)
result = db.execute(
update(PlexState)
.where(PlexState.id == st.id, not_just_finished)
.values(values)
.execution_options(synchronize_session="fetch")
)
db.flush()
return st
return isinstance(result, CursorResult) and result.rowcount > 0
def forget_state(db: Session, st: PlexState) -> None: