Merge refactor/internal/v2-cleanroom-final into pre-release
This commit is contained in:
18 files changed
+533
-27
No files matched your search
@@ -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};
|
||||
|
||||
|
||||
@@ -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<u32>,
|
||||
/// 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<String> {
|
||||
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<Vec<OutputSinkInfo>, 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<OutputSinkInfo> = 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<String> = 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<Vec<OutputSinkInfo>, 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<OutputSinkInfo> = 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)
|
||||
}
|
||||
@@ -7,11 +7,11 @@ 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,
|
||||
StreamUrl, Track, UserSession,
|
||||
Playlist, PlaylistTag, Quality, QueueState, QueueTrack, ReleasesGridResponse, RepeatMode,
|
||||
SearchResultsPage, StreamUrl, Track, TracksContainer, UserSession,
|
||||
};
|
||||
use qbz_player::{PlaybackState, Player, QueueManager};
|
||||
use qbz_qobuz::QobuzClient;
|
||||
@@ -841,6 +841,85 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
.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<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<ArtistAlbums, CoreError> {
|
||||
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 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<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Artist, CoreError> {
|
||||
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 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<TracksContainer, CoreError> {
|
||||
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 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<ReleasesGridResponse, CoreError> {
|
||||
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<LabelPageData, CoreError> {
|
||||
let client = self.client.read().await;
|
||||
|
||||
@@ -18,6 +18,43 @@ 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<String>,
|
||||
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<V2AudioOutputStatus, RuntimeError> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Vec<qbz_audio::OutputSinkInfo>, 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).
|
||||
|
||||
@@ -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, ReleasesGridResponse, SearchResultsPage, Track, TracksContainer,
|
||||
};
|
||||
|
||||
use crate::artist_blacklist::BlacklistState;
|
||||
@@ -399,6 +399,127 @@ 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<u32>,
|
||||
offset: Option<u32>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<ArtistAlbums, RuntimeError> {
|
||||
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 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<u32>,
|
||||
offset: Option<u32>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<Artist, RuntimeError> {
|
||||
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 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<u32>,
|
||||
offset: Option<u32>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<TracksContainer, RuntimeError> {
|
||||
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 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<u32>,
|
||||
offset: Option<u32>,
|
||||
sort: Option<String>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<ReleasesGridResponse, RuntimeError> {
|
||||
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)]
|
||||
|
||||
@@ -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<bool, String> {
|
||||
crate::config::legal_settings::get_qobuz_tos_accepted(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_set_update_check_on_launch(
|
||||
enabled: bool,
|
||||
@@ -1382,6 +1389,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<bool, String> {
|
||||
crate::updates::has_flatpak_welcome_been_shown(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_get_backend_logs() -> Vec<String> {
|
||||
crate::logging::get_backend_logs()
|
||||
@@ -1469,6 +1483,16 @@ 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()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn v2_is_auto_update_eligible() -> bool {
|
||||
crate::updates::is_auto_update_eligible()
|
||||
@@ -1486,6 +1510,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<bool, String> {
|
||||
crate::updates::has_snap_welcome_been_shown(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_detect_legacy_cached_files(
|
||||
cache_state: State<'_, OfflineCacheState>,
|
||||
|
||||
@@ -13,10 +13,11 @@ 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,
|
||||
QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
|
||||
Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
|
||||
DiscoverResponse, GenreInfo, LabelExploreResponse, LabelGetListResponse, LabelListPage,
|
||||
LabelPageData, LabelStoryResponse, PageArtistResponse, Playlist, PlaylistTag, Quality,
|
||||
QueueState, QueueTrack, ReleasesGridResponse, RepeatMode, SearchResultsPage, StreamUrl, Track,
|
||||
TracksContainer, UserSession,
|
||||
};
|
||||
use qbz_player::{PlaybackState, Player};
|
||||
|
||||
@@ -555,6 +556,60 @@ 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<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<ArtistAlbums, String> {
|
||||
self.core
|
||||
.get_artist_albums(artist_id, limit, offset)
|
||||
.await
|
||||
.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<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Artist, String> {
|
||||
self.core
|
||||
.get_artist_detail(artist_id, limit, offset)
|
||||
.await
|
||||
.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<TracksContainer, String> {
|
||||
self.core
|
||||
.get_artist_tracks(artist_id, limit, offset)
|
||||
.await
|
||||
.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<ReleasesGridResponse, String> {
|
||||
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<LabelPageData, String> {
|
||||
self.core
|
||||
|
||||
@@ -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,
|
||||
@@ -1579,6 +1580,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,
|
||||
@@ -1625,6 +1627,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,
|
||||
@@ -1636,6 +1639,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,
|
||||
@@ -1699,6 +1703,10 @@ 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_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,
|
||||
@@ -1723,6 +1731,8 @@ 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_pipewire_sinks,
|
||||
commands_v2::v2_get_audio_settings,
|
||||
commands_v2::v2_set_audio_output_device,
|
||||
commands_v2::v2_set_audio_exclusive_mode,
|
||||
@@ -1868,6 +1878,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,
|
||||
|
||||
@@ -150,8 +150,8 @@
|
||||
try {
|
||||
const [settingsResult, statusResult, sinksResult, alsaResult, hwStatus] = await Promise.all([
|
||||
invoke<AudioSettings>('v2_get_audio_settings'),
|
||||
invoke<AudioOutputStatus>('get_audio_output_status'),
|
||||
invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[]),
|
||||
invoke<AudioOutputStatus>('v2_get_audio_output_status'),
|
||||
invoke<PipewireSink[]>('v2_get_pipewire_sinks').catch(() => [] as PipewireSink[]),
|
||||
invoke<BackendAudioDevice[]>('v2_get_devices_for_backend', { backendType: 'Alsa' }).catch(() => [] as BackendAudioDevice[]),
|
||||
invoke<HardwareAudioStatus>('v2_get_hardware_audio_status').catch(() => null)
|
||||
]);
|
||||
@@ -215,7 +215,7 @@
|
||||
try {
|
||||
const [hwStatus, status] = await Promise.all([
|
||||
invoke<HardwareAudioStatus>('v2_get_hardware_audio_status').catch(() => null),
|
||||
invoke<AudioOutputStatus>('get_audio_output_status').catch(() => null),
|
||||
invoke<AudioOutputStatus>('v2_get_audio_output_status').catch(() => null),
|
||||
]);
|
||||
hardwareStatus = hwStatus;
|
||||
if (status) outputStatus = status;
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
try {
|
||||
// Get artist detail which includes playlists and tracks
|
||||
const artist = await invoke<ArtistDetail>('get_artist_detail', { artistId: id });
|
||||
const artist = await invoke<ArtistDetail>('v2_get_artist_detail', { artistId: id });
|
||||
loadedArtistId = id;
|
||||
|
||||
// Extract curated playlists (max 2)
|
||||
@@ -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<TracksContainer>('get_artist_tracks', {
|
||||
const artistTracks = await invoke<TracksContainer>('v2_get_artist_tracks', {
|
||||
artistId: id,
|
||||
limit: 30,
|
||||
offset: 0
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<QobuzArtist>('get_artist_basic', { artistId: seed.artistId }))
|
||||
batch.map(seed => invoke<QobuzArtist>('v2_get_artist', { artistId: seed.artistId }))
|
||||
);
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -2716,7 +2716,7 @@
|
||||
async function loadAudioDevices() {
|
||||
try {
|
||||
// Load PipeWire sinks - these have friendly descriptions already
|
||||
const sinks = await invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[]);
|
||||
const sinks = await invoke<PipewireSink[]>('v2_get_pipewire_sinks').catch(() => [] as PipewireSink[]);
|
||||
pipewireSinks = sinks;
|
||||
|
||||
// Load hardware audio status
|
||||
@@ -2787,7 +2787,7 @@
|
||||
try {
|
||||
isFlatpak = await invoke<boolean>('v2_is_running_in_flatpak');
|
||||
if (isFlatpak) {
|
||||
flatpakHelpText = await invoke<string>('get_flatpak_help_text');
|
||||
flatpakHelpText = await invoke<string>('v2_get_flatpak_help_text');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check Flatpak status:', err);
|
||||
|
||||
@@ -158,7 +158,7 @@ async function shouldShowFlatpakWelcome(): Promise<boolean> {
|
||||
try {
|
||||
const isFlatpak = await invoke<boolean>('v2_is_running_in_flatpak');
|
||||
if (!isFlatpak) return false;
|
||||
const alreadyShown = await invoke<boolean>('has_flatpak_welcome_been_shown');
|
||||
const alreadyShown = await invoke<boolean>('v2_has_flatpak_welcome_been_shown');
|
||||
return !alreadyShown;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -183,7 +183,7 @@ async function shouldShowSnapWelcome(): Promise<boolean> {
|
||||
try {
|
||||
const isSnap = await invoke<boolean>('v2_is_running_in_snap');
|
||||
if (!isSnap) return false;
|
||||
const alreadyShown = await invoke<boolean>('has_snap_welcome_been_shown');
|
||||
const alreadyShown = await invoke<boolean>('v2_has_snap_welcome_been_shown');
|
||||
return !alreadyShown;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function loadTosAcceptance(): Promise<boolean> {
|
||||
if (!browser) return false;
|
||||
|
||||
try {
|
||||
const accepted = await invoke<boolean>('get_qobuz_tos_accepted');
|
||||
const accepted = await invoke<boolean>('v2_get_qobuz_tos_accepted');
|
||||
qobuzTosAccepted.set(accepted);
|
||||
initialized = true;
|
||||
return accepted;
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function detectDesktopThemeCached(force = false): Promise<DesktopTh
|
||||
detectInFlight = (async () => {
|
||||
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) {
|
||||
|
||||
@@ -1857,7 +1857,7 @@
|
||||
g => g.type === 'ep' || g.type === 'single' || g.type === 'epSingle'
|
||||
);
|
||||
if (!hasEpGroup && selectedArtist.epsSingles.length === 0) {
|
||||
invoke<ReleasesGridResponse>('get_releases_grid', {
|
||||
invoke<ReleasesGridResponse>('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<ReleasesGridResponse>('get_releases_grid', {
|
||||
const result = await invoke<ReleasesGridResponse>('v2_get_releases_grid', {
|
||||
artistId: selectedArtist.id,
|
||||
releaseType: apiReleaseType,
|
||||
limit: 25,
|
||||
|
||||
Reference in new issue
Block a user