From 1934d501ed1a2cdecab7506d3c300f0f133bae2f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:44:05 -0600 Subject: [PATCH] feat(audio): make quality fallback opt-in via allow_quality_fallback setting Off by default. When disabled, if the requested quality is unavailable the download/stream fails instead of silently downgrading. Persisted to audio_settings DB. Applied to offline cache downloads. cc --- crates/qbz-audio/src/settings.rs | 10 ++++++- src-tauri/src/commands_v2.rs | 21 +++++++++++++++ src-tauri/src/config/audio_settings.rs | 25 +++++++++++++++-- src-tauri/src/lib.rs | 1 + src-tauri/src/offline_cache/commands.rs | 36 ++++++++++++++++++++----- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/crates/qbz-audio/src/settings.rs b/crates/qbz-audio/src/settings.rs index 0c0f8976f..68fed1eae 100644 --- a/crates/qbz-audio/src/settings.rs +++ b/crates/qbz-audio/src/settings.rs @@ -53,6 +53,8 @@ pub struct AudioSettings { /// When true, skip `pactl set-default-sink` on stream creation. /// Preserves external routing (JACK, qjackctl, Reaper). pub skip_sink_switch: bool, + /// When true, automatically try lower quality tiers if the requested one fails. + pub allow_quality_fallback: bool, } impl Default for AudioSettings { @@ -76,6 +78,7 @@ impl Default for AudioSettings { gapless_enabled: false, // Off by default — user opts in pw_force_bitperfect: false, // Off by default — experimental PipeWire feature skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups + allow_quality_fallback: false, // Off by default — fail rather than silently downgrade } } } @@ -168,6 +171,10 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0", + [], + ); Ok(Self { conn }) } @@ -186,7 +193,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -224,6 +231,7 @@ impl AudioSettingsStore { gapless_enabled: row.get::<_, Option>(14)?.unwrap_or(0) != 0, pw_force_bitperfect: row.get::<_, Option>(16)?.unwrap_or(0) != 0, skip_sink_switch: row.get::<_, Option>(17)?.unwrap_or(0) != 0, + allow_quality_fallback: row.get::<_, Option>(18)?.unwrap_or(0) != 0, }) }, ) diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 4b5766eb7..786a027e9 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -168,6 +168,7 @@ pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_aud gapless_enabled: settings.gapless_enabled, pw_force_bitperfect: settings.pw_force_bitperfect, skip_sink_switch: settings.skip_sink_switch, + allow_quality_fallback: settings.allow_quality_fallback, } } @@ -8673,6 +8674,26 @@ pub async fn v2_set_audio_gapless_enabled( Ok(()) } +/// Set allow quality fallback (V2) +#[tauri::command] +pub async fn v2_set_audio_allow_quality_fallback( + enabled: bool, + state: State<'_, AudioSettingsState>, +) -> Result<(), RuntimeError> { + log::info!("[V2] set_audio_allow_quality_fallback: {}", enabled); + let guard = state + .store + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + let store = guard + .as_ref() + .ok_or(RuntimeError::UserSessionNotActivated)?; + store + .set_allow_quality_fallback(enabled) + .map_err(RuntimeError::Internal)?; + Ok(()) +} + /// Set skip sink switch (V2) — preserves JACK/qjackctl routing #[tauri::command] pub async fn v2_set_audio_skip_sink_switch( diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs index 3b80ca839..7adf3d65a 100644 --- a/src-tauri/src/config/audio_settings.rs +++ b/src-tauri/src/config/audio_settings.rs @@ -59,6 +59,9 @@ pub struct AudioSettings { /// Preserves external routing (JACK, qjackctl, Reaper). /// Mutually exclusive with dac_passthrough. pub skip_sink_switch: bool, + /// When true, automatically try lower quality tiers if the requested one fails. + /// When false (default), playback or download fails if the exact quality is unavailable. + pub allow_quality_fallback: bool, } impl Default for AudioSettings { @@ -84,6 +87,7 @@ impl Default for AudioSettings { sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case quality_fallback_behavior: "ask".to_string(), skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups + allow_quality_fallback: false, // Off by default — fail rather than silently downgrade } } } @@ -184,6 +188,10 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0", + [], + ); Ok(Self { conn }) } @@ -202,7 +210,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -244,6 +252,7 @@ impl AudioSettingsStore { .get::<_, Option>(18)? .unwrap_or_else(|| "ask".to_string()), skip_sink_switch: row.get::<_, Option>(19)?.unwrap_or(0) != 0, + allow_quality_fallback: row.get::<_, Option>(20)?.unwrap_or(0) != 0, }) }, ) @@ -457,6 +466,16 @@ impl AudioSettingsStore { Ok(()) } + pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?; + Ok(()) + } + pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> { self.conn .execute( @@ -564,7 +583,8 @@ impl AudioSettingsStore { device_sample_rate_limits = ?16, pw_force_bitperfect = ?17, sync_audio_on_startup = ?18, - skip_sink_switch = ?19 + skip_sink_switch = ?19, + allow_quality_fallback = ?20 WHERE id = 1", params![ defaults.output_device, @@ -586,6 +606,7 @@ impl AudioSettingsStore { defaults.pw_force_bitperfect as i64, defaults.sync_audio_on_startup as i64, defaults.skip_sink_switch as i64, + defaults.allow_quality_fallback as i64, ], ) .map_err(|e| format!("Failed to reset audio settings: {}", e))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9c4321f3..92eeee9d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1565,6 +1565,7 @@ pub fn run() { commands_v2::v2_set_audio_backend_type, commands_v2::v2_set_audio_alsa_plugin, commands_v2::v2_set_audio_gapless_enabled, + commands_v2::v2_set_audio_allow_quality_fallback, commands_v2::v2_set_audio_skip_sink_switch, commands_v2::v2_set_audio_normalization_enabled, commands_v2::v2_set_audio_normalization_target, diff --git a/src-tauri/src/offline_cache/commands.rs b/src-tauri/src/offline_cache/commands.rs index ea3d9a31f..de4ed36a8 100644 --- a/src-tauri/src/offline_cache/commands.rs +++ b/src-tauri/src/offline_cache/commands.rs @@ -137,6 +137,7 @@ pub async fn cache_track_for_offline( state: State<'_, AppState>, cache_state: State<'_, OfflineCacheState>, library_state: State<'_, crate::library::commands::LibraryState>, + audio_settings: State<'_, crate::config::audio_settings::AudioSettingsState>, app_handle: AppHandle, ) -> Result<(), String> { log::info!( @@ -171,6 +172,15 @@ pub async fn cache_track_for_offline( db.insert_track(&track_info, &file_path_str)?; } + // Read quality fallback preference before spawning + let allow_fallback = audio_settings + .store + .lock() + .ok() + .and_then(|g| g.as_ref().and_then(|s| s.get_settings().ok())) + .map(|s| s.allow_quality_fallback) + .unwrap_or(false); + // Clone what we need for the spawn let client = state.client.clone(); let fetcher = cache_state.fetcher.clone(); @@ -222,12 +232,18 @@ pub async fn cache_track_for_offline( }), ); - // Get stream URL with highest quality available + // Get stream URL — respect quality fallback preference let stream_url = { let client_guard = client.read().await; - client_guard - .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) - .await + if allow_fallback { + client_guard + .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) + .await + } else { + client_guard + .get_stream_url(track_id, Quality::UltraHiRes) + .await + } }; let url = match stream_url { @@ -324,9 +340,15 @@ pub async fn cache_track_for_offline( tokio::time::sleep(std::time::Duration::from_millis(500)).await; let retry_url = { let client_guard = client.read().await; - client_guard - .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) - .await + if allow_fallback { + client_guard + .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) + .await + } else { + client_guard + .get_stream_url(track_id, Quality::UltraHiRes) + .await + } }; let retry_result = match retry_url {