"""S2 watch-sync semantics: Plex is the source of truth. Two independent pieces: - `_pull_apply` (DB lane): on a genuine divergence for an item Plex knows about, Plex now WINS — the old "keep Siftlode when it's newer within a tolerance" tug-of-war is gone. The anti-bounce short-circuit (`_same_state` + `synced_to_plex`) must still keep a freshly-pushed row from bouncing. - `_should_push_resume` (pure): the in-memory write-back throttle — a `final` checkpoint always pushes, a periodic one pushes at most once per _PUSH_THROTTLE_S seconds per (user, item). """ from datetime import datetime, timedelta, timezone from sqlalchemy import select from app.models import PlexItem, PlexLibrary, PlexState, User from app.plex.watch_sync import _pull_apply from app.routes.plex.playback import _PUSH_THROTTLE_S, _should_push_resume def _seed_item_and_user(db): 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) user = User(email="watch@example.com") db.add(user) db.flush() return item.id, user.id def test_pull_plex_wins_over_newer_local_resume(db): """The core S2 change: a locally-in-progress item whose local edit is NEWER than Plex's still resolves to PLEX's position on the incremental pull — Plex is authoritative, no LWW.""" item_id, user_id = _seed_item_and_user(db) now = datetime.now(timezone.utc) db.add( PlexState( user_id=user_id, item_id=item_id, status="new", position_seconds=300, progress_updated_at=now, # local touched just now — "newer" than Plex synced_to_plex=False, ) ) db.flush() # Plex reports a DIFFERENT (older) resume position for the same item. changed = _pull_apply(db, user_id, item_id, "new", 120, plex_ts=now - timedelta(seconds=60)) db.flush() assert changed == "in_progress" st = ( db.execute( select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id) ) .scalars() .one() ) assert st.position_seconds == 120, "Plex's position must win, even though local was newer" assert st.synced_to_plex is True def test_pull_plex_watched_wins_over_local_in_progress(db): """Plex marking the item watched propagates even over a fresh local in-progress row.""" item_id, user_id = _seed_item_and_user(db) now = datetime.now(timezone.utc) db.add( PlexState( user_id=user_id, item_id=item_id, status="new", position_seconds=300, progress_updated_at=now, synced_to_plex=False, ) ) db.flush() changed = _pull_apply(db, user_id, item_id, "watched", 0, plex_ts=now - timedelta(seconds=60)) db.flush() assert changed == "watched" st = ( db.execute( select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id) ) .scalars() .one() ) assert st.status == "watched" assert st.position_seconds == 0 def test_pull_no_bounce_on_matching_row(db): """Anti-bounce short-circuit: when the row already matches what Plex reports (a just-pushed resume), the pull makes NO change and only settles the synced flag — no churn, no re-pull.""" item_id, user_id = _seed_item_and_user(db) now = datetime.now(timezone.utc) db.add( PlexState( user_id=user_id, item_id=item_id, status="new", position_seconds=100, progress_updated_at=now, synced_to_plex=True, # freshly pushed ) ) db.flush() # Plex echoes the same position a beat later (well within the _PROGRESS_MIN_S fuzz). changed = _pull_apply(db, user_id, item_id, "new", 102, plex_ts=now + timedelta(seconds=2)) db.flush() assert changed is None, "a matching row must not bounce" st = ( db.execute( select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id) ) .scalars() .one() ) assert st.position_seconds == 100, "no churn to the stored position" def test_should_push_resume_final_always_pushes(): """A final checkpoint (pause / seek / stop) always pushes, bypassing the throttle.""" now = datetime.now(timezone.utc) key = (9001, 9001) assert _should_push_resume(*key, now, final=True) is True # A second final a fraction of a second later still pushes. assert _should_push_resume(*key, now + timedelta(seconds=1), final=True) is True def test_should_push_resume_periodic_is_throttled(): """Periodic (non-final) checkpoints push at most once per _PUSH_THROTTLE_S per (user, item).""" now = datetime.now(timezone.utc) key = (9002, 9002) assert _should_push_resume(*key, now, final=False) is True # first tick pushes # A tick within the window is suppressed. assert ( _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S - 1), final=False) is False ) # Once the window elapses, the next tick pushes again. assert _should_push_resume(*key, now + timedelta(seconds=_PUSH_THROTTLE_S), final=False) is True