Files
siftlode/backend/tests/test_plex_finish.py
T
peter 531eb2eef1 refactor(plex): playlists, playback, Discover and admin through the unit; the plex package is fenced
The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds
SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a
user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a
season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py.

The plex package joins FENCED: it reads no other unit's tables, says whose a row is through
userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in
its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes.
A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins.

Pins 178 -> 0; basedpyright baseline 118 -> 106.
2026-10-19 22:42:46 +02:00

84 lines
3.1 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.plex.models import PlexItem, PlexLibrary, PlexState
from app.plex import service as plex_service
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 (
plex_service.mark_finished(first, user.id, item.id, now) is True
) # holds the row lock
out: list[bool] = []
racer = threading.Thread(
target=lambda: out.append(plex_service.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 plex_service.mark_finished(db, user.id, item.id, now) is True
db.commit()
assert plex_service.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