Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:
- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
`{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
mark, and the invite decision + account activation — which an approval used to land in separate
transactions. The other six commit on purpose (a token durable before its link is mailed, a
single-use token burned as its own unit, the Google callback's tail, a background registration
with its own session, the staged search ingest around YouTube calls), each listed with its reason.
`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""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 uid, 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() == {"ok": True, "status": "watched", "position_seconds": 0}
|
|
assert playback._resume_push_superseded(user.id, item.id, earlier) is True
|