From 0fdb539044809b51b1d665c4e4792d6f76ca86c9 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:17:32 -0600 Subject: [PATCH 01/12] refactor(v2): migrate has_flatpak_welcome_been_shown to v2 Add v2_has_flatpak_welcome_been_shown as a thin wrapper over the existing UpdatesStore::has_flatpak_welcome_been_shown(). Update the frontend invoke in updatesService.ts to use the v2 name. Legacy command remains for now. Audit: legacy_callsites 16 -> 15. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 7 +++++++ src-tauri/src/lib.rs | 1 + src/lib/services/updatesService.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index 6835233a5..29fc5bf79 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1382,6 +1382,13 @@ pub fn v2_mark_flatpak_welcome_shown( crate::updates::mark_flatpak_welcome_shown(state) } +#[tauri::command] +pub fn v2_has_flatpak_welcome_been_shown( + state: State<'_, crate::updates::UpdatesState>, +) -> Result { + crate::updates::has_flatpak_welcome_been_shown(state) +} + #[tauri::command] pub fn v2_get_backend_logs() -> Vec { crate::logging::get_backend_logs() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3f5b59660..fdca5d3c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1636,6 +1636,7 @@ pub fn run() { commands_v2::v2_has_whats_new_been_shown, commands_v2::v2_mark_whats_new_shown, commands_v2::v2_mark_flatpak_welcome_shown, + commands_v2::v2_has_flatpak_welcome_been_shown, commands_v2::v2_is_auto_update_eligible, commands_v2::v2_get_backend_logs, commands_v2::v2_upload_logs_to_paste, diff --git a/src/lib/services/updatesService.ts b/src/lib/services/updatesService.ts index 4f6797d83..1289a0fc9 100644 --- a/src/lib/services/updatesService.ts +++ b/src/lib/services/updatesService.ts @@ -158,7 +158,7 @@ async function shouldShowFlatpakWelcome(): Promise { try { const isFlatpak = await invoke('v2_is_running_in_flatpak'); if (!isFlatpak) return false; - const alreadyShown = await invoke('has_flatpak_welcome_been_shown'); + const alreadyShown = await invoke('v2_has_flatpak_welcome_been_shown'); return !alreadyShown; } catch { return false; From 120e3fb827ce57eeb6a8c6f2a6b12b3ca52039cf Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:18:54 -0600 Subject: [PATCH 02/12] refactor(v2): migrate has_snap_welcome_been_shown to v2 Add v2_has_snap_welcome_been_shown as a thin wrapper over the existing UpdatesStore::has_snap_welcome_been_shown(). Update the frontend invoke in updatesService.ts to use the v2 name. Legacy command remains for now. Audit: legacy_callsites 15 -> 14. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 7 +++++++ src-tauri/src/lib.rs | 1 + src/lib/services/updatesService.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index 29fc5bf79..aa4a84ce2 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1493,6 +1493,13 @@ pub fn v2_mark_snap_welcome_shown( crate::updates::mark_snap_welcome_shown(state) } +#[tauri::command] +pub fn v2_has_snap_welcome_been_shown( + state: State<'_, crate::updates::UpdatesState>, +) -> Result { + crate::updates::has_snap_welcome_been_shown(state) +} + #[tauri::command] pub async fn v2_detect_legacy_cached_files( cache_state: State<'_, OfflineCacheState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fdca5d3c3..b37cb0b42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1579,6 +1579,7 @@ pub fn run() { commands_v2::v2_is_running_in_flatpak, commands_v2::v2_is_running_in_snap, commands_v2::v2_mark_snap_welcome_shown, + commands_v2::v2_has_snap_welcome_been_shown, commands_v2::v2_detect_legacy_cached_files, commands_v2::v2_reco_log_event, commands_v2::v2_reco_train_scores, diff --git a/src/lib/services/updatesService.ts b/src/lib/services/updatesService.ts index 1289a0fc9..8f14d95f3 100644 --- a/src/lib/services/updatesService.ts +++ b/src/lib/services/updatesService.ts @@ -183,7 +183,7 @@ async function shouldShowSnapWelcome(): Promise { try { const isSnap = await invoke('v2_is_running_in_snap'); if (!isSnap) return false; - const alreadyShown = await invoke('has_snap_welcome_been_shown'); + const alreadyShown = await invoke('v2_has_snap_welcome_been_shown'); return !alreadyShown; } catch { return false; From 57c5c56bcb1f7e48b7c551f21cc6c445fa8305d2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:20:11 -0600 Subject: [PATCH 03/12] refactor(v2): migrate get_qobuz_tos_accepted to v2 Add v2_get_qobuz_tos_accepted as a thin wrapper over the existing config::legal_settings::get_qobuz_tos_accepted. Update the frontend invoke in qobuzLegalStore.ts to use the v2 name. Legacy command remains for now. Audit: legacy_callsites 14 -> 13. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 7 +++++++ src-tauri/src/lib.rs | 1 + src/lib/stores/qobuzLegalStore.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index aa4a84ce2..edaf7c3a6 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1299,6 +1299,13 @@ pub fn v2_set_qobuz_tos_accepted( crate::config::legal_settings::set_qobuz_tos_accepted(state, accepted) } +#[tauri::command] +pub fn v2_get_qobuz_tos_accepted( + state: State<'_, crate::config::legal_settings::LegalSettingsState>, +) -> Result { + crate::config::legal_settings::get_qobuz_tos_accepted(state) +} + #[tauri::command] pub fn v2_set_update_check_on_launch( enabled: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b37cb0b42..2d91af22e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1626,6 +1626,7 @@ pub fn run() { commands_v2::v2_library_refresh_album_metadata_from_files, commands_v2::v2_factory_reset, commands_v2::v2_set_qobuz_tos_accepted, + commands_v2::v2_get_qobuz_tos_accepted, commands_v2::v2_get_update_preferences, commands_v2::v2_get_current_version, commands_v2::v2_check_for_updates, diff --git a/src/lib/stores/qobuzLegalStore.ts b/src/lib/stores/qobuzLegalStore.ts index 4fdcfd6f0..f526360e0 100644 --- a/src/lib/stores/qobuzLegalStore.ts +++ b/src/lib/stores/qobuzLegalStore.ts @@ -18,7 +18,7 @@ export async function loadTosAcceptance(): Promise { if (!browser) return false; try { - const accepted = await invoke('get_qobuz_tos_accepted'); + const accepted = await invoke('v2_get_qobuz_tos_accepted'); qobuzTosAccepted.set(accepted); initialized = true; return accepted; From a393d9821899b097d2003bf1c9456942053f16bc Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:21:34 -0600 Subject: [PATCH 04/12] refactor(v2): migrate detect_desktop_theme to v2 Add v2_detect_desktop_theme as a thin wrapper over the existing desktop_theme::detect_desktop_theme(). Update the frontend invoke in windowControlsStore.ts to use the v2 name. Legacy command remains for now. Audit: legacy_callsites 13 -> 12. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 5 +++++ src-tauri/src/lib.rs | 1 + src/lib/stores/windowControlsStore.ts | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index edaf7c3a6..c8361355d 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1483,6 +1483,11 @@ pub fn v2_is_running_in_flatpak() -> bool { crate::flatpak::is_running_in_flatpak() } +#[tauri::command] +pub fn v2_detect_desktop_theme() -> crate::desktop_theme::DesktopThemeInfo { + crate::desktop_theme::detect_desktop_theme() +} + #[tauri::command] pub fn v2_is_auto_update_eligible() -> bool { crate::updates::is_auto_update_eligible() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2d91af22e..c14dffb66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1871,6 +1871,7 @@ pub fn run() { commands_v2::v2_clear_image_cache, // Desktop theme detection (KDE/Klassy → adaptive window controls) desktop_theme::detect_desktop_theme, + commands_v2::v2_detect_desktop_theme, // Mixtapes & Collections commands_v2::v2_list_mixtape_collections, commands_v2::v2_get_mixtape_collection, diff --git a/src/lib/stores/windowControlsStore.ts b/src/lib/stores/windowControlsStore.ts index 2558c996c..3871a3ddf 100644 --- a/src/lib/stores/windowControlsStore.ts +++ b/src/lib/stores/windowControlsStore.ts @@ -229,7 +229,7 @@ export async function detectDesktopThemeCached(force = false): Promise { try { const { invoke } = await import('@tauri-apps/api/core'); - const info = (await invoke('detect_desktop_theme')) as DesktopThemeInfo; + const info = (await invoke('v2_detect_desktop_theme')) as DesktopThemeInfo; cachedTheme = info; return info; } catch (e) { From 8ef6f9f02c2e89ee2a9a92cc462258325e5327be Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:23:40 -0600 Subject: [PATCH 05/12] refactor(v2): migrate get_flatpak_help_text to v2 Add v2_get_flatpak_help_text as a thin wrapper over the existing flatpak::get_flatpak_guidance(). Update the frontend invoke in SettingsView.svelte to use the v2 name. Legacy command remains for now. Audit: legacy_callsites 12 -> 11. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 5 +++++ src-tauri/src/lib.rs | 1 + src/lib/components/views/SettingsView.svelte | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index c8361355d..8e7a9f1ce 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1483,6 +1483,11 @@ pub fn v2_is_running_in_flatpak() -> bool { crate::flatpak::is_running_in_flatpak() } +#[tauri::command] +pub fn v2_get_flatpak_help_text() -> String { + crate::flatpak::get_flatpak_guidance() +} + #[tauri::command] pub fn v2_detect_desktop_theme() -> crate::desktop_theme::DesktopThemeInfo { crate::desktop_theme::detect_desktop_theme() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c14dffb66..4b9bebd4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1398,6 +1398,7 @@ pub fn run() { commands::playback::get_pipewire_sinks, commands::playback::get_audio_output_status, flatpak::get_flatpak_help_text, + commands_v2::v2_get_flatpak_help_text, config::legal_settings::get_qobuz_tos_accepted, updates::has_flatpak_welcome_been_shown, updates::has_snap_welcome_been_shown, diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 41ff61046..8f6409702 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -2787,7 +2787,7 @@ try { isFlatpak = await invoke('v2_is_running_in_flatpak'); if (isFlatpak) { - flatpakHelpText = await invoke('get_flatpak_help_text'); + flatpakHelpText = await invoke('v2_get_flatpak_help_text'); } } catch (err) { console.error('Failed to check Flatpak status:', err); From a127de0f3226230759acbedb3962a3a67ba956e7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:37:00 -0600 Subject: [PATCH 06/12] refactor(v2): migrate get_artist_basic to v2 Path A: existing v2_get_artist already returns Artist via QbzCore::get_artist which delegates to client.get_artist_basic; shape matches the legacy command exactly so the frontend just swaps the invoke name. cc --- src/lib/components/views/HomeView.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/views/HomeView.svelte b/src/lib/components/views/HomeView.svelte index 56df13b5d..ac7c3d899 100644 --- a/src/lib/components/views/HomeView.svelte +++ b/src/lib/components/views/HomeView.svelte @@ -599,7 +599,7 @@ for (let i = 0; i < uncachedSeeds.length; i += BATCH_SIZE) { const batch = uncachedSeeds.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( - batch.map(seed => invoke('get_artist_basic', { artistId: seed.artistId })) + batch.map(seed => invoke('v2_get_artist', { artistId: seed.artistId })) ); results.forEach((result, index) => { From 1dcb7765d338f45042f2899f1ec7663ec4b17c99 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:43:23 -0600 Subject: [PATCH 07/12] refactor(v2): migrate get_artist_albums to v2 Path C: legacy command projected `Artist.albums` from client.get_artist_with_pagination(with_albums=true). The existing v2_get_artist_with_albums returns the full Artist envelope, so add a dedicated v2_get_artist_albums in QbzCore that performs the same projection and exposes only ArtistAlbums to match what the two callsites destructure. cc --- crates/qbz-core/src/core.rs | 27 +++++++++++++- src-tauri/src/commands_v2/catalog.rs | 36 ++++++++++++++++--- src-tauri/src/core_bridge.rs | 19 ++++++++-- src-tauri/src/lib.rs | 1 + .../views/ArtistsByLocationView.svelte | 2 +- src/lib/components/views/FavoritesView.svelte | 2 +- 6 files changed, 77 insertions(+), 10 deletions(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 615de3e87..9f722541e 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use tokio::sync::RwLock; use qbz_models::{ - Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, + Album, Artist, ArtistAlbums, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, @@ -841,6 +841,31 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Get an artist's albums collection (paginated `ArtistAlbums` only). + /// + /// Equivalent to `get_artist_with_albums` but projects only the `albums` + /// field for callers that don't need the full artist envelope. + pub async fn get_artist_albums( + &self, + artist_id: u64, + limit: Option, + offset: Option, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + let artist = client + .get_artist_with_pagination(artist_id, true, limit, offset) + .await + .map_err(CoreError::Api)?; + + artist + .albums + .ok_or_else(|| CoreError::Api(qbz_qobuz::ApiError::ApiResponse( + "No albums in artist response".to_string(), + ))) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index 6510c35fd..17d3baf1a 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -1,10 +1,10 @@ use tauri::State; use qbz_models::{ - Album, Artist, AwardPageData, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, - DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, - LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, - SearchResultsPage, Track, + Album, Artist, ArtistAlbums, AwardPageData, DiscoverAlbum, DiscoverData, + DiscoverPlaylistsResponse, DiscoverResponse, GenreInfo, LabelExploreResponse, + LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, + Playlist, PlaylistTag, SearchResultsPage, Track, }; use crate::artist_blacklist::BlacklistState; @@ -399,6 +399,34 @@ pub async fn v2_get_artist_with_albums( .map_err(RuntimeError::Internal) } +/// Get an artist's albums collection (V2 - returns only the `ArtistAlbums` envelope). +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_artist_albums( + artistId: u64, + limit: Option, + offset: Option, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + log::info!( + "[V2] get_artist_albums: {} limit={:?} offset={:?}", + artistId, + limit, + offset + ); + let bridge = bridge.get().await; + bridge + .get_artist_albums(artistId, limit, offset) + .await + .map_err(RuntimeError::Internal) +} + /// Get label page (aggregated: top tracks, releases, playlists, artists) #[tauri::command] #[allow(non_snake_case)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 4fd9c8bf3..1bf7a78eb 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -13,9 +13,9 @@ use tokio::sync::RwLock; use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap}; use qbz_core::QbzCore; use qbz_models::{ - Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, - GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, - LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, + Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, + DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, + LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, }; use qbz_player::{PlaybackState, Player}; @@ -555,6 +555,19 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Get an artist's albums collection (paginated `ArtistAlbums` only). + pub async fn get_artist_albums( + &self, + artist_id: u64, + limit: Option, + offset: Option, + ) -> Result { + self.core + .get_artist_albums(artist_id, limit, offset) + .await + .map_err(|e| e.to_string()) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { self.core diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4b9bebd4b..d16b5885f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1703,6 +1703,7 @@ pub fn run() { commands_v2::v2_get_artist_page, commands_v2::v2_get_similar_artists, commands_v2::v2_get_artist_with_albums, + commands_v2::v2_get_artist_albums, commands_v2::v2_get_label_page, commands_v2::v2_get_label_explore, commands_v2::v2_get_label_albums, diff --git a/src/lib/components/views/ArtistsByLocationView.svelte b/src/lib/components/views/ArtistsByLocationView.svelte index dd59595f7..01c81b2fa 100644 --- a/src/lib/components/views/ArtistsByLocationView.svelte +++ b/src/lib/components/views/ArtistsByLocationView.svelte @@ -342,7 +342,7 @@ albumsError = null; try { - const result = await invoke<{ items: QobuzAlbum[] }>('get_artist_albums', { + const result = await invoke<{ items: QobuzAlbum[] }>('v2_get_artist_albums', { artistId: artist.id, limit: 500, offset: 0, diff --git a/src/lib/components/views/FavoritesView.svelte b/src/lib/components/views/FavoritesView.svelte index 8917fb666..9173ed96a 100644 --- a/src/lib/components/views/FavoritesView.svelte +++ b/src/lib/components/views/FavoritesView.svelte @@ -938,7 +938,7 @@ artistAlbumsError = null; try { - const result = await invoke<{ items: QobuzAlbum[]; total: number }>('get_artist_albums', { + const result = await invoke<{ items: QobuzAlbum[]; total: number }>('v2_get_artist_albums', { artistId: artist.id, limit: 500, // Fetch more to ensure we have enough Discography albums after filtering offset: 0 From 0bc033b3247cdc3c034e662d802aa210613c939f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:45:47 -0600 Subject: [PATCH 08/12] refactor(v2): migrate get_artist_detail to v2 Path C: legacy command called client.get_artist_detail with the hard-coded mobile defaults of limit=1000, offset=0 to populate the suggestions panel's playlists and tracks_appears_on. No close v2 equivalent existed (v2_get_artist_with_albums uses extra=albums only), so add v2_get_artist_detail that goes through QbzCore -> QobuzClient::get_artist_detail directly. cc --- crates/qbz-core/src/core.rs | 20 +++++++++++++ src-tauri/src/commands_v2/catalog.rs | 30 +++++++++++++++++++ src-tauri/src/core_bridge.rs | 13 ++++++++ src-tauri/src/lib.rs | 1 + .../immersive/panels/SuggestionsPanel.svelte | 2 +- 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 9f722541e..7e692f674 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -866,6 +866,26 @@ impl QbzCore { ))) } + /// Get artist detail with albums, playlists and appears-on tracks. + /// + /// Backs the suggestions panel: requests `extra=albums,tracks_appears_on,playlists` + /// from `/artist/get` so callers can read `playlists` and `tracks_appears_on` + /// without a second round-trip. + pub async fn get_artist_detail( + &self, + artist_id: u64, + limit: Option, + offset: Option, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_artist_detail(artist_id, limit, offset) + .await + .map_err(CoreError::Api) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index 17d3baf1a..f33375879 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -427,6 +427,36 @@ pub async fn v2_get_artist_albums( .map_err(RuntimeError::Internal) } +/// Get artist detail with albums, playlists and appears-on tracks (V2 - uses QbzCore). +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_artist_detail( + artistId: u64, + limit: Option, + offset: Option, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + let limit = limit.or(Some(1000)); + let offset = offset.or(Some(0)); + log::info!( + "[V2] get_artist_detail: {} limit={:?} offset={:?}", + artistId, + limit, + offset + ); + let bridge = bridge.get().await; + bridge + .get_artist_detail(artistId, limit, offset) + .await + .map_err(RuntimeError::Internal) +} + /// Get label page (aggregated: top tracks, releases, playlists, artists) #[tauri::command] #[allow(non_snake_case)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 1bf7a78eb..66158957b 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -568,6 +568,19 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Get artist detail (albums, playlists, appears-on tracks). + pub async fn get_artist_detail( + &self, + artist_id: u64, + limit: Option, + offset: Option, + ) -> Result { + self.core + .get_artist_detail(artist_id, limit, offset) + .await + .map_err(|e| e.to_string()) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { self.core diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d16b5885f..f2adc5f5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1704,6 +1704,7 @@ pub fn run() { commands_v2::v2_get_similar_artists, commands_v2::v2_get_artist_with_albums, commands_v2::v2_get_artist_albums, + commands_v2::v2_get_artist_detail, commands_v2::v2_get_label_page, commands_v2::v2_get_label_explore, commands_v2::v2_get_label_albums, diff --git a/src/lib/components/immersive/panels/SuggestionsPanel.svelte b/src/lib/components/immersive/panels/SuggestionsPanel.svelte index 70c70893c..82c472052 100644 --- a/src/lib/components/immersive/panels/SuggestionsPanel.svelte +++ b/src/lib/components/immersive/panels/SuggestionsPanel.svelte @@ -110,7 +110,7 @@ try { // Get artist detail which includes playlists and tracks - const artist = await invoke('get_artist_detail', { artistId: id }); + const artist = await invoke('v2_get_artist_detail', { artistId: id }); loadedArtistId = id; // Extract curated playlists (max 2) From 7d89ae77ef6478f39a7c29f902fe97930a3a284f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:48:29 -0600 Subject: [PATCH 09/12] refactor(v2): migrate get_artist_tracks to v2 Path C: legacy command called client.get_artist_tracks (the /artist/get?extra=tracks path) and returned a TracksContainer. No existing v2 equivalent, so add v2_get_artist_tracks via QbzCore -> QobuzClient::get_artist_tracks directly. cc --- crates/qbz-core/src/core.rs | 18 ++++++++++- src-tauri/src/commands_v2/catalog.rs | 32 ++++++++++++++++++- src-tauri/src/core_bridge.rs | 16 +++++++++- src-tauri/src/lib.rs | 1 + .../immersive/panels/SuggestionsPanel.svelte | 2 +- 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 7e692f674..49c674634 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -11,7 +11,7 @@ use qbz_models::{ DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, - StreamUrl, Track, UserSession, + StreamUrl, Track, TracksContainer, UserSession, }; use qbz_player::{PlaybackState, Player, QueueManager}; use qbz_qobuz::QobuzClient; @@ -886,6 +886,22 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Get an artist's popular/top tracks (`/artist/get?extra=tracks`). + pub async fn get_artist_tracks( + &self, + artist_id: u64, + limit: u32, + offset: u32, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_artist_tracks(artist_id, limit, offset) + .await + .map_err(CoreError::Api) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index f33375879..c3ef50a7c 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -4,7 +4,7 @@ use qbz_models::{ Album, Artist, ArtistAlbums, AwardPageData, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, - Playlist, PlaylistTag, SearchResultsPage, Track, + Playlist, PlaylistTag, SearchResultsPage, Track, TracksContainer, }; use crate::artist_blacklist::BlacklistState; @@ -457,6 +457,36 @@ pub async fn v2_get_artist_detail( .map_err(RuntimeError::Internal) } +/// Get an artist's popular/top tracks (V2 - uses QbzCore). +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_artist_tracks( + artistId: u64, + limit: Option, + offset: Option, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + log::info!( + "[V2] get_artist_tracks: {} limit={} offset={}", + artistId, + limit, + offset + ); + let bridge = bridge.get().await; + bridge + .get_artist_tracks(artistId, limit, offset) + .await + .map_err(RuntimeError::Internal) +} + /// Get label page (aggregated: top tracks, releases, playlists, artists) #[tauri::command] #[allow(non_snake_case)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 66158957b..3715ceec5 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -16,7 +16,8 @@ use qbz_models::{ Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, - QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, + QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, TracksContainer, + UserSession, }; use qbz_player::{PlaybackState, Player}; @@ -581,6 +582,19 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Get an artist's popular/top tracks. + pub async fn get_artist_tracks( + &self, + artist_id: u64, + limit: u32, + offset: u32, + ) -> Result { + self.core + .get_artist_tracks(artist_id, limit, offset) + .await + .map_err(|e| e.to_string()) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { self.core diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f2adc5f5b..8cdb2ae53 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1705,6 +1705,7 @@ pub fn run() { commands_v2::v2_get_artist_with_albums, commands_v2::v2_get_artist_albums, commands_v2::v2_get_artist_detail, + commands_v2::v2_get_artist_tracks, commands_v2::v2_get_label_page, commands_v2::v2_get_label_explore, commands_v2::v2_get_label_albums, diff --git a/src/lib/components/immersive/panels/SuggestionsPanel.svelte b/src/lib/components/immersive/panels/SuggestionsPanel.svelte index 82c472052..89ffd8657 100644 --- a/src/lib/components/immersive/panels/SuggestionsPanel.svelte +++ b/src/lib/components/immersive/panels/SuggestionsPanel.svelte @@ -133,7 +133,7 @@ if (tracks.length < 5) { console.log(`[Suggestions] Sparse tracks_appears_on (${tracks.length}), fetching artist tracks...`); try { - const artistTracks = await invoke('get_artist_tracks', { + const artistTracks = await invoke('v2_get_artist_tracks', { artistId: id, limit: 30, offset: 0 From eb17cc6d2281e4cd37c6c3f3bad52dff24505d6b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:51:33 -0600 Subject: [PATCH 10/12] refactor(v2): migrate get_releases_grid to v2 Path C: legacy command called client.get_releases_grid for the artist detail view's release-type pagination grid. No existing v2 equivalent, so add v2_get_releases_grid via QbzCore -> QobuzClient::get_releases_grid directly. cc --- crates/qbz-core/src/core.rs | 22 +++++++++++++++-- src-tauri/src/commands_v2/catalog.rs | 35 +++++++++++++++++++++++++++- src-tauri/src/core_bridge.rs | 19 +++++++++++++-- src-tauri/src/lib.rs | 1 + src/routes/+page.svelte | 4 ++-- 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 49c674634..71ac82fb7 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -10,8 +10,8 @@ use qbz_models::{ Album, Artist, ArtistAlbums, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, - Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, - StreamUrl, Track, TracksContainer, UserSession, + Playlist, PlaylistTag, Quality, QueueState, QueueTrack, ReleasesGridResponse, RepeatMode, + SearchResultsPage, StreamUrl, Track, TracksContainer, UserSession, }; use qbz_player::{PlaybackState, Player, QueueManager}; use qbz_qobuz::QobuzClient; @@ -902,6 +902,24 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Get an artist's releases grid (paginated by `release_type`). + pub async fn get_releases_grid( + &self, + artist_id: u64, + release_type: &str, + limit: u32, + offset: u32, + sort: Option<&str>, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .get_releases_grid(artist_id, release_type, limit, offset, sort) + .await + .map_err(CoreError::Api) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { let client = self.client.read().await; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index c3ef50a7c..eda2e4086 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -4,7 +4,7 @@ use qbz_models::{ Album, Artist, ArtistAlbums, AwardPageData, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, - Playlist, PlaylistTag, SearchResultsPage, Track, TracksContainer, + Playlist, PlaylistTag, ReleasesGridResponse, SearchResultsPage, Track, TracksContainer, }; use crate::artist_blacklist::BlacklistState; @@ -487,6 +487,39 @@ pub async fn v2_get_artist_tracks( .map_err(RuntimeError::Internal) } +/// Get an artist's releases grid paginated by release_type (V2 - uses QbzCore). +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_get_releases_grid( + artistId: u64, + releaseType: String, + limit: Option, + offset: Option, + sort: Option, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + let limit = limit.unwrap_or(25); + let offset = offset.unwrap_or(0); + log::info!( + "[V2] get_releases_grid: {} type={} limit={} offset={}", + artistId, + releaseType, + limit, + offset + ); + let bridge = bridge.get().await; + bridge + .get_releases_grid(artistId, &releaseType, limit, offset, sort.as_deref()) + .await + .map_err(RuntimeError::Internal) +} + /// Get label page (aggregated: top tracks, releases, playlists, artists) #[tauri::command] #[allow(non_snake_case)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 3715ceec5..c9a5ce637 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -16,8 +16,8 @@ use qbz_models::{ Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage, LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality, - QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, TracksContainer, - UserSession, + QueueState, QueueTrack, ReleasesGridResponse, RepeatMode, SearchResultsPage, StreamUrl, Track, + TracksContainer, UserSession, }; use qbz_player::{PlaybackState, Player}; @@ -595,6 +595,21 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Get an artist's releases grid (paginated by `release_type`). + pub async fn get_releases_grid( + &self, + artist_id: u64, + release_type: &str, + limit: u32, + offset: u32, + sort: Option<&str>, + ) -> Result { + self.core + .get_releases_grid(artist_id, release_type, limit, offset, sort) + .await + .map_err(|e| e.to_string()) + } + /// Get label page (aggregated: top tracks, releases, playlists, artists) pub async fn get_label_page(&self, label_id: u64) -> Result { self.core diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8cdb2ae53..fea0e4565 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1706,6 +1706,7 @@ pub fn run() { commands_v2::v2_get_artist_albums, commands_v2::v2_get_artist_detail, commands_v2::v2_get_artist_tracks, + commands_v2::v2_get_releases_grid, commands_v2::v2_get_label_page, commands_v2::v2_get_label_explore, commands_v2::v2_get_label_albums, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 144e2bc37..5b2835d88 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1857,7 +1857,7 @@ g => g.type === 'ep' || g.type === 'single' || g.type === 'epSingle' ); if (!hasEpGroup && selectedArtist.epsSingles.length === 0) { - invoke('get_releases_grid', { + invoke('v2_get_releases_grid', { artistId, releaseType: 'epSingle', limit: 25, offset: 0 }).then(result => { if (result.items.length > 0 && selectedArtist?.id === artistId) { @@ -2128,7 +2128,7 @@ isArtistAlbumsLoading = true; try { - const result = await invoke('get_releases_grid', { + const result = await invoke('v2_get_releases_grid', { artistId: selectedArtist.id, releaseType: apiReleaseType, limit: 25, From 1abb57bd7f19aee9491af8fb35a46cb4683c3515 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:00:15 -0600 Subject: [PATCH 11/12] refactor(v2): migrate get_audio_output_status to v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path B: legacy command read state.player.state.current_device() and state.player.state.is_playing() — both methods already exist on the qbz-player Player.state in the V2 architecture. No domain extraction needed; the v2 command reads them via CoreBridge.player() like the existing v2_get_hardware_audio_status does. Adds v2_get_audio_output_status returning V2AudioOutputStatus (serialized to the same {device_name, is_playing} shape so the AudioOutputBadges frontend type is unchanged). Updates both AudioOutputBadges call sites (initial loadStatus + the 1s polling interval). The legacy command in commands/playback.rs is preserved untouched. cc --- src-tauri/src/commands_v2/audio.rs | 25 +++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/AudioOutputBadges.svelte | 4 ++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands_v2/audio.rs b/src-tauri/src/commands_v2/audio.rs index 90b3d393c..ccf0221cb 100644 --- a/src-tauri/src/commands_v2/audio.rs +++ b/src-tauri/src/commands_v2/audio.rs @@ -18,6 +18,31 @@ pub enum AudioDevicePresence { Inconclusive, } +/// Snapshot of which output device is currently in use and whether we're playing. +/// Mirrors the legacy `AudioOutputStatus` shape so the frontend type is unchanged. +#[derive(Debug, serde::Serialize)] +pub struct V2AudioOutputStatus { + pub device_name: Option, + pub is_playing: bool, +} + +/// Get current audio output status (V2 — reads from CoreBridge.player state). +/// +/// Returns the device name actually in use by the audio thread plus whether +/// playback is currently active. Used by AudioOutputBadges to render the +/// active sink + DAC indicators in the now-playing bar. +#[tauri::command] +pub async fn v2_get_audio_output_status( + bridge: State<'_, CoreBridgeState>, +) -> Result { + let bridge_guard = bridge.get().await; + let player = bridge_guard.player(); + Ok(V2AudioOutputStatus { + device_name: player.state.current_device(), + is_playing: player.state.is_playing(), + }) +} + /// Snapshot the presence of the user's currently-selected output /// device. Frontend calls this on demand (e.g. when a /// `audio:device-missing` toast button fires Retry). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fea0e4565..bb645618f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1731,6 +1731,7 @@ pub fn run() { commands_v2::v2_prefetch_track, commands_v2::v2_reinit_audio_device, commands_v2::v2_check_audio_device_presence, + commands_v2::v2_get_audio_output_status, commands_v2::v2_get_audio_settings, commands_v2::v2_set_audio_output_device, commands_v2::v2_set_audio_exclusive_mode, diff --git a/src/lib/components/AudioOutputBadges.svelte b/src/lib/components/AudioOutputBadges.svelte index 89f0a4b5c..617956e6b 100644 --- a/src/lib/components/AudioOutputBadges.svelte +++ b/src/lib/components/AudioOutputBadges.svelte @@ -150,7 +150,7 @@ try { const [settingsResult, statusResult, sinksResult, alsaResult, hwStatus] = await Promise.all([ invoke('v2_get_audio_settings'), - invoke('get_audio_output_status'), + invoke('v2_get_audio_output_status'), invoke('get_pipewire_sinks').catch(() => [] as PipewireSink[]), invoke('v2_get_devices_for_backend', { backendType: 'Alsa' }).catch(() => [] as BackendAudioDevice[]), invoke('v2_get_hardware_audio_status').catch(() => null) @@ -215,7 +215,7 @@ try { const [hwStatus, status] = await Promise.all([ invoke('v2_get_hardware_audio_status').catch(() => null), - invoke('get_audio_output_status').catch(() => null), + invoke('v2_get_audio_output_status').catch(() => null), ]); hardwareStatus = hwStatus; if (status) outputStatus = status; From c70db4157a855d22cc280dca406f9c522d43d94e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:03:28 -0600 Subject: [PATCH 12/12] refactor(v2): migrate get_pipewire_sinks to v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path C: legacy CPAL output-sink enumeration was inlined directly in commands/playback.rs (cfg-gated Linux + non-Linux variants), with no equivalent in any domain crate. Extract a domain function `qbz_audio::output_sinks::list_output_sinks` that returns the same {name, description, volume, is_default} shape (mirrored as the new public `OutputSinkInfo` struct) for both Linux and non-Linux platforms, preserving the original logging cadence and the rule that name == CPAL device name (so saved IDs reopen correctly). Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into qbz_audio directly — no `crate::commands::*` delegation. Updates the two frontend call sites (AudioOutputBadges.loadStatus and SettingsView.loadAudioDevices). Legacy command in commands/playback.rs is preserved untouched; the audio-protected files (pipewire_backend.rs, audio_settings.rs, player init_device) are not modified. cc --- crates/qbz-audio/src/lib.rs | 2 + crates/qbz-audio/src/output_sinks.rs | 170 +++++++++++++++++++ src-tauri/src/commands_v2/audio.rs | 12 ++ src-tauri/src/lib.rs | 1 + src/lib/components/AudioOutputBadges.svelte | 2 +- src/lib/components/views/SettingsView.svelte | 2 +- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 crates/qbz-audio/src/output_sinks.rs diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 3a3e7909f..7b3a35099 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -44,6 +44,7 @@ pub mod dynamic_amplify; pub mod loudness; pub mod loudness_analyzer; pub mod loudness_cache; +pub mod output_sinks; pub mod settings; pub mod visualizer; @@ -65,6 +66,7 @@ pub use dynamic_amplify::DynamicAmplify; pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData}; pub use loudness_analyzer::LoudnessAnalyzer; pub use loudness_cache::LoudnessCache; +pub use output_sinks::{list_output_sinks, OutputSinkInfo}; pub use settings::AudioSettings; pub use visualizer::{RingBuffer, TappedSource, VisualizerTap}; diff --git a/crates/qbz-audio/src/output_sinks.rs b/crates/qbz-audio/src/output_sinks.rs new file mode 100644 index 000000000..4186a6dfa --- /dev/null +++ b/crates/qbz-audio/src/output_sinks.rs @@ -0,0 +1,170 @@ +//! Output sink enumeration (frontend-shaped diagnostic). +//! +//! Provides a small, frontend-facing struct (`OutputSinkInfo`) listing the +//! available CPAL output devices. This is the same data the legacy +//! `get_pipewire_sinks` command exposed — the simpler shape (`name`, +//! `description`, `volume`, `is_default`) used by the audio settings UI +//! and the AudioOutputBadges component. +//! +//! NOTE: This is the same CPAL host the Player itself opens, so the +//! `name` returned here is guaranteed to be a valid identifier the +//! audio backend can re-open later. It is intentionally NOT the richer +//! `AudioDevice` struct from `backend::AudioBackend::enumerate_devices`, +//! which carries sample-rate probing data the Settings UI does not need. + +use serde::Serialize; + +/// Frontend-shaped info for a single audio output device. +/// +/// Mirrors the legacy `PipewireSink` struct so the existing TypeScript +/// `PipewireSink` interface can consume V2 output unchanged. +#[derive(Debug, Clone, Serialize)] +pub struct OutputSinkInfo { + /// Internal name (e.g. CPAL device name; on Linux this is the + /// PipeWire/PulseAudio sink name like `alsa_output.usb-XXX`). + pub name: String, + /// User-friendly description. On PipeWire the CPAL name is already + /// user-readable; on macOS/Windows the name itself is descriptive. + pub description: String, + /// Current volume percentage (0–100). CPAL does not expose this so + /// it is always `None` here; preserved for API compatibility. + pub volume: Option, + /// Whether this is the default sink. + pub is_default: bool, +} + +/// Resolve the CPAL `description().name()` for a device, returning `None` +/// if the description cannot be queried. +fn cpal_device_name(device: &rodio::cpal::Device) -> Option { + use rodio::cpal::traits::DeviceTrait; + device + .description() + .ok() + .map(|description| description.name().to_string()) +} + +/// Enumerate the system's CPAL output devices. +/// +/// On Linux the CPAL host is PipeWire/PulseAudio (rodio defaults to +/// CPAL's PipeWire host on modern distros); on macOS/Windows it is the +/// platform default. Output is shaped for the audio settings UI. +/// +/// CRITICAL: The returned `name` is exactly the CPAL device name, so it +/// matches what the audio backend uses to re-open the device later. Do +/// NOT substitute a friendlier description for `name`. +#[cfg(target_os = "linux")] +pub fn list_output_sinks() -> Result, String> { + log::debug!("[qbz-audio] list_output_sinks (Linux, using CPAL)"); + + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + + let host = rodio::cpal::default_host(); + + let default_device_name = host + .default_output_device() + .and_then(|d| cpal_device_name(&d)); + + log::debug!( + "[qbz-audio] CPAL default device: {:?}", + default_device_name + ); + + let sinks: Vec = host + .output_devices() + .map_err(|e| format!("Failed to enumerate devices: {}", e))? + .enumerate() + .filter_map(|(idx, device)| { + let name = match cpal_device_name(&device) { + Some(name) => name, + None => { + log::warn!("[qbz-audio] [{}] Failed to get device description", idx); + return None; + } + }; + + let is_default = default_device_name + .as_ref() + .map(|d| d == &name) + .unwrap_or(false); + + // Same diagnostic logging as the legacy command, so log output + // for the V2 command matches what users / support reports + // already document. + let configs_info = device + .supported_output_configs() + .ok() + .map(|configs| { + let config_strs: Vec = configs + .take(3) + .map(|c| format!("{}ch/{}Hz", c.channels(), c.max_sample_rate())) + .collect(); + config_strs.join(", ") + }) + .unwrap_or_else(|| "no configs".to_string()); + + log::debug!( + "[qbz-audio] [{}] Device: '{}' (default: {}) - Configs: {}", + idx, + name, + is_default, + configs_info + ); + + // Use the CPAL name for both `name` and `description`: PipeWire + // CPAL names are already user-friendly, and storing the same + // value as `name` guarantees the saved id reopens correctly. + Some(OutputSinkInfo { + name: name.clone(), + description: name, + volume: None, + is_default, + }) + }) + .collect(); + + log::debug!( + "[qbz-audio] Found {} audio output devices via CPAL", + sinks.len() + ); + + Ok(sinks) +} + +/// Enumerate the system's CPAL output devices (macOS/Windows). +/// +/// CPAL device names on these platforms are already descriptive enough +/// to display directly to the user. +#[cfg(not(target_os = "linux"))] +pub fn list_output_sinks() -> Result, String> { + log::info!("[qbz-audio] list_output_sinks (non-Linux, using CPAL)"); + + use rodio::cpal::traits::HostTrait; + + let host = rodio::cpal::default_host(); + + let default_device_name = host + .default_output_device() + .and_then(|d| cpal_device_name(&d)); + + let sinks: Vec = host + .output_devices() + .map_err(|e| format!("Failed to enumerate devices: {}", e))? + .filter_map(|device| { + cpal_device_name(&device).map(|name| { + let is_default = default_device_name + .as_ref() + .map(|d| d == &name) + .unwrap_or(false); + OutputSinkInfo { + name: name.clone(), + description: name, + volume: None, + is_default, + } + }) + }) + .collect(); + + log::info!("[qbz-audio] Found {} audio output devices", sinks.len()); + Ok(sinks) +} diff --git a/src-tauri/src/commands_v2/audio.rs b/src-tauri/src/commands_v2/audio.rs index ccf0221cb..407643947 100644 --- a/src-tauri/src/commands_v2/audio.rs +++ b/src-tauri/src/commands_v2/audio.rs @@ -43,6 +43,18 @@ pub async fn v2_get_audio_output_status( }) } +/// List the available CPAL output sinks (V2). +/// +/// Delegates enumeration to `qbz_audio::output_sinks::list_output_sinks`, +/// which returns the same `{name, description, volume, is_default}` shape +/// the legacy `get_pipewire_sinks` command returned. Used by the audio +/// settings UI and AudioOutputBadges to populate the device picker and +/// label the currently-routed sink. +#[tauri::command] +pub fn v2_get_pipewire_sinks() -> Result, RuntimeError> { + qbz_audio::list_output_sinks().map_err(RuntimeError::Internal) +} + /// Snapshot the presence of the user's currently-selected output /// device. Frontend calls this on demand (e.g. when a /// `audio:device-missing` toast button fires Retry). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bb645618f..6ca267680 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1732,6 +1732,7 @@ pub fn run() { commands_v2::v2_reinit_audio_device, commands_v2::v2_check_audio_device_presence, commands_v2::v2_get_audio_output_status, + commands_v2::v2_get_pipewire_sinks, commands_v2::v2_get_audio_settings, commands_v2::v2_set_audio_output_device, commands_v2::v2_set_audio_exclusive_mode, diff --git a/src/lib/components/AudioOutputBadges.svelte b/src/lib/components/AudioOutputBadges.svelte index 617956e6b..44ca3d335 100644 --- a/src/lib/components/AudioOutputBadges.svelte +++ b/src/lib/components/AudioOutputBadges.svelte @@ -151,7 +151,7 @@ const [settingsResult, statusResult, sinksResult, alsaResult, hwStatus] = await Promise.all([ invoke('v2_get_audio_settings'), invoke('v2_get_audio_output_status'), - invoke('get_pipewire_sinks').catch(() => [] as PipewireSink[]), + invoke('v2_get_pipewire_sinks').catch(() => [] as PipewireSink[]), invoke('v2_get_devices_for_backend', { backendType: 'Alsa' }).catch(() => [] as BackendAudioDevice[]), invoke('v2_get_hardware_audio_status').catch(() => null) ]); diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 8f6409702..b7c2d93be 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -2716,7 +2716,7 @@ async function loadAudioDevices() { try { // Load PipeWire sinks - these have friendly descriptions already - const sinks = await invoke('get_pipewire_sinks').catch(() => [] as PipewireSink[]); + const sinks = await invoke('v2_get_pipewire_sinks').catch(() => [] as PipewireSink[]); pipewireSinks = sinks; // Load hardware audio status