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
This commit is contained in:
vicrodh committed 2026-04-05 10:44:05 -06:00
1 parent f39cf7c95f
commit 1934d501ed
5 files changed
+83 -10

No files matched your search

+9 -1
View File
@@ -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<AudioSettings, String> {
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<i64>>(14)?.unwrap_or(0) != 0,
pw_force_bitperfect: row.get::<_, Option<i64>>(16)?.unwrap_or(0) != 0,
skip_sink_switch: row.get::<_, Option<i64>>(17)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(18)?.unwrap_or(0) != 0,
})
},
)
+21
View File
@@ -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(
+23 -2
View File
@@ -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<AudioSettings, String> {
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<String>>(18)?
.unwrap_or_else(|| "ask".to_string()),
skip_sink_switch: row.get::<_, Option<i64>>(19)?.unwrap_or(0) != 0,
allow_quality_fallback: row.get::<_, Option<i64>>(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))?;
+1
View File
@@ -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,
+29 -7
View File
@@ -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 {