From 5e4d7ca033aa6d16793cb1ce4156fa7cdc43ee2c Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Sat, 22 Aug 2026 20:58:46 -0600
Subject: [PATCH] fix(queue): preserve metadata and polish extended view
cx
---
.../translations/de/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/en/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/es/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/fr/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/ja/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/nl/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/pt/LC_MESSAGES/qbz-ui.po | 3 +
.../translations/ru/LC_MESSAGES/qbz-ui.po | 3 +
.../assets/icons/accent/panel-right-open.svg | 5 +
.../assets/icons/black/panel-right-open.svg | 5 +
.../icons/favorite/panel-right-open.svg | 5 +
.../assets/icons/muted/panel-right-open.svg | 5 +
.../assets/icons/primary/panel-right-open.svg | 5 +
.../icons/secondary/panel-right-open.svg | 5 +
.../assets/icons/warning/panel-right-open.svg | 5 +
crates/qbz-qt/qml/controls/QbzIconButton.qml | 20 ++-
crates/qbz-qt/qml/shell/QueuePanel.qml | 11 +-
crates/qbz-qt/qml/views/QueueView.qml | 46 ++++-
crates/qbz-qt/src/icon_tint_qt.rs | 1 +
crates/qconnect-app/src/renderer.rs | 157 +++++++++++++++++-
20 files changed, 280 insertions(+), 14 deletions(-)
create mode 100644 crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg
create mode 100644 crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg
diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po
index bcf48c55f..4c2679e2b 100644
--- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po
@@ -5680,3 +5680,6 @@ msgstr "Kein Qobuz-Konto? Du kannst QBZ trotzdem als Player für deine eigene Mu
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Du nutzt QBZ ohne Qobuz-Konto. Deine lokale Bibliothek und Downloads funktionieren weiterhin."
+
+msgid "Continuous playback"
+msgstr "Kontinuierliche Wiedergabe"
diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po
index b3308faa9..d4da25334 100644
--- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po
@@ -1204,3 +1204,6 @@ msgstr "Filter tracks"
msgid "No track matches that."
msgstr "No track matches that."
+
+msgid "Continuous playback"
+msgstr "Continuous playback"
diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po
index 63211ce64..afcf13ac7 100644
--- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po
@@ -5686,3 +5686,6 @@ msgstr "¿No tienes cuenta de Qobuz? Aun así puedes usar QBZ como reproductor d
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Estás usando QBZ sin cuenta de Qobuz. Tu biblioteca local y tus descargas siguen funcionando."
+
+msgid "Continuous playback"
+msgstr "Reproducción continua"
diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po
index e803da037..c2ed96059 100644
--- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po
@@ -5678,3 +5678,6 @@ msgstr "Pas de compte Qobuz ? Vous pouvez tout de même utiliser QBZ comme lecte
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Vous utilisez QBZ sans compte Qobuz. Votre bibliothèque locale et vos téléchargements continuent de fonctionner."
+
+msgid "Continuous playback"
+msgstr "Lecture continue"
diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po
index b341d9faa..af3180b34 100644
--- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po
@@ -5663,3 +5663,6 @@ msgstr "Qobuz アカウントがなくても、QBZ を自分の音楽ライブ
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Qobuz アカウントなしで QBZ を使っています。ローカルライブラリとダウンロードはそのまま使えます。"
+
+msgid "Continuous playback"
+msgstr "連続再生"
diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po
index 04e1e1e1d..f376a121a 100644
--- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po
@@ -5668,3 +5668,6 @@ msgstr "Geen Qobuz-account? Je kunt QBZ nog steeds gebruiken als speler voor je
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Je gebruikt QBZ zonder Qobuz-account. Je lokale bibliotheek en downloads blijven werken."
+
+msgid "Continuous playback"
+msgstr "Doorlopend afspelen"
diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po
index 554b0800d..c8766722f 100644
--- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po
@@ -5678,3 +5678,6 @@ msgstr "Sem conta Qobuz? Você ainda pode usar o QBZ como reprodutor da sua pró
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Você está usando o QBZ sem conta Qobuz. Sua biblioteca local e seus downloads continuam funcionando."
+
+msgid "Continuous playback"
+msgstr "Reprodução contínua"
diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po
index adefd59a5..24a94462e 100644
--- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po
+++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po
@@ -5697,3 +5697,6 @@ msgstr "Нет аккаунта Qobuz? QBZ всё равно можно испо
msgid "You're using QBZ without a Qobuz account. Your local library and downloads keep working."
msgstr "Вы используете QBZ без аккаунта Qobuz. Локальная библиотека и загрузки продолжают работать."
+
+msgid "Continuous playback"
+msgstr "Непрерывное воспроизведение"
diff --git a/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg
new file mode 100644
index 000000000..e65ada05d
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/accent/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg
new file mode 100644
index 000000000..01701ac77
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/black/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg
new file mode 100644
index 000000000..64cc0c58b
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/favorite/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg
new file mode 100644
index 000000000..0cba9f0ad
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/muted/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg
new file mode 100644
index 000000000..0fe7bd153
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/primary/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg
new file mode 100644
index 000000000..a2659d1ef
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/secondary/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg b/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg
new file mode 100644
index 000000000..138685b00
--- /dev/null
+++ b/crates/qbz-qt/qml/assets/icons/warning/panel-right-open.svg
@@ -0,0 +1,5 @@
+
diff --git a/crates/qbz-qt/qml/controls/QbzIconButton.qml b/crates/qbz-qt/qml/controls/QbzIconButton.qml
index 517259c25..5ccf38ca7 100644
--- a/crates/qbz-qt/qml/controls/QbzIconButton.qml
+++ b/crates/qbz-qt/qml/controls/QbzIconButton.qml
@@ -32,6 +32,13 @@ Rectangle {
property bool active: false
property bool activeBackground: false
property bool btnEnabled: true
+ /// Optional host for the shared QbzTooltip mechanism. Kept opt-in so the
+ /// many icon-only call sites that intentionally have no bubble stay inert.
+ property var tooltip: null
+ property string tooltipKey: ""
+ property string tooltipText: ""
+ readonly property string resolvedTooltipKey: tooltipKey !== ""
+ ? tooltipKey : "icon-" + name
signal clicked()
QbzTheme { id: theme }
@@ -62,6 +69,17 @@ Rectangle {
enabled: parent.btnEnabled
hoverEnabled: true
cursorShape: parent.btnEnabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: if (parent.btnEnabled) parent.clicked()
+ onContainsMouseChanged: {
+ if (btn.tooltip && btn.tooltipText !== "")
+ btn.tooltip.hover(containsMouse, btn, btn.resolvedTooltipKey,
+ btn.tooltipText)
+ }
+ onClicked: {
+ if (!parent.btnEnabled)
+ return
+ if (btn.tooltip)
+ btn.tooltip.hide(btn.resolvedTooltipKey)
+ parent.clicked()
+ }
}
}
diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml
index 026f0435d..68d6b020f 100644
--- a/crates/qbz-qt/qml/shell/QueuePanel.qml
+++ b/crates/qbz-qt/qml/shell/QueuePanel.qml
@@ -772,7 +772,10 @@ Rectangle {
QbzIconButton {
btnSize: 28
iconSize: 15
- name: "maximize-2"
+ name: "panel-right-open"
+ tooltip: tips
+ tooltipKey: "queue-view-open"
+ tooltipText: QbzSession.tr("Queue View", QbzSession.trRev)
onClicked: QbzShell.navigateTo("queue-view")
}
QueueTab {
@@ -1528,4 +1531,10 @@ Rectangle {
}
}
}
+
+ QbzTooltip {
+ id: tips
+ anchors.fill: parent
+ z: 4000
+ }
}
diff --git a/crates/qbz-qt/qml/views/QueueView.qml b/crates/qbz-qt/qml/views/QueueView.qml
index 1d7ef0437..71e8d534d 100644
--- a/crates/qbz-qt/qml/views/QueueView.qml
+++ b/crates/qbz-qt/qml/views/QueueView.qml
@@ -273,6 +273,8 @@ Rectangle {
Rectangle {
width: parent.width
height: 54
+ topLeftRadius: theme.radiusMd
+ topRightRadius: theme.radiusMd
color: theme.ambientOn ? theme.surfaceMainA30 : theme.surfaceMain
Row {
@@ -283,7 +285,10 @@ Rectangle {
QbzIconButton {
btnSize: 32
- name: "chevron-left"
+ name: "panel-right-close"
+ tooltip: tips
+ tooltipKey: "queue-view-close"
+ tooltipText: QbzSession.tr("Back", QbzSession.trRev)
onClicked: QbzShell.navigateBack()
}
Text {
@@ -310,8 +315,8 @@ Rectangle {
spacing: theme.spacingXs
QbzLineEdit {
- width: Math.max(130, Math.min(240, root.width
- - (root.width >= 720 ? 330 : 210)))
+ width: Math.max(110, Math.min(220, root.width
+ - (root.width >= 720 ? 490 : 390)))
searchMode: true
text: root.doc.searchQuery || ""
placeholder: QbzSession.tr("Search queue", QbzSession.trRev)
@@ -321,6 +326,9 @@ Rectangle {
btnSize: 32
name: "trash-list"
btnEnabled: root.rows.length > 0
+ tooltip: tips
+ tooltipKey: "queue-view-clear"
+ tooltipText: QbzSession.tr("Clear", QbzSession.trRev)
onClicked: QbzQueue.queueClear()
}
QbzIconButton {
@@ -328,19 +336,35 @@ Rectangle {
name: "add-to-list"
btnEnabled: root.rows.length > 0
&& !(root.currentRow && root.currentRow.isEphemeral === true)
+ tooltip: tips
+ tooltipKey: "queue-view-save"
+ tooltipText: QbzSession.tr("Add to Playlist", QbzSession.trRev)
onClicked: QbzQueue.queueSaveAsPlaylist()
}
- QbzIconButton {
- btnSize: 32
- name: "infinity"
- active: root.doc.infinitePlay === true
- onClicked: QbzQueue.queueToggleInfinitePlay()
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 6
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: QbzSession.tr("Continuous playback", QbzSession.trRev)
+ color: theme.textSecondary
+ font.pixelSize: 11
+ }
+ QbzToggle {
+ anchors.verticalCenter: parent.verticalCenter
+ checked: root.doc.infinitePlay === true
+ onToggled: QbzQueue.queueToggleInfinitePlay()
+ }
}
QbzIconButton {
id: sleepButton
btnSize: 32
name: "clock"
active: QbzQueue.sleepActive
+ tooltip: tips
+ tooltipKey: "queue-view-sleep"
+ tooltipText: QbzSession.tr("Set Timer", QbzSession.trRev)
onClicked: sleepMenu.openAtCursor(sleepButton, 0, sleepButton.height)
CardMenu {
@@ -564,4 +588,10 @@ Rectangle {
}
}
}
+
+ QbzTooltip {
+ id: tips
+ anchors.fill: parent
+ z: 4000
+ }
}
diff --git a/crates/qbz-qt/src/icon_tint_qt.rs b/crates/qbz-qt/src/icon_tint_qt.rs
index 3d127c2dc..a6d68e5ce 100644
--- a/crates/qbz-qt/src/icon_tint_qt.rs
+++ b/crates/qbz-qt/src/icon_tint_qt.rs
@@ -250,6 +250,7 @@ const MASTERS: &[(&str, &str)] = &[
("network", include_str!("../qml/assets/icons/primary/network.svg")),
("panel-left", include_str!("../qml/assets/icons/primary/panel-left.svg")),
("panel-right-close", include_str!("../qml/assets/icons/primary/panel-right-close.svg")),
+ ("panel-right-open", include_str!("../qml/assets/icons/primary/panel-right-open.svg")),
("pause", include_str!("../qml/assets/icons/primary/pause.svg")),
("pen-line", include_str!("../qml/assets/icons/primary/pen-line.svg")),
("picture-in-picture-2", include_str!("../qml/assets/icons/primary/picture-in-picture-2.svg")),
diff --git a/crates/qconnect-app/src/renderer.rs b/crates/qconnect-app/src/renderer.rs
index 7390e802a..094d29567 100644
--- a/crates/qconnect-app/src/renderer.rs
+++ b/crates/qconnect-app/src/renderer.rs
@@ -106,6 +106,72 @@ pub fn model_track_to_core_queue_track(track: &Track) -> QueueTrack {
}
}
+/// Keep intrinsic metadata that was already present in the local queue when a
+/// QConnect cloud echo materializes the same catalog track again.
+///
+/// The queue wire carries ids only. Hydrating those ids through the batch
+/// track endpoint gives us enough to play, but that endpoint can omit the
+/// track `version` and cannot express the album release `version` at all
+/// (`Track::album` is only an `AlbumSummary`). Replacing an album-built queue
+/// with that terse projection therefore stripped edition suffixes from every
+/// downstream consumer: queue rows, Now Playing, MPRIS, lyrics and scrobbling.
+///
+/// Only missing intrinsic fields are filled, and only from a non-local track
+/// with the same catalog id. The remote source tag, availability answer and
+/// context remain authoritative; in particular, an unrelated local-library id
+/// collision can never leak file metadata into a Qobuz queue.
+fn preserve_existing_catalog_metadata(remote: &mut QueueTrack, existing: &QueueTrack) {
+ if remote.id != existing.id || existing.is_local {
+ return;
+ }
+
+ let missing = |value: &Option| {
+ value
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .is_none()
+ };
+ if missing(&remote.version) && !missing(&existing.version) {
+ remote.version = existing.version.clone();
+ }
+ if missing(&remote.album_version) && !missing(&existing.album_version) {
+ remote.album_version = existing.album_version.clone();
+ }
+ if remote.title.trim().is_empty() && !existing.title.trim().is_empty() {
+ remote.title = existing.title.clone();
+ }
+ if (remote.artist.trim().is_empty() || remote.artist == "Unknown Artist")
+ && !existing.artist.trim().is_empty()
+ {
+ remote.artist = existing.artist.clone();
+ }
+ if (remote.album.trim().is_empty() || remote.album == "Unknown Album")
+ && !existing.album.trim().is_empty()
+ {
+ remote.album = existing.album.clone();
+ }
+ if missing(&remote.artwork_url) && !missing(&existing.artwork_url) {
+ remote.artwork_url = existing.artwork_url.clone();
+ }
+ if missing(&remote.album_id) && !missing(&existing.album_id) {
+ remote.album_id = existing.album_id.clone();
+ }
+ if remote.artist_id.is_none() {
+ remote.artist_id = existing.artist_id;
+ }
+ if remote.duration_secs == 0 {
+ remote.duration_secs = existing.duration_secs;
+ }
+ if remote.bit_depth.is_none() {
+ remote.bit_depth = existing.bit_depth;
+ }
+ if remote.sample_rate.is_none() {
+ remote.sample_rate = existing.sample_rate;
+ }
+ remote.hires |= existing.hires;
+}
+
// ===================== Renderer orchestration (slice 6, step 6) =====================
//
// Engine-agnostic: written ONLY against `QconnectRendererEngine` + the shared
@@ -645,6 +711,18 @@ pub async fn materialize_remote_queue(
return Ok(());
}
+ // Preserve the richer QueueTrack built by album/playlist entry points
+ // before the id-only cloud echo replaces the core queue. Per-id enrichment
+ // is safe across reorder/insert operations because every copied field is
+ // intrinsic catalog metadata; queue context and the remote source word are
+ // deliberately not copied.
+ let (existing_tracks, _) = engine.get_all_queue_tracks().await;
+ let existing_by_id: HashMap = existing_tracks
+ .into_iter()
+ .filter(|track| !track.is_local)
+ .map(|track| (track.id, track))
+ .collect();
+
let unique_track_ids = dedupe_track_ids(queue_state);
let fetched_tracks = engine
.get_tracks_batch(&unique_track_ids)
@@ -653,7 +731,11 @@ pub async fn materialize_remote_queue(
let mut tracks_by_id = HashMap::with_capacity(fetched_tracks.len());
for track in fetched_tracks {
- tracks_by_id.insert(track.id, model_track_to_core_queue_track(&track));
+ let mut mapped = model_track_to_core_queue_track(&track);
+ if let Some(existing) = existing_by_id.get(&track.id) {
+ preserve_existing_catalog_metadata(&mut mapped, existing);
+ }
+ tracks_by_id.insert(track.id, mapped);
}
let mut queue_tracks = Vec::with_capacity(queue_state.queue_items.len());
@@ -665,7 +747,10 @@ pub async fn materialize_remote_queue(
match engine.get_track(item.track_id).await {
Ok(track) => {
- let mapped = model_track_to_core_queue_track(&track);
+ let mut mapped = model_track_to_core_queue_track(&track);
+ if let Some(existing) = existing_by_id.get(&track.id) {
+ preserve_existing_catalog_metadata(&mut mapped, existing);
+ }
tracks_by_id.insert(item.track_id, mapped.clone());
queue_tracks.push(mapped);
}
@@ -853,6 +938,7 @@ mod tests {
set_shuffles: Vec,
set_shuffle_flags: Vec,
set_queue_with_order: Vec<(bool, Option>)>,
+ materialized_tracks: Vec,
set_queues: u32,
clear_queues: Vec,
play_indexes: Vec,
@@ -931,12 +1017,14 @@ mod tests {
}
async fn set_queue_with_order(
&self,
- _tracks: Vec,
+ tracks: Vec,
_start_index: Option,
shuffle_enabled: bool,
shuffle_order: Option>,
) {
- self.calls()
+ let mut calls = self.calls();
+ calls.materialized_tracks = tracks;
+ calls
.set_queue_with_order
.push((shuffle_enabled, shuffle_order));
}
@@ -1011,6 +1099,67 @@ mod tests {
Arc::new(Mutex::new(QconnectRemoteSyncState::default()))
}
+ /// A queue created from `/album/get` carries both edition suffixes. The
+ /// id-only QConnect echo must not replace them with the terse batch-track
+ /// response, because every metadata consumer reads the materialized core
+ /// queue after this point.
+ #[tokio::test]
+ async fn materialize_preserves_rich_metadata_from_matching_local_queue() {
+ let mut engine = MockEngine::new();
+ let mut rich = mock_queue_track(7);
+ rich.title = "What Is Life".to_string();
+ rich.version = Some("Backing Track / Bonus Track".to_string());
+ rich.artist = "George Harrison".to_string();
+ rich.album = "All Things Must Pass".to_string();
+ rich.album_version = Some("Remastered 2014".to_string());
+ rich.artwork_url = Some("https://example.test/cover.jpg".to_string());
+ rich.album_id = Some("album-7".to_string());
+ rich.artist_id = Some(42);
+ rich.bit_depth = Some(24);
+ rich.sample_rate = Some(96_000.0);
+ rich.hires = true;
+ rich.source = Some("qobuz".to_string());
+ rich.context_kind = Some("album".to_string());
+ rich.context_id = Some("album-7".to_string());
+ engine.queue_tracks = vec![rich];
+ engine.queue_index = Some(0);
+
+ materialize_remote_queue(
+ &engine,
+ &sync(),
+ &queue_state(QueueVersion::new(1, 0), vec![qi(7, 0)], false, None),
+ )
+ .await
+ .unwrap();
+
+ let calls = engine.calls();
+ let track = calls
+ .materialized_tracks
+ .first()
+ .expect("materialized track");
+ assert_eq!(
+ track.version.as_deref(),
+ Some("Backing Track / Bonus Track")
+ );
+ assert_eq!(track.album_version.as_deref(), Some("Remastered 2014"));
+ assert_eq!(track.artist, "George Harrison");
+ assert_eq!(track.album, "All Things Must Pass");
+ assert_eq!(track.album_id.as_deref(), Some("album-7"));
+ assert_eq!(track.artist_id, Some(42));
+ assert_eq!(track.bit_depth, Some(24));
+ assert_eq!(track.sample_rate, Some(96_000.0));
+ assert!(track.hires);
+ assert_eq!(track.source.as_deref(), Some(QCONNECT_REMOTE_QUEUE_SOURCE));
+ assert!(
+ track.context_kind.is_none(),
+ "remote context remains authoritative"
+ );
+ assert!(
+ track.context_id.is_none(),
+ "remote context remains authoritative"
+ );
+ }
+
/// #2 — two loads for the same track within the dedup window trigger exactly
/// one `start_track_stream`; the second is swallowed by the 5s window even
/// though the audio thread hasn't reported the track yet.