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
+177 -16
View File
@@ -665,40 +665,63 @@ impl OfflineRepository {
}
/// Mirror the server's per-user state for an item into the local
/// `user_data` table, so favourites marked on any other client are visible
/// here — including offline, where the local table is the only source.
/// `user_data` table, so favourites marked and positions watched — on any
/// other client are visible here, including offline, where the local table
/// is the only source.
///
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
/// conflict rule: a toggle made while the server was unreachable is still
/// conflict rule: a change made while the server was unreachable is still
/// waiting to be pushed, and must not be clobbered by the stale value the
/// server is still reporting. Rows carrying no favourite state are skipped
/// entirely rather than written as `0`, which would fabricate an
/// "unfavourited" record from an endpoint that simply omits `UserData`.
/// server is still reporting. For a position that means it is never pulled
/// *backwards* by a server that has not yet heard where we got to.
///
/// TRACES: UR-069 | DR-114 | UT-102
/// Each field is mirrored only when the server actually reported it —
/// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
/// absent, and a row with neither field is skipped outright rather than
/// written as zeroes, which would fabricate an "unfavourited, unwatched"
/// record from an endpoint that simply omits `UserData`.
///
/// The position half is what makes cross-device resume work: the resume
/// check reads this table alone, so before it was mirrored an item watched
/// elsewhere resumed from whatever *this* device last saw, or not at all.
///
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
let Some(is_favorite) = item.user_data.as_ref().and_then(|ud| ud.is_favorite) else {
let user_data = item.user_data.as_ref();
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
// Nothing the server actually told us about — do not invent a row.
if is_favorite.is_none() && position_ticks.is_none() {
return Ok(());
};
}
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, 0)
"INSERT INTO user_data
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = excluded.is_favorite,
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
playback_position_ticks = COALESCE(
excluded.playback_position_ticks, user_data.playback_position_ticks),
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0",
vec![
QueryParam::String(self.user_id.clone()),
QueryParam::String(item.id.clone()),
QueryParam::Int(if is_favorite { 1 } else { 0 }),
is_favorite
.map(|f| QueryParam::Int(if f { 1 } else { 0 }))
.unwrap_or(QueryParam::Null),
position_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
QueryParam::String(now.to_string()),
],
);
// A missing item row (FK) is not fatal here — the favourite mirror is
// best-effort metadata, and failing the whole cache write over it would
// break browsing.
// A missing item row (FK) is not fatal here — the mirror is best-effort
// metadata, and failing the whole cache write over it would break
// browsing.
if let Err(e) = self.db_service.execute(query).await {
debug!(
"[OfflineRepo] user_data mirror skipped for {}: {}",
@@ -4438,4 +4461,142 @@ mod tests {
"an unsynced local toggle must survive a cache write"
);
}
/// UT-152 — the server's watch position is mirrored locally, so an item
/// watched on another device resumes here.
///
/// The resume check reads only the local `user_data` row, and the mirror
/// previously carried `is_favorite` alone — so a position set on any other
/// client never reached this device and cross-device resume silently did
/// nothing. The `pending_sync` guard is the same conflict rule favourites
/// use: a local position still waiting to be pushed must not be pulled
/// backwards by the stale value the server is still reporting.
///
/// TRACES: UR-025, UR-069 | DR-155 | UT-152
#[tokio::test]
async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let position = |id: &'static str| {
let db = db_service.clone();
async move {
db.query_optional(
Query::with_params(
"SELECT playback_position_ticks, pending_sync FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String(id.to_string()),
],
),
|row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
)
.await
.unwrap()
}
};
// Watched 20 minutes into this episode on another device.
let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
watched.user_data = Some(UserData {
playback_position_ticks: Some(12_000_000_000),
..Default::default()
});
// No user data at all — must not fabricate a position of 0.
let untouched = create_test_item("ep-2", "No User Data", None);
repo.save_to_cache("parent-1", &[watched.clone(), untouched])
.await
.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(12_000_000_000), Some(0))),
"the server's position should be mirrored as synced"
);
assert_eq!(
position("ep-2").await,
None,
"an item without UserData should not get an invented position"
);
// Watched further here while the server was unreachable: pending_sync = 1.
db_service
.execute(Query::with_params(
"UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::Int64(30_000_000_000),
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-1".to_string()),
],
))
.await
.unwrap();
// The server still reports the older position; caching must not win.
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(30_000_000_000), Some(1))),
"an unsynced local position must not be pulled backwards"
);
}
/// UT-152 — a server item carrying *only* a position (no favourite flag)
/// still gets mirrored.
///
/// The mirror used to return early whenever `is_favorite` was absent, which
/// is exactly the shape of an ordinary watched episode: Jellyfin reports
/// `PlaybackPositionTicks` with no favourite state. That early return is why
/// the position never landed.
///
/// TRACES: UR-025 | DR-155 | UT-152
#[tokio::test]
async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let mut watched = create_test_item("ep-3", "Position Only", None);
watched.user_data = Some(UserData {
is_favorite: None,
playback_position_ticks: Some(9_000_000_000),
..Default::default()
});
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
let stored = db_service
.query_optional(
Query::with_params(
"SELECT playback_position_ticks FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-3".to_string()),
],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap();
assert_eq!(
stored,
Some(Some(9_000_000_000)),
"a position with no favourite flag must still be mirrored"
);
}
}