Make Plex the source of truth for watch state: - Write-back during playback: item_progress now pushes the resume position to Plex on the periodic checkpoint too (not only the final flush), throttled to one push per item every ~10s (in-memory, single-process rate limiter). The FE now also fires an immediate (debounced) push on seek. - Faster incremental pull: 30 → 15 min (plex_watch_sync_interval_min). - Plex-authoritative pull: _pull_apply resolves a genuine divergence to Plex's state instead of last-write-wins; the _same_state + synced_to_plex short-circuit still prevents a freshly-pushed row from bouncing. Removed the now-unused _LWW_TOLERANCE_S / _sift_ts. Tests: 5 new in test_plex_watch_sync.py (Plex-wins resume/watched, no-bounce, throttle final vs periodic).
149 lines
5.3 KiB
Python
149 lines
5.3 KiB
Python
"""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
|