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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user