fix: every first watch state survives a write beside it, and a folded Watch later keeps its pin

A season marked watched, a pull from Plex and a YouTube resume point made their first row by a read
then an add, and failed on the unique key when a first checkpoint or the watched mark made it in
between. They go through the same insert-then-read as the checkpoint (the resume point is one
upsert); a pull that meets a local change still waiting for its push leaves it.

Folding duplicate Watch later lists moved their videos but not a Home pin, which the cascade then
deleted; the first pin of a dropped copy moves onto the kept list.

Marking one notice read trimmed before its own flag was flushed, so the trim left one past the cap.
The route test now crosses the cap and reads the result from a session of its own.
This commit is contained in:
2026-10-20 01:56:30 +02:00
parent b04b2cbd62
commit c5f121139b
10 changed files with 199 additions and 68 deletions
+7 -20
View File
@@ -32,6 +32,8 @@ from app.db import SessionLocal
from app.userscope import owned_by
from app.plex.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex import queries
from app.plex.service import state_row
from app.plex.sync import (
_TYPE_EPISODE,
_TYPE_MOVIE,
@@ -252,15 +254,7 @@ def push_state_to_plex(
# Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be
# nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was
# re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile).
st = (
db.execute(
select(PlexState).where(
owned_by(PlexState.user_id, user_id), PlexState.item_id == item_id
)
)
.scalars()
.first()
)
st = queries.state(db, user_id, item_id)
if st is not None and _row_matches(st, expect_ts):
st.synced_to_plex = True
db.commit()
@@ -394,15 +388,7 @@ def _pull_apply(
"""Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns
"watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the
full reconcile's job) — this only pulls in watched/resume."""
st = (
db.execute(
select(PlexState).where(
owned_by(PlexState.user_id, user_id), PlexState.item_id == item_id
)
)
.scalars()
.first()
)
st = queries.state(db, user_id, item_id)
if _same_state(st, status, pos):
if st is not None and not st.synced_to_plex:
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
@@ -423,8 +409,9 @@ def _pull_apply(
# "almost always already pushed" assumption above excludes.
return None
if st is None:
st = PlexState(user_id=user_id, item_id=item_id)
db.add(st)
st = state_row(db, user_id, item_id, synced=True)
if not st.synced_to_plex: # a local change made it first, beside this pull: the guard above
return None
if status == "watched":
st.status, st.position_seconds, st.watched_at, st.progress_updated_at = (
"watched",