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:
3 files changed
+74
-2
No files matched your search
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user