The incremental watch-sync (run_plex_watch_sync) learned a movie's watched transition only from the history feed + onDeck. When a movie is finished on Plex (commonly by scrubbing through the end credits) Plex sets viewCount>0, clears viewOffset and DROPS it from Continue-Watching, so onDeck no longer lists it (and onDeck can only ever ADD an in-progress row, never resolve one), while a scrub-to-end frequently emits no history session row. The stale local status='new', position>0 row then survived until the DAILY full reconcile — which is why movies kept showing "in progress". Add _reconcile_inprogress_movies: for each local in-progress MOVIE that Plex's onDeck no longer lists, read its current per-account viewCount/viewOffset with one metadata() call and apply it via the shared _pull_apply, flipping to watched when Plex says so. Bounded by a probe cap (100), most-recently-touched first; episodes are left to onDeck/history. No-signal rows are preserved (an un-watch stays the daily reconcile's job). Tests: watched-flip, onDeck+episode skip, and no-signal-preserved.
387 lines
15 KiB
Python
387 lines
15 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 types import SimpleNamespace
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.models import PlexItem, PlexLibrary, PlexState, User
|
|
from app.plex.watch_sync import _pull_apply, _reconcile_inprogress_movies
|
|
from app.routes.plex.playback import (
|
|
_PUSH_THROTTLE_S,
|
|
_resume_push_superseded,
|
|
_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
|
|
|
|
|
|
class _FakePlex:
|
|
"""Minimal PlexClient stand-in for the movie-probe tests: metadata(rating_key) → a leaf dict."""
|
|
|
|
def __init__(self, by_rk: dict[str, dict]):
|
|
self._by_rk = by_rk
|
|
|
|
def metadata(self, rating_key: str, markers: bool = False):
|
|
return self._by_rk.get(str(rating_key))
|
|
|
|
|
|
def test_pull_plex_wins_over_diverging_synced_local_resume(db):
|
|
"""The core S2 change: a SYNCED local row (its state already reached Plex) that later diverges from
|
|
Plex = a real external Plex-side change, so the incremental pull resolves to PLEX's position — Plex
|
|
is authoritative, no LWW. (A DIRTY local row is protected instead — see the dirty-guard test.)"""
|
|
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=True, # but already pushed → a later Plex divergence is an external change
|
|
)
|
|
)
|
|
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_synced_local_in_progress(db):
|
|
"""Plex marking the item watched propagates over a SYNCED local in-progress row (external change)."""
|
|
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=True,
|
|
)
|
|
)
|
|
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_pull_keeps_dirty_local_on_divergence(db):
|
|
"""Durability guard: a DIRTY row (a local change whose immediate push failed → synced_to_plex=False)
|
|
must NOT be overwritten by a diverging Plex signal, and must stay dirty so `_repush_dirty` can push
|
|
the local value up. Overwriting-and-clearing here would strand the local change forever."""
|
|
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="watched", # user just marked watched locally; the scrobble to Plex failed
|
|
position_seconds=0,
|
|
watched_at=now,
|
|
progress_updated_at=now,
|
|
synced_to_plex=False, # dirty — never reached Plex
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
# Plex still lists it in on_deck at an old in-progress offset (it never got the watched scrobble).
|
|
changed = _pull_apply(db, user_id, item_id, "new", 240, plex_ts=now - timedelta(seconds=60))
|
|
db.flush()
|
|
|
|
assert changed is None, "a dirty local row must not be clobbered by the pull"
|
|
st = (
|
|
db.execute(
|
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
|
)
|
|
.scalars()
|
|
.one()
|
|
)
|
|
assert st.status == "watched", "local watched preserved"
|
|
assert st.position_seconds == 0
|
|
assert st.synced_to_plex is False, "stays dirty so _repush_dirty retries the push"
|
|
|
|
|
|
def test_pull_settles_flag_on_matching_dirty_row(db):
|
|
"""Anti-bounce, dirty branch: a row that already MATCHES Plex but is still dirty gets its
|
|
synced_to_plex flag settled to True (no churn), so `_repush_dirty` stops re-pushing it forever."""
|
|
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=False, # dirty, but already agrees with Plex
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
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 makes no change"
|
|
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"
|
|
assert st.synced_to_plex is True, "the dirty flag is settled so it isn't re-pushed endlessly"
|
|
|
|
|
|
def test_movie_probe_flips_stale_in_progress_to_watched(db):
|
|
"""S1 core: a local in-progress MOVIE that Plex has since finished (viewCount>0) and dropped from
|
|
Continue-Watching flips to watched on the incremental pass — without waiting for the daily reconcile.
|
|
This is the "movie stuck in progress after scrubbing the credits" bug."""
|
|
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=True, # was created by a prior pull; the user then watched it on Plex
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
# Plex now reports the movie watched (viewCount>0, no offset) and it is NOT on the deck.
|
|
plex = _FakePlex({"m1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())}})
|
|
out = _reconcile_inprogress_movies(
|
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
|
)
|
|
db.flush()
|
|
|
|
assert out == {"watched": 1, "in_progress": 0, "probed": 1}
|
|
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_movie_probe_skips_ondeck_and_episodes(db):
|
|
"""The probe must NOT touch (a) a movie Plex still lists on the deck — it's legitimately in
|
|
progress — nor (b) an in-progress EPISODE (episodes ride onDeck/history + rollup). Only stale
|
|
in-progress movies are probed, and metadata() is called for those alone."""
|
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
on_deck_movie = PlexItem(rating_key="mDeck", library_id=lib.id, kind="movie", title="On deck")
|
|
episode = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="An episode")
|
|
db.add_all([on_deck_movie, episode])
|
|
user = User(email="probe2@example.com")
|
|
db.add(user)
|
|
db.flush()
|
|
now = datetime.now(timezone.utc)
|
|
for iid in (on_deck_movie.id, episode.id):
|
|
db.add(
|
|
PlexState(
|
|
user_id=user.id,
|
|
item_id=iid,
|
|
status="new",
|
|
position_seconds=300,
|
|
progress_updated_at=now,
|
|
synced_to_plex=True,
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
# Plex would report both watched — but neither should be probed at all, so the fake returning
|
|
# "watched" must have NO effect.
|
|
plex = _FakePlex(
|
|
{
|
|
"mDeck": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
|
"e1": {"viewCount": 1, "lastViewedAt": int(now.timestamp())},
|
|
}
|
|
)
|
|
out = _reconcile_inprogress_movies(
|
|
db, plex, SimpleNamespace(user_id=user.id), on_deck_ids={on_deck_movie.id}
|
|
)
|
|
db.flush()
|
|
|
|
assert out == {"watched": 0, "in_progress": 0, "probed": 0}, "neither item is probed"
|
|
for iid in (on_deck_movie.id, episode.id):
|
|
st = (
|
|
db.execute(
|
|
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == iid)
|
|
)
|
|
.scalars()
|
|
.one()
|
|
)
|
|
assert st.status == "new" and st.position_seconds == 300, "left untouched"
|
|
|
|
|
|
def test_movie_probe_leaves_row_when_plex_has_no_signal(db):
|
|
"""If Plex reports no watch signal (viewCount 0, no offset) for a probed movie, the incremental pass
|
|
must NOT clear the local in-progress row — un-watch is the daily reconcile's job, and a Siftlode-only
|
|
in-progress state must survive."""
|
|
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=True,
|
|
)
|
|
)
|
|
db.flush()
|
|
|
|
plex = _FakePlex({"m1": {"viewCount": 0}})
|
|
out = _reconcile_inprogress_movies(
|
|
db, plex, SimpleNamespace(user_id=user_id), on_deck_ids=set()
|
|
)
|
|
db.flush()
|
|
|
|
assert out == {"watched": 0, "in_progress": 0, "probed": 1}
|
|
st = (
|
|
db.execute(
|
|
select(PlexState).where(PlexState.user_id == user_id, PlexState.item_id == item_id)
|
|
)
|
|
.scalars()
|
|
.one()
|
|
)
|
|
assert st.status == "new" and st.position_seconds == 300, "in-progress row preserved"
|
|
|
|
|
|
def test_should_push_resume_scopes_by_user_and_item():
|
|
"""The throttle window is keyed by the FULL (user, item) tuple: pushing for one key must not
|
|
suppress a distinct user or a distinct item within the same window."""
|
|
now = datetime.now(timezone.utc)
|
|
assert _should_push_resume(9101, 9201, now, final=False) is True # (u1, i1) first tick
|
|
# Same window, different item for the same user → independent, still pushes.
|
|
assert _should_push_resume(9101, 9202, now, final=False) is True
|
|
# Same window, different user, same item → independent, still pushes.
|
|
assert _should_push_resume(9102, 9201, now, final=False) is True
|
|
# And the original key is still throttled within its window (proves the above weren't just "always").
|
|
assert _should_push_resume(9101, 9201, now + timedelta(seconds=1), final=False) is False
|
|
|
|
|
|
def test_resume_push_superseded_drops_older_pending_push():
|
|
"""The write-ordering guard: an older still-pending resume push is dropped once a newer push for the
|
|
same (user, item) has been scheduled, so a reordered stale position can't land at Plex last."""
|
|
key = (9301, 9301)
|
|
t0 = datetime.now(timezone.utc)
|
|
# Schedule an initial push at t0 (records t0 as the last-push time for this key).
|
|
assert _should_push_resume(*key, t0, final=False) is True
|
|
# Nothing newer yet → the t0 push is current, not superseded.
|
|
assert _resume_push_superseded(*key, t0) is False
|
|
# A newer push (e.g. a backward seek) is scheduled at t1 > t0.
|
|
t1 = t0 + timedelta(seconds=1)
|
|
assert _should_push_resume(*key, t1, final=True) is True
|
|
# Now the still-pending t0 push is superseded and must drop; the t1 push is current.
|
|
assert _resume_push_superseded(*key, t0) is True
|
|
assert _resume_push_superseded(*key, t1) is False
|
|
|
|
|
|
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
|