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:
+21 -7
View File
@@ -234,6 +234,15 @@ def person_image(u: str, request: Request) -> Response:
return imgcache.response(data, ct)
def _supersede_resume_pushes(user_id: int, item_id: int, now: datetime) -> None:
"""A watched mark supersedes every resume push still in flight for the item: stamping it as the
latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep the map
small) removed the very guard, and an older resume push could then land at Plex AFTER the
scrobble and revive a resume point on a watched item. Eviction bounds the map."""
with _push_throttle_lock:
_last_push_at[(user_id, item_id)] = now
def _push_resume_guarded(
user_id: int,
item_id: int,
@@ -329,12 +338,7 @@ def item_progress(
near_end = duration > 0 and position > duration - _FINISH_MARGIN_S
if near_end:
flipped = service.mark_finished(db, user.id, it.id, now)
# The finish supersedes every resume push still in flight for this item: stamping it as the
# latest push makes `_resume_push_superseded` drop them. Popping the entry instead (to keep
# the map small) removed the very guard, and an older resume push could then land at Plex
# AFTER the scrobble and revive a resume point on a watched item. Eviction bounds the map.
with _push_throttle_lock:
_last_push_at[(user.id, it.id)] = now
_supersede_resume_pushes(user.id, it.id, now)
db.commit()
# Only the checkpoint that flipped the row scrobbles: a later checkpoint of the same finished
# item — or a second one racing this one — would otherwise bump Plex's viewCount again.
@@ -352,7 +356,11 @@ def item_progress(
push_now = _should_push_resume(user.id, it.id, now, final)
# Marked dirty before the (background) push so the reconcile knows to re-push if it fails, and so
# `push_state_to_plex` can settle synced_to_plex once Plex confirms.
service.checkpoint(db, user, it.id, st, position, now, dirty=push_now)
if not service.checkpoint(db, user, it.id, st, position, now, dirty=push_now):
# Marked watched a moment ago: this is the finishing player's late tick (see
# `service.checkpoint`), and the answer is the same one a near-end checkpoint gets.
db.commit()
return {"ok": True, "status": "watched", "position_seconds": 0}
db.commit()
if push_now:
_push_watch(
@@ -408,6 +416,8 @@ def item_state(
_push_watch(background, db, user.id, it.id, rating_key, "unwatched")
return {"ok": True, "status": "new"}
service.set_status(db, user, it.id, st, status)
if status == "watched":
_supersede_resume_pushes(user.id, it.id, datetime.now(timezone.utc))
db.commit()
if status == "watched" and prev_status != "watched":
_push_watch(background, db, user.id, it.id, rating_key, "watched")
@@ -427,6 +437,10 @@ def _bulk_state(
always reads the committed marks."""
pushable = plex_watch.link_for_push(db, user_id) is not None
to_push = service.bulk_state(db, user_id, eps, watched)
if watched:
now = datetime.now(timezone.utc)
for item_id, _rating_key, _action in to_push:
_supersede_resume_pushes(user_id, item_id, now)
if pushable and to_push:
# One coalesced background task for the whole batch (reuses a single session + Plex client),
# not one task per episode — a 200-ep show mark is 1 task, not 200.