fix(sync): mirror the server's watch position so resume crosses devices (DR-155)

The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore write-only from this
device's perspective: watch 40 minutes in a browser, open JellyTau, and
it resumed from whatever this device last saw, or offered no resume at
all. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. get_item -- the call the player route
makes -- returned the cached copy on a hit and never consulted the
server, so for an already-cached item the mirror never ran. It now
refreshes in the background on a cache hit via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 22:14:20 +02:00
co-authored by Claude Opus 5
parent fec4b7ae8c
commit ba5fd55204
5 changed files with 272 additions and 19 deletions
+9
View File
@@ -1472,6 +1472,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
},
/**
* Report playback stopped
*
* A stop-report that cannot reach the server is queued rather than dropped:
* this is the position the resume point is built from, and losing it is
* exactly the "it forgot where I was" the sync queue exists to prevent. The
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
* failing the command because the *queue* write failed would tell the caller
* the report was lost when the local position was already saved.
*
* TRACES: UR-025 | DR-154 | UT-151
*/
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });