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.