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:
5 files changed
+83
-10
No files matched your search
@@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user