fix(plex): one scrobble per finish, and no resume push lands after it

Two near-end checkpoints arrive together routinely (the 10 s tick and the
debounced seeked flush); both read "not watched" before writing and both
scrobbled, bumping Plex's viewCount twice. The transition is now decided by
the write: an insert that did not conflict, or an update conditional on the
old status.

The finish also popped the push-ordering entry that _resume_push_superseded
reads, so an older resume push still in flight could land at Plex after the
scrobble and revive a resume point on a watched item. It now stamps the
entry instead; the existing eviction bounds the map.
This commit is contained in:
2026-09-19 06:57:20 +02:00
parent fad301e7a4
commit bacd6aec4f
2 changed files with 129 additions and 23 deletions
+80
View File
@@ -0,0 +1,80 @@
"""The near-end checkpoint that finishes a Plex item.
- Exactly one scrobble per finish, even when two near-end checkpoints race (the 10 s tick and the
debounced `seeked` flush arrive together): the transition is decided by a conditional write, not a
read taken before it.
- The finish supersedes every resume push still in flight, so none can land at Plex after the
scrobble and revive a resume point on a watched item.
"""
import threading
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from app.db import SessionLocal
from app.models import PlexItem, PlexLibrary, PlexState
from app.routes.plex import playback
def _item(db) -> PlexItem:
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
item = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A Movie")
db.add(item)
db.commit()
return item
def test_two_racing_finishes_flip_the_row_once(db, make_user):
user = make_user()
item = _item(db)
db.add(PlexState(user_id=user.id, item_id=item.id, status="new", position_seconds=5000))
db.commit()
now = datetime.now(timezone.utc)
first, second = SessionLocal(), SessionLocal()
try:
assert playback._mark_finished(first, user.id, item.id, now) is True # holds the row lock
out: list[bool] = []
racer = threading.Thread(
target=lambda: out.append(playback._mark_finished(second, user.id, item.id, now))
)
racer.start()
racer.join(0.5)
assert racer.is_alive(), "the second writer must wait on the first one's row lock"
first.commit()
racer.join(5)
second.commit()
finally:
first.close()
second.close()
assert out == [False], "the second checkpoint saw the committed finish: no second scrobble"
def test_a_first_ever_finish_inserts_and_counts_once(db, make_user):
user = make_user()
item = _item(db)
now = datetime.now(timezone.utc)
assert playback._mark_finished(db, user.id, item.id, now) is True
db.commit()
assert playback._mark_finished(db, user.id, item.id, now + timedelta(seconds=10)) is False
db.commit()
st = db.execute(select(PlexState).where(PlexState.item_id == item.id)).scalars().one()
assert (st.status, st.position_seconds) == ("watched", 0)
def test_the_finish_supersedes_a_resume_push_still_in_flight(db, as_user, make_user, monkeypatch):
user = make_user()
item = _item(db)
monkeypatch.setattr(playback.plex_stream, "touch_session", lambda key: None)
earlier = datetime.now(timezone.utc) - timedelta(seconds=5)
# A resume push was scheduled a moment ago and has not reached Plex yet.
assert playback._should_push_resume(user.id, item.id, earlier, final=True)
r = as_user(user).post(
"/api/plex/item/m1/progress", json={"position_seconds": 2995, "duration_seconds": 3000}
)
assert r.json() == {"status": "watched", "position_seconds": 0}
assert playback._resume_push_superseded(user.id, item.id, earlier) is True