fix(library): make explicit server resync authoritative

Treat Local Library resync actions as complete source reconciliations so provider IDs removed or reminted by Jellyfin are pruned only after a successful sweep.

Add regressions for a fully remapped Jellyfin identity set and for the QML resync contract.

cx
This commit is contained in:
vicrodh committed 2026-08-24 15:36:32 -06:00
1 parent d627560f4f
commit 2c6bd510a9
3 files changed
+74 -2

No files matched your search

+53
View File
@@ -1661,6 +1661,59 @@ mod tests {
.is_some()); .is_some());
} }
/// Jellyfin can remint every item id after a library rebuild while the
/// audio and album metadata remain identical. A full user-requested
/// reconciliation must treat the newly observed identity set as
/// authoritative; otherwise every physical track appears twice forever.
#[test]
fn full_reconciliation_replaces_a_completely_remapped_identity_set() {
let mut c = db();
let mut old_one = track("old-one", "same album", Some(1), Some(1));
old_one.title = "First".into();
let mut old_two = track("old-two", "same album", Some(1), Some(2));
old_two.title = "Second".into();
save_tracks(
&mut c,
RemoteSource::Jellyfin,
&[old_one.clone(), old_two.clone()],
)
.unwrap();
let generation = begin_source_sync(&mut c, RemoteSource::Jellyfin)
.unwrap()
.generation;
let mut new_one = old_one;
new_one.item_id = "new-one".into();
let mut new_two = old_two;
new_two.item_id = "new-two".into();
save_essential_tracks(
&mut c,
RemoteSource::Jellyfin,
generation,
&[new_one, new_two],
)
.unwrap();
assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 4);
assert_eq!(
complete_source_sync(&mut c, RemoteSource::Jellyfin, generation, true).unwrap(),
2
);
assert_eq!(count(&c, RemoteSource::Jellyfin).unwrap(), 2);
assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "old-one")
.unwrap()
.is_none());
assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "old-two")
.unwrap()
.is_none());
assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "new-one")
.unwrap()
.is_some());
assert!(track_by_item_id(&c, RemoteSource::Jellyfin, "new-two")
.unwrap()
.is_some());
}
#[test] #[test]
fn duplicate_item_across_essential_pages_rolls_back_the_second_page() { fn duplicate_item_across_essential_pages_rolls_back_the_second_page() {
let mut c = db(); let mut c = db();
@@ -296,10 +296,17 @@ Item {
QbzBridge.settingsString("library-scan", "") QbzBridge.settingsString("library-scan", "")
if ((action === "plex" || action === "all") && QbzLocal.plexAvailable) if ((action === "plex" || action === "all") && QbzLocal.plexAvailable)
QbzLocal.syncPlex() QbzLocal.syncPlex()
// A button labelled Resync is an authoritative reconciliation,
// not a delta. Jellyfin's DateLastSaved delta cannot report rows
// whose provider ids disappeared (for example after a library
// rebuild), so using it here retained the old ids beside the new
// ones and rendered one physical copy as two versions. Automatic
// background catch-up may still use deltas; an explicit repair
// must observe the complete source before it authorizes pruning.
if ((action === "jellyfin" || action === "all") && QbzLocal.mediaHasJellyfin) if ((action === "jellyfin" || action === "all") && QbzLocal.mediaHasJellyfin)
QbzLocal.mediaSync("jellyfin", false) QbzLocal.mediaSync("jellyfin", true)
if ((action === "subsonic" || action === "all") && QbzLocal.mediaHasSubsonic) if ((action === "subsonic" || action === "all") && QbzLocal.mediaHasSubsonic)
QbzLocal.mediaSync("subsonic", false) QbzLocal.mediaSync("subsonic", true)
} }
} }
+12
View File
@@ -560,4 +560,16 @@ mod phase_a_tests {
assert!(view_qml.contains("readonly property var tracksVisible: tracks")); assert!(view_qml.contains("readonly property var tracksVisible: tracks"));
assert!(!view_qml.contains("readonly property var tracksVisible: {")); assert!(!view_qml.contains("readonly property var tracksVisible: {"));
} }
#[test]
fn explicit_remote_resync_is_authoritative() {
// Jellyfin deltas cannot enumerate deleted/reminted provider ids. The
// Local Library menu says "Resync", so both its direct rows and the
// aggregate action must request a full observation before pruning.
let chrome = include_str!("../qml/views/local/LocalChrome.qml");
assert!(chrome.contains("QbzLocal.mediaSync(\"jellyfin\", true)"));
assert!(chrome.contains("QbzLocal.mediaSync(\"subsonic\", true)"));
assert!(!chrome.contains("QbzLocal.mediaSync(\"jellyfin\", false)"));
assert!(!chrome.contains("QbzLocal.mediaSync(\"subsonic\", false)"));
}
} }