diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index fc84ec4a..f231e7e7 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -3,6 +3,7 @@ use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; use serde::Serialize; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread::JoinHandle; @@ -10,7 +11,7 @@ use tauri::Emitter; type Reply = mpsc::Sender; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct AudioDevice { pub id: String, pub name: String, @@ -94,8 +95,14 @@ enum WriterCommand { generation: u64, }, FormatHint(PcmFormat), - Resampling { from: u32, to: u32 }, - PendingPromotion { from: String, generation: u64 }, + Resampling { + from: u32, + to: u32, + }, + PendingPromotion { + from: String, + generation: u64, + }, Flush, Shutdown, } @@ -351,9 +358,7 @@ fn attach_next_bin( // Wait for the preroll to complete so pad_added has fired + linked. Bounded // so a stalled network source can't hang the executor thread. let (ret, cur, pend) = udb.state(gst::ClockTime::from_seconds(15)); - log::debug!( - "[audio] gapless: next bin preroll state ret={ret:?} cur={cur:?} pend={pend:?}" - ); + log::debug!("[audio] gapless: next bin preroll state ret={ret:?} cur={cur:?} pend={pend:?}"); // Preroll done + pad linked: now safe to promote to PLAYING. if let Err(e) = branch_queue.sync_state_with_parent() { @@ -412,9 +417,7 @@ fn detach_bin( let _ = branch_queue.set_state(gst::State::Null); let (br, bcur, _) = bin.state(gst::ClockTime::from_seconds(10)); let (qr, qcur, _) = branch_queue.state(gst::ClockTime::from_seconds(10)); - log::debug!( - "[audio] gapless: next bin NULL wait bin={br:?}/{bcur:?} queue={qr:?}/{qcur:?}" - ); + log::debug!("[audio] gapless: next bin NULL wait bin={br:?}/{bcur:?} queue={qr:?}/{qcur:?}"); let _ = pipeline.remove_many([bin, branch_queue]); log::debug!("[audio] gapless: detached next bin"); @@ -488,8 +491,8 @@ fn probe_supported_gst_formats(pcm: &alsa::PCM) -> Vec<&'static str> { }; let probe: &[(Format, &str)] = &[ (Format::S32LE, "S32LE"), - (Format::S24LE, "S24_32LE"), // ALSA S24LE = GStreamer S24_32LE - (Format::S243LE, "S24LE"), // ALSA S243LE = GStreamer S24LE + (Format::S24LE, "S24_32LE"), // ALSA S24LE = GStreamer S24_32LE + (Format::S243LE, "S24LE"), // ALSA S243LE = GStreamer S24LE (Format::FloatLE, "F32LE"), (Format::S16LE, "S16LE"), ]; @@ -511,7 +514,7 @@ fn probe_supported_gst_formats(pcm: &alsa::PCM) -> Vec<&'static str> { /// 2. Narrowest lossless promotion the DAC supports (container widening or, for S24_32LE, /// shrinking to S24LE which holds the same 24 audio bits in 3 bytes). /// 3. Lossy fallback: DAC's first probed format (widest per probe order). -/// In case 3, the writer's `resolve_pending` still emits a truthful from→to toast. +/// The writer's `resolve_pending` still emits a truthful from→to toast. #[cfg(target_os = "linux")] fn pick_capsfilter_format(source: &str, dac_supported: &[String]) -> String { // 1. Pass-through. @@ -523,12 +526,15 @@ fn pick_capsfilter_format(source: &str, dac_supported: &[String]) -> String { // S24_32LE → S24LE is safe because audioconvert writes a zero pad byte // upstream; stripping it preserves the 24 audio bits exactly. let promotions: &[&str] = match source { - "S16LE" => &["S24LE", "S24_32LE", "S32LE"], - "S24LE" => &["S24_32LE", "S32LE"], + "S16LE" => &["S24LE", "S24_32LE", "S32LE"], + "S24LE" => &["S24_32LE", "S32LE"], "S24_32LE" => &["S24LE", "S32LE"], // S24LE = same 24 bits, narrower container - _ => &[], // S32LE, F32LE, unknowns: no lossless integer alternative + _ => &[], // S32LE, F32LE, unknowns: no lossless integer alternative }; - if let Some(p) = promotions.iter().find(|p| dac_supported.iter().any(|f| f == *p)) { + if let Some(p) = promotions + .iter() + .find(|p| dac_supported.iter().any(|f| f == *p)) + { return (*p).to_string(); } // 3. Lossy fallback — DAC's preferred (widest) format. PendingPromotion still @@ -603,8 +609,8 @@ fn configure_alsa_hwparams( // Ranked fallback: requested first, then descending quality let fallbacks: &[Format] = &[ Format::S32LE, - Format::S24LE, // 24-in-32 container - Format::S243LE, // 24-bit packed + Format::S24LE, // 24-in-32 container + Format::S243LE, // 24-bit packed Format::FloatLE, Format::S16LE, ]; @@ -636,7 +642,10 @@ fn configure_alsa_hwparams( hwp.set_rate(fmt.sample_rate, ValueOr::Nearest) .map_err(|e| { if bit_perfect { - log::warn!("[audio] bit-perfect set_rate({}) failed: {e}", fmt.sample_rate); + log::warn!( + "[audio] bit-perfect set_rate({}) failed: {e}", + fmt.sample_rate + ); format!( "DAC doesn't support {}kHz — turn off bit-perfect mode for compatibility", fmt.sample_rate / 1000 @@ -650,7 +659,8 @@ fn configure_alsa_hwparams( if actual_rate != fmt.sample_rate { log::warn!( "[audio] bit-perfect rate mismatch: DAC negotiated {}Hz, track requires {}Hz", - actual_rate, fmt.sample_rate + actual_rate, + fmt.sample_rate ); return Err(format!( "DAC doesn't support {}kHz — turn off bit-perfect mode for compatibility", @@ -668,7 +678,8 @@ fn configure_alsa_hwparams( Ok(n) if n > 0 => { log::info!( "[audio] DAC rejects {}ch, using device-native {}ch", - fmt.channels, n + fmt.channels, + n ); n } @@ -693,13 +704,17 @@ fn configure_alsa_hwparams( // writei), which causes underruns when the writer can't keep up from frame one. // Match GStreamer alsasink: start_threshold = buffer_size (full pre-fill). { - let swp = pcm.sw_params_current() + let swp = pcm + .sw_params_current() .map_err(|e| format!("sw_params_current: {e}"))?; - let hwp_active = pcm.hw_params_current() + let hwp_active = pcm + .hw_params_current() .map_err(|e| format!("hw_params_current for sw: {e}"))?; - let buffer_frames = hwp_active.get_buffer_size() + let buffer_frames = hwp_active + .get_buffer_size() .map_err(|e| format!("get_buffer_size: {e}"))?; - let period_frames = hwp_active.get_period_size() + let period_frames = hwp_active + .get_period_size() .map_err(|e| format!("get_period_size: {e}"))?; // start_threshold: largest period-aligned value ≤ buffer_size. // With our time-near requests this equals buffer_size, but the @@ -709,11 +724,11 @@ fn configure_alsa_hwparams( .map_err(|e| format!("set_start_threshold: {e}"))?; swp.set_avail_min(period_frames as alsa::pcm::Frames) .map_err(|e| format!("set_avail_min: {e}"))?; - pcm.sw_params(&swp) - .map_err(|e| format!("sw_params: {e}"))?; + pcm.sw_params(&swp).map_err(|e| format!("sw_params: {e}"))?; log::debug!( "[audio] sw_params committed: start_threshold={}, avail_min={}", - start, period_frames + start, + period_frames ); } @@ -733,10 +748,13 @@ fn configure_alsa_hwparams( if alsa_fmt != requested { log::info!( "[audio] format fallback: {} -> {} (DAC doesn't support {})", - fmt.gst_format, gst_fmt_str, fmt.gst_format + fmt.gst_format, + gst_fmt_str, + fmt.gst_format ); } - let actual_rate = pcm.hw_params_current() + let actual_rate = pcm + .hw_params_current() .and_then(|p| p.get_rate()) .unwrap_or(fmt.sample_rate); Ok(PcmFormat { @@ -748,6 +766,7 @@ fn configure_alsa_hwparams( } #[cfg(target_os = "linux")] +#[allow(clippy::type_complexity)] #[allow(clippy::too_many_arguments)] fn spawn_alsa_writer( device: &str, @@ -763,7 +782,16 @@ fn spawn_alsa_writer( signal_path: Arc, decoded_cell: Arc>>, output_cell: Arc>>, -) -> Result<(crossbeam_channel::Sender, JoinHandle<()>, PcmFormat, Vec<&'static str>, Vec), String> { +) -> Result< + ( + crossbeam_channel::Sender, + JoinHandle<()>, + PcmFormat, + Vec<&'static str>, + Vec, + ), + String, +> { let device = device.to_string(); let initial_format = initial_format.clone(); let (tx, rx) = crossbeam_channel::bounded::(256); @@ -779,7 +807,10 @@ fn spawn_alsa_writer( })?; let supported_gst_formats = probe_supported_gst_formats(&pcm); - log::debug!("[alsa-writer] DAC supported GStreamer formats: {:?}", supported_gst_formats); + log::debug!( + "[alsa-writer] DAC supported GStreamer formats: {:?}", + supported_gst_formats + ); let supported_rates = probe_supported_rates(&pcm); log::debug!("[alsa-writer] DAC supported rates: {:?}", supported_rates); @@ -794,7 +825,8 @@ fn spawn_alsa_writer( let (_, bps) = alsa_format_to_gst(gst_format_to_alsa(best)); log::info!( "[alsa-writer] DAC doesn't support {}, using {} for initial config", - fmt.gst_format, best + fmt.gst_format, + best ); fmt.gst_format = best.to_string(); fmt.bytes_per_sample = bps; @@ -803,7 +835,8 @@ fn spawn_alsa_writer( let fallback = supported_rates[0]; // first probed rate (44100 typically) log::info!( "[alsa-writer] DAC doesn't support {}Hz, using {}Hz for initial config", - fmt.sample_rate, fallback + fmt.sample_rate, + fallback ); fmt.sample_rate = fallback; } @@ -1340,7 +1373,13 @@ fn spawn_alsa_writer( }) .map_err(|e| format!("Failed to spawn ALSA writer thread: {e}"))?; - Ok((tx, handle, negotiated_fmt, supported_gst_formats, supported_rates)) + Ok(( + tx, + handle, + negotiated_fmt, + supported_gst_formats, + supported_rates, + )) } // ── Audio command protocol ───────────────────────────────────────────── @@ -1556,8 +1595,14 @@ impl AudioPlayer { // Fade out if let Some(ref vol) = user_volume_el { for i in (0..=10).rev() { - vol.set_property("volume", slider_to_amplitude(current_volume) * (i as f64 / 10.0)); - std::thread::sleep(std::time::Duration::from_millis(10)); + vol.set_property( + "volume", + slider_to_amplitude(current_volume) + * (i as f64 / 10.0), + ); + std::thread::sleep( + std::time::Duration::from_millis(10), + ); } } old_pipe.set_state(gst::State::Null).ok(); @@ -1649,7 +1694,11 @@ impl AudioPlayer { let device_changed = writer_device.as_deref() != Some(dev); let mode_changed = writer_bit_perfect != Some(bit_perfect); - if !writer_alive || writer_tx.is_none() || device_changed || mode_changed { + if !writer_alive + || writer_tx.is_none() + || device_changed + || mode_changed + { // Shut down old writer cleanly if let Some(tx) = writer_tx.take() { tx.try_send(WriterCommand::Shutdown).ok(); @@ -1657,7 +1706,13 @@ impl AudioPlayer { if let Some(h) = writer_thread.take() { h.join().ok(); } - let (tx, handle, negotiated_fmt, supported_gst_fmts, supported_rates) = spawn_alsa_writer( + let ( + tx, + handle, + negotiated_fmt, + supported_gst_fmts, + supported_rates, + ) = spawn_alsa_writer( dev, &default_fmt, app_handle.clone(), @@ -1684,9 +1739,13 @@ impl AudioPlayer { let wtx = writer_tx.as_ref().unwrap().clone(); // Build appsink pipeline - let fmt_for_pipeline = writer_fmt.as_ref().unwrap_or(&default_fmt); - let supported_fmts_for_pipeline = writer_supported_fmts.as_deref().unwrap_or(&["S32LE"]); - let supported_rates_for_pipeline = writer_supported_rates.as_deref().unwrap_or(&[44100, 48000]); + let fmt_for_pipeline = + writer_fmt.as_ref().unwrap_or(&default_fmt); + let supported_fmts_for_pipeline = + writer_supported_fmts.as_deref().unwrap_or(&["S32LE"]); + let supported_rates_for_pipeline = writer_supported_rates + .as_deref() + .unwrap_or(&[44100, 48000]); let (pipe, u_vol, n_vol) = build_appsink_pipeline( &uri, exclusive, @@ -1955,23 +2014,32 @@ impl AudioPlayer { // format when the downstream capsfilter is locked, which is misleading. if let Some(sink_pad) = audioconvert.static_pad("sink") { let cell = Arc::clone(&decoded_cell_thread); - sink_pad.add_probe(gst::PadProbeType::EVENT_DOWNSTREAM, move |_pad, info| { - if let Some(gst::PadProbeData::Event(ref event)) = info.data { - if let gst::EventView::Caps(caps_event) = event.view() { - let caps = caps_event.caps(); - if let Some(fmt) = parse_pcm_format(caps) { - if let Ok(mut guard) = cell.lock() { - *guard = Some(crate::pipeline_probe::PadCaps { - format: fmt.gst_format.clone(), - rate: fmt.sample_rate, - channels: fmt.channels, - }); + sink_pad.add_probe( + gst::PadProbeType::EVENT_DOWNSTREAM, + move |_pad, info| { + if let Some(gst::PadProbeData::Event(ref event)) = + info.data + { + if let gst::EventView::Caps(caps_event) = + event.view() + { + let caps = caps_event.caps(); + if let Some(fmt) = parse_pcm_format(caps) { + if let Ok(mut guard) = cell.lock() { + *guard = Some( + crate::pipeline_probe::PadCaps { + format: fmt.gst_format.clone(), + rate: fmt.sample_rate, + channels: fmt.channels, + }, + ); + } } } } - } - gst::PadProbeReturn::Ok - }); + gst::PadProbeReturn::Ok + }, + ); } // autoaudiosink is a bin — its real child sink @@ -2098,8 +2166,7 @@ impl AudioPlayer { if let Ok(el) = obj .clone() .downcast::( - ) - { + ) { if el == next_el { found = true; break; @@ -2123,9 +2190,8 @@ impl AudioPlayer { log::warn!( "[audio] gapless: next-bin bus error, isolating: {err_msg}" ); - let _ = cmd_tx_bus.send( - AudioCommand::HandleNextBinError, - ); + let _ = cmd_tx_bus + .send(AudioCommand::HandleNextBinError); // Do NOT set eos / emit audio-error / // break — current track is unaffected. continue; @@ -2413,11 +2479,12 @@ impl AudioPlayer { // 2b-A3 (detach matrix): enabling exclusive invalidates any // Normal-pipeline next bin. Detach it (gated on !next_active). if enabled && !next_active.load(Ordering::Acquire) { - if let (Some(stale), Some(PlaybackBackend::Normal { - pipeline, - concat, - .. - })) = ( + if let ( + Some(stale), + Some(PlaybackBackend::Normal { + pipeline, concat, .. + }), + ) = ( next_bin.lock().ok().and_then(|mut g| g.take()), backend.as_ref(), ) { @@ -2440,11 +2507,12 @@ impl AudioPlayer { // 2b-A3 (detach matrix): enabling bit-perfect invalidates any // Normal-pipeline next bin. Detach it (gated on !next_active). if enabled && !next_active.load(Ordering::Acquire) { - if let (Some(stale), Some(PlaybackBackend::Normal { - pipeline, - concat, - .. - })) = ( + if let ( + Some(stale), + Some(PlaybackBackend::Normal { + pipeline, concat, .. + }), + ) = ( next_bin.lock().ok().and_then(|mut g| g.take()), backend.as_ref(), ) { @@ -2465,11 +2533,12 @@ impl AudioPlayer { // 2b-A3 (detach matrix): disabling gapless invalidates any // prerolled next bin. Detach it (gated on !next_active). if !enabled && !next_active.load(Ordering::Acquire) { - if let (Some(stale), Some(PlaybackBackend::Normal { - pipeline, - concat, - .. - })) = ( + if let ( + Some(stale), + Some(PlaybackBackend::Normal { + pipeline, concat, .. + }), + ) = ( next_bin.lock().ok().and_then(|mut g| g.take()), backend.as_ref(), ) { @@ -2518,7 +2587,8 @@ impl AudioPlayer { // Gapless off / wrong mode: detach any existing next_bin // (gated on !next_active per C5) and do nothing else. if !next_active.load(Ordering::Acquire) { - if let Some(stale) = next_bin.lock().ok().and_then(|mut g| g.take()) { + if let Some(stale) = next_bin.lock().ok().and_then(|mut g| g.take()) + { if let Some(PlaybackBackend::Normal { pipeline, concat, .. }) = backend.as_ref() @@ -2576,7 +2646,9 @@ impl AudioPlayer { }); } - log::debug!("[gapless-diag] SetNextTrack: dispatching ATTACH for track {track_id}"); + log::debug!( + "[gapless-diag] SetNextTrack: dispatching ATTACH for track {track_id}" + ); let _ = attach_tx.send(AttachJob::Attach { pipeline, concat, @@ -2638,11 +2710,12 @@ impl AudioPlayer { // detach now that concat has switched away from it. Gated to // Normal (C5) — the worker never reaches here on DirectAlsa // (gapless is mode-gated off), but be defensive. - if let (Some((old_bin, old_queue)), Some(PlaybackBackend::Normal { - pipeline, - concat, - .. - })) = (current_branch.take(), backend.as_ref()) + if let ( + Some((old_bin, old_queue)), + Some(PlaybackBackend::Normal { + pipeline, concat, .. + }), + ) = (current_branch.take(), backend.as_ref()) { let _ = attach_tx.send(AttachJob::Detach { pipeline: pipeline.clone(), @@ -2693,11 +2766,12 @@ impl AudioPlayer { // current track — the natural boundary falls back to playNext. AudioCommand::HandleNextBinError => { if !next_active.load(Ordering::Acquire) { - if let (Some(stale), Some(PlaybackBackend::Normal { - pipeline, - concat, - .. - })) = ( + if let ( + Some(stale), + Some(PlaybackBackend::Normal { + pipeline, concat, .. + }), + ) = ( next_bin.lock().ok().and_then(|mut g| g.take()), backend.as_ref(), ) { @@ -2712,7 +2786,7 @@ impl AudioPlayer { } AudioCommand::ListDevices { reply } => { - let result = list_alsa_devices_inner(); + let result = Ok(list_alsa_devices_with_override(None)); reply.send(result).ok(); } } @@ -2841,6 +2915,7 @@ fn stereo_pad_mix_matrix(out_channels: u32) -> gst::Array { } #[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] fn build_appsink_pipeline( uri: &str, exclusive: bool, @@ -2915,7 +2990,10 @@ fn build_appsink_pipeline( // non-DASH/BTS path). if is_dash { let mut caps_builder = gst::Caps::builder("audio/x-raw") - .field("format", gst::List::new(supported_gst_formats.iter().copied())) + .field( + "format", + gst::List::new(supported_gst_formats.iter().copied()), + ) .field("channels", device_channels as i32); let rate_list: Vec = supported_rates.iter().map(|&r| r as i32).collect(); if !bit_perfect && !rate_list.is_empty() { @@ -2925,7 +3003,11 @@ fn build_appsink_pipeline( log::debug!( "[audio] DASH appsink caps = formats:{:?} rates:{} (bit_perfect={bit_perfect})", supported_gst_formats, - if bit_perfect { "passthrough".to_string() } else { format!("{supported_rates:?}") } + if bit_perfect { + "passthrough".to_string() + } else { + format!("{supported_rates:?}") + } ); } @@ -2933,7 +3015,11 @@ fn build_appsink_pipeline( "[audio] building appsink pipeline: exclusive={exclusive} bit_perfect={bit_perfect}" ); - let (u_vol, n_vol, capsfilter_weak_from_build): (Option, Option, Option>) = if bit_perfect { + let (u_vol, n_vol, capsfilter_weak_from_build): ( + Option, + Option, + Option>, + ) = if bit_perfect { audioconvert.set_property_from_str("dithering", "none"); audioconvert.set_property_from_str("noise-shaping", "none"); @@ -3033,7 +3119,10 @@ fn build_appsink_pipeline( // Connect uridecodebin's dynamic pad to audioconvert let convert_weak = audioconvert.downgrade(); - let supported_fmts_for_closure: Vec = supported_gst_formats.iter().map(|s| s.to_string()).collect(); + let supported_fmts_for_closure: Vec = supported_gst_formats + .iter() + .map(|s| s.to_string()) + .collect(); let supported_rates_for_closure: Vec = supported_rates.to_vec(); let resample_tx = writer_tx.clone(); let is_bit_perfect = bit_perfect; @@ -3075,9 +3164,10 @@ fn build_appsink_pipeline( .copied() .min_by_key(|&r| (r as i64 - native as i64).unsigned_abs()) .unwrap_or(48000); - let _ = resample_tx.try_send( - WriterCommand::Resampling { from: native, to: closest }, - ); + let _ = resample_tx.try_send(WriterCommand::Resampling { + from: native, + to: closest, + }); } } } @@ -3169,7 +3259,8 @@ fn build_appsink_pipeline( channels: device_channels, bytes_per_sample: bps, }; - let _ = resample_tx.try_send(WriterCommand::FormatHint(hint_fmt)); + let _ = + resample_tx.try_send(WriterCommand::FormatHint(hint_fmt)); } } } @@ -3236,13 +3327,379 @@ fn build_appsink_pipeline( // ── Device enumeration ───────────────────────────────────────────────── -/// Enumerate ALSA hardware devices. Does NOT use the audio pipeline, -/// so it is safe to call from any thread. -pub fn list_alsa_devices() -> Result, String> { - list_alsa_devices_inner() +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct HardwareEndpointKey { + card_id: String, + device: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AudioDeviceSource { + Native, + GStreamer, + Manual, +} + +#[derive(Debug, Clone)] +struct AudioDeviceEntry { + device: AudioDevice, + sort_label: String, + sort_card_label: String, + sort_card_id: String, + sort_device: Option, + card_index: Option, + hardware_key: Option, + source: AudioDeviceSource, +} + +impl AudioDeviceEntry { + fn dedup_key(&self) -> DeviceDedupKey { + if let Some(key) = &self.hardware_key { + DeviceDedupKey::Hardware(key.clone()) + } else { + DeviceDedupKey::Exact(self.device.id.clone()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +enum DeviceDedupKey { + Hardware(HardwareEndpointKey), + Exact(String), +} + +#[derive(Debug, Clone)] +struct NativeCardMeta { + index: i32, + id: String, + name: String, + longname: String, +} + +#[derive(Debug, Clone)] +struct ParsedHwEndpoint { + card_token: String, + device: u32, +} + +struct AudioDeviceDiscovery { + entries: Vec, + gstreamer_count: usize, + native_alsa_count: usize, + manual_count: usize, +} + +fn build_hw_device_id(card_id: &str, device: u32) -> String { + format!("hw:CARD={card_id},DEV={device}") +} + +fn format_device_name(label: &str, id: &str) -> String { + format!("{label} — {id}") +} + +fn label_score(label: &str, source: AudioDeviceSource) -> usize { + let mut score = label.trim().len(); + if label.contains(" / ") { + score += 8; + } + if label.contains(" - ") || label.contains(" | ") { + score += 4; + } + score + + match source { + AudioDeviceSource::Native => 20, + AudioDeviceSource::GStreamer => 10, + AudioDeviceSource::Manual => 0, + } +} + +fn better_audio_device_entry(candidate: &AudioDeviceEntry, current: &AudioDeviceEntry) -> bool { + let candidate_score = label_score(&candidate.sort_label, candidate.source); + let current_score = label_score(¤t.sort_label, current.source); + candidate_score > current_score +} + +fn sort_audio_device_entries(a: &AudioDeviceEntry, b: &AudioDeviceEntry) -> std::cmp::Ordering { + a.sort_card_label + .cmp(&b.sort_card_label) + .then_with(|| a.sort_card_id.cmp(&b.sort_card_id)) + .then_with(|| { + a.sort_device + .unwrap_or(u32::MAX) + .cmp(&b.sort_device.unwrap_or(u32::MAX)) + }) + .then_with(|| a.device.name.cmp(&b.device.name)) +} + +fn parse_hw_endpoint(device_id: &str) -> Option { + let rest = device_id.strip_prefix("hw:")?; + if let Some(card) = rest.strip_prefix("CARD=") { + let idx = card.rfind(",DEV=")?; + let (card_token, device_token) = card.split_at(idx); + let device = device_token.strip_prefix(",DEV=")?.parse().ok()?; + return Some(ParsedHwEndpoint { + card_token: card_token.trim().to_string(), + device, + }); + } + + let idx = rest.rfind(',')?; + let (card_token, device_token) = rest.split_at(idx); + let device = device_token.strip_prefix(',')?.parse().ok()?; + Some(ParsedHwEndpoint { + card_token: card_token.trim().to_string(), + device, + }) +} + +fn resolve_card_id( + card_token: &str, + native_lookup: Option<&HashMap>, +) -> String { + if let Ok(index) = card_token.parse::() { + if let Some(meta) = native_lookup.and_then(|lookup| lookup.get(&index)) { + return meta.id.clone(); + } + } + + card_token.to_string() +} + +fn resolve_hw_endpoint( + parsed: &ParsedHwEndpoint, + native_lookup: Option<&HashMap>, +) -> HardwareEndpointKey { + HardwareEndpointKey { + card_id: resolve_card_id(&parsed.card_token, native_lookup), + device: parsed.device, + } +} + +fn card_display_label(card_name: &str, card_longname: &str, card_id: &str) -> String { + let card_name = card_name.trim(); + let card_longname = card_longname.trim(); + + if !card_name.is_empty() && card_name != card_id { + card_name.to_string() + } else if !card_longname.is_empty() { + card_longname.to_string() + } else { + card_id.to_string() + } +} + +fn pcm_label(card_name: &str, card_longname: &str, pcm_name: &str, card_id: &str) -> String { + let card_label = card_display_label(card_name, card_longname, card_id); + let pcm_name = pcm_name.trim(); + + if !pcm_name.is_empty() && pcm_name != card_label { + format!("{card_label} / {pcm_name}") + } else { + card_label + } +} + +fn native_entry(card: &NativeCardMeta, pcm_device: u32, pcm_name: &str) -> AudioDeviceEntry { + let id = build_hw_device_id(&card.id, pcm_device); + let card_label = card_display_label(&card.name, &card.longname, &card.id); + let label = pcm_label(&card.name, &card.longname, pcm_name, &card.id); + AudioDeviceEntry { + device: AudioDevice { + id, + name: format_device_name(&label, &build_hw_device_id(&card.id, pcm_device)), + }, + sort_label: label, + sort_card_label: card_label, + sort_card_id: card.id.clone(), + sort_device: Some(pcm_device), + card_index: Some(card.index), + hardware_key: Some(HardwareEndpointKey { + card_id: card.id.clone(), + device: pcm_device, + }), + source: AudioDeviceSource::Native, + } +} + +fn gstreamer_entry(display_name: &str, hardware_key: HardwareEndpointKey) -> AudioDeviceEntry { + let id = build_hw_device_id(&hardware_key.card_id, hardware_key.device); + AudioDeviceEntry { + device: AudioDevice { + id, + name: format_device_name( + display_name, + &build_hw_device_id(&hardware_key.card_id, hardware_key.device), + ), + }, + sort_label: display_name.trim().to_string(), + sort_card_label: display_name.trim().to_string(), + sort_card_id: hardware_key.card_id.clone(), + sort_device: Some(hardware_key.device), + card_index: None, + hardware_key: Some(hardware_key), + source: AudioDeviceSource::GStreamer, + } +} + +fn manual_entry(device_id: &str) -> AudioDeviceEntry { + AudioDeviceEntry { + device: AudioDevice { + id: device_id.to_string(), + name: format_device_name("Manual ALSA device", device_id), + }, + sort_label: "Manual ALSA device".to_string(), + sort_card_label: "Manual ALSA device".to_string(), + sort_card_id: device_id.to_string(), + sort_device: None, + card_index: None, + hardware_key: parse_hw_endpoint(device_id).map(|parsed| resolve_hw_endpoint(&parsed, None)), + source: AudioDeviceSource::Manual, + } } -fn list_alsa_devices_inner() -> Result, String> { +fn merge_audio_device_entries(entries: Vec) -> Vec { + let mut merged: HashMap = HashMap::new(); + + for entry in entries { + let key = entry.dedup_key(); + merged + .entry(key) + .and_modify(|current| { + if better_audio_device_entry(&entry, current) { + *current = entry.clone(); + } + }) + .or_insert(entry); + } + + let mut merged: Vec<_> = merged.into_values().collect(); + merged.sort_by(sort_audio_device_entries); + merged.into_iter().map(|entry| entry.device).collect() +} + +fn native_card_lookup(entries: &[AudioDeviceEntry]) -> HashMap { + let mut lookup = HashMap::new(); + for entry in entries { + if let Some(index) = entry.card_index { + if let Some(key) = &entry.hardware_key { + lookup.entry(index).or_insert_with(|| NativeCardMeta { + index, + id: key.card_id.clone(), + name: entry.sort_card_label.clone(), + longname: entry.sort_card_label.clone(), + }); + } + } + } + lookup +} + +#[cfg(target_os = "linux")] +fn list_native_alsa_playback_devices() -> Result, String> { + let mut result = Vec::new(); + + for card_result in alsa::card::Iter::new() { + let card = match card_result { + Ok(card) => card, + Err(e) => { + log::warn!("[native-alsa] card iteration failed: {e}"); + continue; + } + }; + + let ctl = match alsa::Ctl::from_card(&card, false) { + Ok(ctl) => ctl, + Err(e) => { + log::warn!( + "[native-alsa] failed to open control for card {}: {e}", + card.get_index() + ); + continue; + } + }; + + let info = match ctl.card_info() { + Ok(info) => info, + Err(e) => { + log::warn!( + "[native-alsa] failed to query card info for {}: {e}", + card.get_index() + ); + continue; + } + }; + + let meta = NativeCardMeta { + index: info.get_card().get_index(), + id: match info.get_id() { + Ok(id) => id.to_string(), + Err(e) => { + log::warn!( + "[native-alsa] card id query failed for {}: {e}", + card.get_index() + ); + continue; + } + }, + name: match info.get_name() { + Ok(name) => name.to_string(), + Err(e) => { + log::warn!( + "[native-alsa] card name query failed for {}: {e}", + card.get_index() + ); + continue; + } + }, + longname: match info.get_longname() { + Ok(longname) => longname.to_string(), + Err(e) => { + log::warn!( + "[native-alsa] card longname query failed for {}: {e}", + card.get_index() + ); + continue; + } + }, + }; + + log::debug!( + "[native-alsa] card index={} id={} name=\"{}\"", + meta.index, + meta.id, + meta.name + ); + + for device in alsa::ctl::DeviceIter::new(&ctl) { + let Ok(pcm_info) = ctl.pcm_info(device as u32, 0, alsa::Direction::Playback) else { + continue; + }; + + let pcm_name = pcm_info.get_name().unwrap_or("").to_string(); + let pcm_device = pcm_info.get_device(); + let entry = native_entry(&meta, pcm_device, &pcm_name); + log::debug!( + "[native-alsa] playback pcm card={} device={} id={} name=\"{}\"", + meta.id, + pcm_device, + entry.device.id, + pcm_name + ); + result.push(entry); + } + } + + Ok(result) +} + +#[cfg(not(target_os = "linux"))] +fn list_native_alsa_playback_devices() -> Result, String> { + Ok(Vec::new()) +} + +fn list_gstreamer_audio_devices( + native_lookup: Option<&HashMap>, +) -> Result, String> { gst::init().map_err(|e| format!("GStreamer init failed: {e}"))?; let monitor = gst::DeviceMonitor::new(); let caps = gst::Caps::new_empty_simple("audio/x-raw"); @@ -3251,8 +3708,6 @@ fn list_alsa_devices_inner() -> Result, String> { .start() .map_err(|e| format!("Failed to start device monitor: {e}"))?; - // GStreamer 1.28+ starts providers async, so devices() may initially be empty. - // On older versions start() blocks and devices are available immediately. let devices = { let mut devs = monitor.devices(); let mut waited = 0u32; @@ -3267,7 +3722,7 @@ fn list_alsa_devices_inner() -> Result, String> { monitor.stop(); log::debug!( - "[list_alsa_devices] DeviceMonitor found {} devices", + "[list_gstreamer_audio_devices] DeviceMonitor found {} devices", devices.len() ); @@ -3282,23 +3737,95 @@ fn list_alsa_devices_inner() -> Result, String> { continue; } - let path = props.get::("api.alsa.path").ok().or_else(|| { - let card = props.get::("alsa.card").ok()?; - let dev_num = props.get::("alsa.device").ok()?; - Some(format!("hw:{card},{dev_num}")) - }); + let parsed = props + .get::("api.alsa.path") + .ok() + .as_deref() + .and_then(parse_hw_endpoint); - if let Some(path) = path { - let name = dev.display_name().to_string(); - log::debug!("[list_alsa_devices] found: '{}' -> {}", name, path); - result.push(AudioDevice { id: path, name }); - } + let Some(parsed) = parsed else { + continue; + }; + + let hardware_key = resolve_hw_endpoint(&parsed, native_lookup); + let display_name = dev.display_name().to_string(); + log::debug!( + "[list_gstreamer_audio_devices] found: '{}' -> {}", + display_name, + build_hw_device_id(&hardware_key.card_id, hardware_key.device) + ); + result.push(gstreamer_entry(&display_name, hardware_key)); } - log::debug!("[list_alsa_devices] returning {} devices", result.len()); + log::debug!( + "[list_gstreamer_audio_devices] returning {} devices", + result.len() + ); Ok(result) } +fn list_audio_device_entries(manual_device: Option<&str>) -> AudioDeviceDiscovery { + let native_entries = match list_native_alsa_playback_devices() { + Ok(entries) => entries, + Err(e) => { + log::warn!("[native-alsa] enumeration failed: {e}"); + Vec::new() + } + }; + let native_lookup = native_card_lookup(&native_entries); + + let gstreamer_entries = match list_gstreamer_audio_devices(Some(&native_lookup)) { + Ok(entries) => entries, + Err(e) => { + log::warn!("[list_gstreamer_audio_devices] enumeration failed: {e}"); + Vec::new() + } + }; + let gstreamer_count = gstreamer_entries.len(); + let native_count = native_entries.len(); + + let mut all_entries = Vec::new(); + all_entries.extend(gstreamer_entries); + all_entries.extend(native_entries); + + let manual_entries = manual_device + .map(str::trim) + .filter(|device| !device.is_empty()) + .map(manual_entry); + let manual_count = manual_entries.as_ref().map(|_| 1).unwrap_or(0); + if let Some(entry) = manual_entries { + all_entries.push(entry); + } + + AudioDeviceDiscovery { + entries: all_entries, + gstreamer_count, + native_alsa_count: native_count, + manual_count, + } +} + +/// Enumerate ALSA hardware devices. Does NOT use the audio pipeline, +/// so it is safe to call from any thread. +#[allow(dead_code)] +pub fn list_alsa_devices() -> Vec { + list_alsa_devices_with_override(None) +} + +pub fn list_alsa_devices_with_override(manual_device: Option<&str>) -> Vec { + let discovery = list_audio_device_entries(manual_device); + let devices = merge_audio_device_entries(discovery.entries); + log::debug!( + "[audio-devices] gstreamer={} native_alsa={} manual={} merged={}", + discovery.gstreamer_count, + discovery.native_alsa_count, + discovery.manual_count, + devices.len(), + ); + + devices +} + /// Gapless (2b architecture) needs the `concat` element. The chain is legacy /// `uridecodebin` then a per-branch `queue` then `concat`, which handle Tidal /// `data:application/dash+xml` on any GStreamer with the legacy dash demuxer @@ -3307,3 +3834,258 @@ fn list_alsa_devices_inner() -> Result, String> { pub fn gapless_supported() -> bool { gst::ElementFactory::find("concat").is_some() } + +#[cfg(test)] +mod tests { + use super::{ + build_hw_device_id, merge_audio_device_entries, parse_hw_endpoint, AudioDevice, + AudioDeviceEntry, AudioDeviceSource, HardwareEndpointKey, + }; + + fn native( + card_index: i32, + card_id: &str, + card_name: &str, + card_longname: &str, + device: u32, + pcm_name: &str, + ) -> AudioDeviceEntry { + let id = build_hw_device_id(card_id, device); + let card_label = super::card_display_label(card_name, card_longname, card_id); + let label = super::pcm_label(card_name, card_longname, pcm_name, card_id); + AudioDeviceEntry { + device: AudioDevice { + id: id.clone(), + name: format!("{label} — {id}"), + }, + sort_label: label, + sort_card_label: card_label, + sort_card_id: card_id.to_string(), + sort_device: Some(device), + card_index: Some(card_index), + hardware_key: Some(HardwareEndpointKey { + card_id: card_id.to_string(), + device, + }), + source: AudioDeviceSource::Native, + } + } + + fn gstreamer(id: &str, card_id: &str, device: u32, label: &str) -> AudioDeviceEntry { + AudioDeviceEntry { + device: AudioDevice { + id: id.to_string(), + name: format!("{label} — {id}"), + }, + sort_label: label.to_string(), + sort_card_label: label.to_string(), + sort_card_id: card_id.to_string(), + sort_device: Some(device), + card_index: None, + hardware_key: Some(HardwareEndpointKey { + card_id: card_id.to_string(), + device, + }), + source: AudioDeviceSource::GStreamer, + } + } + + fn manual(id: &str) -> AudioDeviceEntry { + AudioDeviceEntry { + device: AudioDevice { + id: id.to_string(), + name: format!("Manual ALSA device — {id}"), + }, + sort_label: "Manual ALSA device".to_string(), + sort_card_label: "Manual ALSA device".to_string(), + sort_card_id: id.to_string(), + sort_device: None, + card_index: None, + hardware_key: parse_hw_endpoint(id).map(|parsed| HardwareEndpointKey { + card_id: parsed.card_token, + device: parsed.device, + }), + source: AudioDeviceSource::Manual, + } + } + + #[test] + fn merges_gstreamer_and_native_results() { + let merged = merge_audio_device_entries(vec![ + gstreamer("hw:CARD=Q1,DEV=0", "Q1", 0, "Q1"), + native(3, "Q1", "FiiO Q1", "FiiO Q1", 0, "USB Audio"), + ]); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].id, "hw:CARD=Q1,DEV=0"); + assert_eq!(merged[0].name, "FiiO Q1 / USB Audio — hw:CARD=Q1,DEV=0"); + } + + #[test] + fn native_only_results_stay_visible() { + let merged = merge_audio_device_entries(vec![native( + 1, + "PCH", + "HDA Intel PCH", + "HDA Intel PCH", + 0, + "ALC1220 Analog", + )]); + + assert_eq!( + merged, + vec![AudioDevice { + id: "hw:CARD=PCH,DEV=0".to_string(), + name: "HDA Intel PCH / ALC1220 Analog — hw:CARD=PCH,DEV=0".to_string(), + }] + ); + } + + #[test] + fn gstreamer_only_results_stay_visible() { + let merged = merge_audio_device_entries(vec![gstreamer( + "hw:CARD=CODEC,DEV=0", + "CODEC", + 0, + "USB Audio CODEC / USB Audio", + )]); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].id, "hw:CARD=CODEC,DEV=0"); + } + + #[test] + fn preserves_multiple_cards_and_pcm_devices() { + let merged = merge_audio_device_entries(vec![ + native( + 0, + "PCH", + "HDA Intel PCH", + "HDA Intel PCH", + 0, + "ALC1220 Analog", + ), + native( + 1, + "CODEC", + "USB Audio CODEC", + "USB Audio CODEC", + 0, + "USB Audio", + ), + native( + 1, + "CODEC", + "USB Audio CODEC", + "USB Audio CODEC", + 1, + "S/PDIF", + ), + ]); + + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].id, "hw:CARD=PCH,DEV=0"); + assert_eq!(merged[1].id, "hw:CARD=CODEC,DEV=0"); + assert_eq!(merged[2].id, "hw:CARD=CODEC,DEV=1"); + } + + #[test] + fn sorts_predictably_by_label_card_id_device_then_name() { + let merged = merge_audio_device_entries(vec![ + native(2, "B", "Bravo", "Bravo", 1, "Beta"), + native(1, "A", "Alpha", "Alpha", 0, "Alpha"), + native(3, "A", "Alpha", "Alpha", 2, "Gamma"), + ]); + + assert_eq!( + merged.iter().map(|d| d.id.as_str()).collect::>(), + vec!["hw:CARD=A,DEV=0", "hw:CARD=A,DEV=2", "hw:CARD=B,DEV=1"] + ); + } + + #[test] + fn deduplicates_semantic_hw_forms() { + let merged = merge_audio_device_entries(vec![ + AudioDeviceEntry { + device: AudioDevice { + id: "hw:3,0".to_string(), + name: "Generic hw:3,0 — hw:3,0".to_string(), + }, + sort_label: "Generic hw".to_string(), + sort_card_label: "Generic hw".to_string(), + sort_card_id: "Q1".to_string(), + sort_device: Some(0), + card_index: Some(3), + hardware_key: Some(HardwareEndpointKey { + card_id: "Q1".to_string(), + device: 0, + }), + source: AudioDeviceSource::GStreamer, + }, + native(3, "Q1", "FiiO Q1", "FiiO Q1", 0, "USB Audio"), + ]); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].id, "hw:CARD=Q1,DEV=0"); + } + + #[test] + fn manual_override_is_inserted_when_missing() { + let merged = merge_audio_device_entries(vec![ + native(1, "PCH", "HDA Intel PCH", "HDA Intel PCH", 0, "Analog"), + manual("hw:CARD=Q1,DEV=0"), + ]); + + assert!(merged.iter().any(|d| d.id == "hw:CARD=Q1,DEV=0")); + assert!(merged + .iter() + .any(|d| d.name == "Manual ALSA device — hw:CARD=Q1,DEV=0")); + } + + #[test] + fn manual_override_is_not_duplicated_when_present() { + let merged = merge_audio_device_entries(vec![ + native(1, "Q1", "FiiO Q1", "FiiO Q1", 0, "USB Audio"), + manual("hw:CARD=Q1,DEV=0"), + ]); + + assert_eq!(merged.len(), 1); + } + + #[test] + fn raw_hardware_id_parsing_accepts_card_and_device_forms() { + let parsed = parse_hw_endpoint("hw:CARD=Q1,DEV=0").expect("should parse"); + assert_eq!(parsed.card_token, "Q1"); + assert_eq!(parsed.device, 0); + + let parsed = parse_hw_endpoint("hw:Q1,1").expect("should parse"); + assert_eq!(parsed.card_token, "Q1"); + assert_eq!(parsed.device, 1); + } + + #[test] + fn raw_hardware_id_parsing_rejects_virtual_aliases() { + assert!(parse_hw_endpoint("default").is_none()); + assert!(parse_hw_endpoint("plughw:CARD=Q1,DEV=0").is_none()); + assert!(parse_hw_endpoint("dmix").is_none()); + } + + #[test] + fn supports_unusual_card_ids_and_punctuation() { + let merged = merge_audio_device_entries(vec![native( + 4, + "USB-Audio.1", + "USB Audio", + "USB Audio", + 0, + "USB Audio", + )]); + + assert_eq!(merged[0].id, "hw:CARD=USB-Audio.1,DEV=0"); + } + + #[test] + fn build_hw_device_id_uses_stable_card_id_format() { + assert_eq!(build_hw_device_id("Q1", 0), "hw:CARD=Q1,DEV=0"); + } +} diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index 683d0973..0789f54b 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -473,11 +473,12 @@ pub async fn logout(state: State<'_, AppState>) -> Result<(), SoneError> { /// purges them) and does NOT lock `tidal_client` (callers hold that lock). pub(crate) async fn restore_session_services(app: &AppHandle) { let state = app.state::(); - let discord_rpc = state.load_settings().map(|s| s.discord_rpc).unwrap_or(false); + let discord_rpc = state + .load_settings() + .map(|s| s.discord_rpc) + .unwrap_or(false); if discord_rpc { - state - .discord - .send(crate::discord::DiscordCommand::Connect); + state.discord.send(crate::discord::DiscordCommand::Connect); } crate::mcp::ensure_mcp_started(app).await; } diff --git a/src-tauri/src/commands/library.rs b/src-tauri/src/commands/library.rs index 3fe9d86f..52b3cc67 100644 --- a/src-tauri/src/commands/library.rs +++ b/src-tauri/src/commands/library.rs @@ -105,7 +105,9 @@ pub async fn get_all_playlists( ) -> Result, SoneError> { log::debug!( "[get_all_playlists]: user_id={}, offset={}, limit={}", - user_id, offset, limit + user_id, + offset, + limit ); let cache_key = format!( @@ -124,16 +126,10 @@ pub async fn get_all_playlists( } CacheResult::Stale(bytes) => { if let Ok(data) = - serde_json::from_slice::>( - &bytes, - ) + serde_json::from_slice::>(&bytes) { if state.disk_cache.mark_in_flight(&cache_key).await { - if state - .disk_cache - .should_retry_refresh(&cache_key, 300) - .await - { + if state.disk_cache.should_retry_refresh(&cache_key, 300).await { state.disk_cache.mark_refresh_attempt(&cache_key).await; let handle = app_handle.clone(); let key = cache_key.clone(); @@ -154,10 +150,7 @@ pub async fn get_all_playlists( &key, &json, CacheTier::UserContent, - &[ - "all-playlists", - &format!("user:{}", user_id), - ], + &["all-playlists", &format!("user:{}", user_id)], ) .await .ok(); @@ -497,7 +490,10 @@ pub async fn get_favorite_albums( order_direction ); - let cache_key = format!("fav-albums:{}:{}:{}:{}:{}", user_id, offset, limit, order, order_direction); + let cache_key = format!( + "fav-albums:{}:{}:{}:{}:{}", + user_id, offset, limit, order, order_direction + ); match state .disk_cache .get(&cache_key, CacheTier::UserContent) @@ -524,7 +520,9 @@ pub async fn get_favorite_albums( let st = handle.state::(); let result = { let mut client = st.tidal_client.lock().await; - client.get_favorite_albums(user_id, offset, limit, &order_bg, &dir_bg).await + client + .get_favorite_albums(user_id, offset, limit, &order_bg, &dir_bg) + .await }; if let Ok(fresh) = result { if let Ok(json) = serde_json::to_vec(&fresh) { @@ -552,7 +550,9 @@ pub async fn get_favorite_albums( } let mut client = state.tidal_client.lock().await; - let data = client.get_favorite_albums(user_id, offset, limit, &order, &order_direction).await?; + let data = client + .get_favorite_albums(user_id, offset, limit, &order, &order_direction) + .await?; drop(client); if let Ok(json) = serde_json::to_vec(&data) { @@ -577,7 +577,11 @@ pub async fn create_playlist( description: String, access_type: String, ) -> Result { - log::debug!("[create_playlist]: title={}, access_type={}", title, access_type); + log::debug!( + "[create_playlist]: title={}, access_type={}", + title, + access_type + ); let client = state.tidal_client.lock().await; let playlist = client .create_playlist(&title, &description, &access_type) @@ -586,7 +590,10 @@ pub async fn create_playlist( let user_id = client.tokens.as_ref().and_then(|t| t.user_id); drop(client); if let Some(uid) = user_id { - state.disk_cache.invalidate_tag(&format!("user:{}", uid)).await; + state + .disk_cache + .invalidate_tag(&format!("user:{}", uid)) + .await; } state.disk_cache.invalidate_tag("folders").await; Ok(playlist) @@ -600,7 +607,11 @@ pub async fn update_playlist( description: String, access_type: String, ) -> Result { - log::debug!("[update_playlist]: playlist_id={}, title={}", playlist_id, title); + log::debug!( + "[update_playlist]: playlist_id={}, title={}", + playlist_id, + title + ); let client = state.tidal_client.lock().await; let playlist = client .update_playlist(&playlist_id, &title, &description, &access_type) @@ -608,7 +619,10 @@ pub async fn update_playlist( let user_id = client.tokens.as_ref().and_then(|t| t.user_id); drop(client); if let Some(uid) = user_id { - state.disk_cache.invalidate_tag(&format!("user:{}", uid)).await; + state + .disk_cache + .invalidate_tag(&format!("user:{}", uid)) + .await; } state.disk_cache.invalidate_tag("folders").await; Ok(playlist) @@ -1112,7 +1126,10 @@ pub async fn get_favorite_mixes( order_direction ); - let cache_key = format!("fav-mixes:{}:{}:{}:{}", offset, limit, order, order_direction); + let cache_key = format!( + "fav-mixes:{}:{}:{}:{}", + offset, limit, order, order_direction + ); match state .disk_cache .get(&cache_key, CacheTier::UserContent) @@ -1139,7 +1156,9 @@ pub async fn get_favorite_mixes( let st = handle.state::(); let result = { let mut client = st.tidal_client.lock().await; - client.get_favorite_mixes(offset, limit, &order_bg, &dir_bg).await + client + .get_favorite_mixes(offset, limit, &order_bg, &dir_bg) + .await }; if let Ok(fresh) = result { if let Ok(json) = serde_json::to_vec(&fresh) { @@ -1162,7 +1181,9 @@ pub async fn get_favorite_mixes( } let mut client = state.tidal_client.lock().await; - let data = client.get_favorite_mixes(offset, limit, &order, &order_direction).await?; + let data = client + .get_favorite_mixes(offset, limit, &order, &order_direction) + .await?; drop(client); if let Ok(json) = serde_json::to_vec(&data) { @@ -1218,7 +1239,10 @@ pub async fn get_favorite_artists( order_direction ); - let cache_key = format!("fav-artists:{}:{}:{}:{}:{}", user_id, offset, limit, order, order_direction); + let cache_key = format!( + "fav-artists:{}:{}:{}:{}:{}", + user_id, offset, limit, order, order_direction + ); match state .disk_cache .get(&cache_key, CacheTier::UserContent) @@ -1245,7 +1269,11 @@ pub async fn get_favorite_artists( let st = handle.state::(); let result = { let mut client = st.tidal_client.lock().await; - client.get_favorite_artists(user_id, offset, limit, &order_bg, &dir_bg).await + client + .get_favorite_artists( + user_id, offset, limit, &order_bg, &dir_bg, + ) + .await }; if let Ok(fresh) = result { if let Ok(json) = serde_json::to_vec(&fresh) { @@ -1273,7 +1301,9 @@ pub async fn get_favorite_artists( } let mut client = state.tidal_client.lock().await; - let data = client.get_favorite_artists(user_id, offset, limit, &order, &order_direction).await?; + let data = client + .get_favorite_artists(user_id, offset, limit, &order, &order_direction) + .await?; drop(client); if let Ok(json) = serde_json::to_vec(&data) { @@ -1329,11 +1359,7 @@ pub async fn get_playlist_folders( CacheResult::Stale(bytes) => { if let Ok(val) = serde_json::from_slice::(&bytes) { if state.disk_cache.mark_in_flight(&cache_key).await { - if state - .disk_cache - .should_retry_refresh(&cache_key, 300) - .await - { + if state.disk_cache.should_retry_refresh(&cache_key, 300).await { state.disk_cache.mark_refresh_attempt(&cache_key).await; let handle = app_handle.clone(); let key = cache_key.clone(); @@ -1373,10 +1399,7 @@ pub async fn get_playlist_folders( } } Err(e) => { - log::warn!( - "[get_playlist_folders] bg refresh failed: {}", - e - ); + log::warn!("[get_playlist_folders] bg refresh failed: {}", e); } } st.disk_cache.clear_in_flight(&key).await; diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 2a326d36..480c6d65 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -1,9 +1,9 @@ use serde::Serialize; use tauri::State; -use crate::AppState; use crate::error::SoneError; use crate::mcp::{NowPlayingSnapshot, QueueTrackSnapshot}; +use crate::AppState; #[derive(Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/commands/metadata.rs b/src-tauri/src/commands/metadata.rs index 0c48659e..3303265d 100644 --- a/src-tauri/src/commands/metadata.rs +++ b/src-tauri/src/commands/metadata.rs @@ -84,4 +84,3 @@ pub async fn get_track_credits( } Ok(credits) } - diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index a4a666fc..5aa091a4 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -151,7 +151,9 @@ pub async fn play_tidal_track( resolve_play_uri(state.inner(), track_id, use_track_gain).await?; // Store selected values for live toggle - state.last_replay_gain.store(rg.to_bits(), Ordering::Relaxed); + state + .last_replay_gain + .store(rg.to_bits(), Ordering::Relaxed); state .last_peak_amplitude .store(peak.to_bits(), Ordering::Relaxed); @@ -163,9 +165,9 @@ pub async fn play_tidal_track( player.set_normalization_gain(norm_gain)?; player.play_url(&uri) }) - .await - .map_err(|e| SoneError::Audio(e.to_string()))? - .map_err(SoneError::Audio)?; + .await + .map_err(|e| SoneError::Audio(e.to_string()))? + .map_err(SoneError::Audio)?; // Save last played track if let Some(mut settings) = state.load_settings() { @@ -194,7 +196,10 @@ pub async fn set_next_track( #[tauri::command] pub fn clear_next_track(state: State<'_, AppState>) -> Result<(), SoneError> { - state.audio_player.clear_next_track().map_err(SoneError::Audio) + state + .audio_player + .clear_next_track() + .map_err(SoneError::Audio) } /// Pure resolver: returns `StreamInfo` WITHOUT arming the gapless slot or @@ -419,10 +424,7 @@ pub fn update_mpris_playback_status( #[tauri::command] #[allow(unused_variables)] -pub fn update_mpris_shuffle( - state: State<'_, AppState>, - enabled: bool, -) -> Result<(), SoneError> { +pub fn update_mpris_shuffle(state: State<'_, AppState>, enabled: bool) -> Result<(), SoneError> { #[cfg(target_os = "linux")] state .mpris @@ -445,10 +447,7 @@ pub fn update_mpris_fullscreen( #[tauri::command] #[allow(unused_variables)] -pub fn update_mpris_loop_status( - state: State<'_, AppState>, - mode: u8, -) -> Result<(), SoneError> { +pub fn update_mpris_loop_status(state: State<'_, AppState>, mode: u8) -> Result<(), SoneError> { #[cfg(target_os = "linux")] state .mpris @@ -471,7 +470,10 @@ mod tests { #[test] fn ceiling_max_without_secret_drops_hires() { // Reproduces the legacy no-secret branch exactly. - assert_eq!(quality_tiers("HI_RES_LOSSLESS", false), vec!["LOSSLESS", "HIGH"]); + assert_eq!( + quality_tiers("HI_RES_LOSSLESS", false), + vec!["LOSSLESS", "HIGH"] + ); } #[test] @@ -499,7 +501,10 @@ mod tests { for ceiling in ["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "GARBAGE"] { for has_secret in [true, false] { let tiers = quality_tiers(ceiling, has_secret); - assert!(tiers.contains(&"HIGH"), "ceiling={ceiling} secret={has_secret}"); + assert!( + tiers.contains(&"HIGH"), + "ceiling={ceiling} secret={has_secret}" + ); } } } diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs index cc7d2184..aaf6587b 100644 --- a/src-tauri/src/commands/profile.rs +++ b/src-tauri/src/commands/profile.rs @@ -20,7 +20,11 @@ pub async fn update_profile_meta( handle: Option, dry_run: bool, ) -> Result<(), SoneError> { - log::debug!("[update_profile_meta]: artist_id={}, dry_run={}", artist_id, dry_run); + log::debug!( + "[update_profile_meta]: artist_id={}, dry_run={}", + artist_id, + dry_run + ); let client = state.tidal_client.lock().await; client .update_artist_meta(artist_id, name.as_deref(), handle.as_deref(), dry_run) @@ -44,7 +48,11 @@ pub async fn update_profile_links( artist_id: u64, links: Vec, ) -> Result<(), SoneError> { - log::debug!("[update_profile_links]: artist_id={}, n={}", artist_id, links.len()); + log::debug!( + "[update_profile_links]: artist_id={}, n={}", + artist_id, + links.len() + ); let client = state.tidal_client.lock().await; client.update_external_links(artist_id, links).await } diff --git a/src-tauri/src/commands/utility.rs b/src-tauri/src/commands/utility.rs index e06322b2..51a5ac94 100644 --- a/src-tauri/src/commands/utility.rs +++ b/src-tauri/src/commands/utility.rs @@ -289,11 +289,18 @@ pub fn list_audio_devices(state: State<'_, AppState>) -> Result } // First call: probe directly (not via audio thread) and cache - let devices = crate::audio::list_alsa_devices().map_err(SoneError::Audio)?; + let devices = + crate::audio::list_alsa_devices_with_override(state.alsa_device_override.as_deref()); *state.cached_audio_devices.lock().unwrap() = Some(devices.clone()); Ok(devices) } +#[tauri::command] +pub fn refresh_audio_devices(state: State<'_, AppState>) -> Result, SoneError> { + *state.cached_audio_devices.lock().unwrap() = None; + list_audio_devices(state) +} + #[tauri::command] pub fn get_discord_rpc(state: State<'_, AppState>) -> bool { state @@ -305,9 +312,7 @@ pub fn get_discord_rpc(state: State<'_, AppState>) -> bool { #[tauri::command] pub fn set_discord_rpc(state: State<'_, AppState>, enabled: bool) -> Result<(), SoneError> { if enabled { - state - .discord - .send(crate::discord::DiscordCommand::Connect); + state.discord.send(crate::discord::DiscordCommand::Connect); } else { state .discord @@ -341,10 +346,7 @@ pub fn set_discord_status_text(state: State<'_, AppState>, text: String) -> Resu #[tauri::command] pub fn get_proxy_settings(state: State<'_, AppState>) -> crate::ProxySettings { - state - .load_settings() - .map(|s| s.proxy) - .unwrap_or_default() + state.load_settings().map(|s| s.proxy).unwrap_or_default() } #[tauri::command] @@ -388,9 +390,7 @@ pub async fn uninhibit_idle(state: State<'_, AppState>) -> Result<(), SoneError> } #[tauri::command] -pub async fn test_proxy_connection( - settings: crate::ProxySettings, -) -> Result { +pub async fn test_proxy_connection(settings: crate::ProxySettings) -> Result { let client = crate::tidal_api::build_http_client(&settings) .map_err(|e| format!("Failed to create client: {e}"))?; diff --git a/src-tauri/src/idle_inhibit/dbus.rs b/src-tauri/src/idle_inhibit/dbus.rs index d6bb6cc3..905a0fed 100644 --- a/src-tauri/src/idle_inhibit/dbus.rs +++ b/src-tauri/src/idle_inhibit/dbus.rs @@ -19,8 +19,13 @@ trait ScreenSaver { )] trait GnomeSessionManager { // flags: 8 = inhibit idle (screen blanking). xid 0 = no toplevel. - fn inhibit(&self, app_id: &str, toplevel_xid: u32, reason: &str, flags: u32) - -> zbus::Result; + fn inhibit( + &self, + app_id: &str, + toplevel_xid: u32, + reason: &str, + flags: u32, + ) -> zbus::Result; fn uninhibit(&self, cookie: u32) -> zbus::Result<()>; } @@ -30,8 +35,13 @@ trait GnomeSessionManager { default_path = "/org/freedesktop/login1" )] trait Login1Manager { - fn inhibit(&self, what: &str, who: &str, why: &str, mode: &str) - -> zbus::Result; + fn inhibit( + &self, + what: &str, + who: &str, + why: &str, + mode: &str, + ) -> zbus::Result; } #[zbus::proxy( @@ -62,7 +72,12 @@ pub struct DbusInhibitor { impl DbusInhibitor { pub fn new() -> Self { - Self { screensaver: None, gnome: None, sleep_fd: None, portal_conn: None } + Self { + screensaver: None, + gnome: None, + sleep_fd: None, + portal_conn: None, + } } /// Inhibit screen-off via the session bus. Returns true if at least one @@ -81,7 +96,10 @@ impl DbusInhibitor { } } if let Ok(proxy) = GnomeSessionManagerProxy::new(&conn).await { - match proxy.inhibit("org.sone.app", 0, "Fullscreen playback", 8).await { + match proxy + .inhibit("org.sone.app", 0, "Fullscreen playback", 8) + .await + { Ok(cookie) => { log::info!("GNOME SessionManager inhibited (cookie={cookie})"); self.gnome = Some((conn, cookie)); @@ -102,8 +120,13 @@ impl DbusInhibitor { log::warn!("system bus unavailable for sleep inhibit"); return; }; - let Ok(proxy) = Login1ManagerProxy::new(&conn).await else { return }; - match proxy.inhibit("idle:sleep", "sone", "Fullscreen playback", "block").await { + let Ok(proxy) = Login1ManagerProxy::new(&conn).await else { + return; + }; + match proxy + .inhibit("idle:sleep", "sone", "Fullscreen playback", "block") + .await + { Ok(fd) => { log::info!("Sleep inhibited via logind"); self.sleep_fd = Some(fd.into()); @@ -115,8 +138,12 @@ impl DbusInhibitor { /// Last-resort fallback: XDG portal Inhibit (suspend|idle = 12). /// Inhibition lives as long as the connection. pub async fn inhibit_portal(&mut self) -> bool { - let Ok(conn) = zbus::Connection::session().await else { return false }; - let Ok(proxy) = PortalInhibitProxy::new(&conn).await else { return false }; + let Ok(conn) = zbus::Connection::session().await else { + return false; + }; + let Ok(proxy) = PortalInhibitProxy::new(&conn).await else { + return false; + }; let mut options = std::collections::HashMap::new(); options.insert("reason", zbus::zvariant::Value::from("Fullscreen playback")); match proxy.inhibit("", 4 | 8, options).await { diff --git a/src-tauri/src/idle_inhibit/mod.rs b/src-tauri/src/idle_inhibit/mod.rs index 423643a4..1f808ee5 100644 --- a/src-tauri/src/idle_inhibit/mod.rs +++ b/src-tauri/src/idle_inhibit/mod.rs @@ -113,7 +113,10 @@ mod tests { #[test] fn wayland_wins_when_both_set() { - assert_eq!(DisplayServer::detect_from(true, true), DisplayServer::Wayland); + assert_eq!( + DisplayServer::detect_from(true, true), + DisplayServer::Wayland + ); } #[test] @@ -123,11 +126,17 @@ mod tests { #[test] fn wayland_when_only_wayland_set() { - assert_eq!(DisplayServer::detect_from(true, false), DisplayServer::Wayland); + assert_eq!( + DisplayServer::detect_from(true, false), + DisplayServer::Wayland + ); } #[test] fn unknown_when_neither_set() { - assert_eq!(DisplayServer::detect_from(false, false), DisplayServer::Unknown); + assert_eq!( + DisplayServer::detect_from(false, false), + DisplayServer::Unknown + ); } } diff --git a/src-tauri/src/idle_inhibit/wayland.rs b/src-tauri/src/idle_inhibit/wayland.rs index 4a47351e..43688a3d 100644 --- a/src-tauri/src/idle_inhibit/wayland.rs +++ b/src-tauri/src/idle_inhibit/wayland.rs @@ -8,8 +8,7 @@ use wayland_client::protocol::wl_registry::WlRegistry; use wayland_client::protocol::wl_surface::WlSurface; use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; use wayland_protocols::wp::idle_inhibit::zv1::client::{ - zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1, - zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1, + zwp_idle_inhibit_manager_v1::ZwpIdleInhibitManagerV1, zwp_idle_inhibitor_v1::ZwpIdleInhibitorV1, }; /// Minimal dispatch sink. We only ever send requests (bind, create_inhibitor, @@ -17,18 +16,37 @@ use wayland_protocols::wp::idle_inhibit::zv1::client::{ struct State; impl Dispatch for State { - fn event(_: &mut Self, _: &WlRegistry, _: ::Event, - _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} + fn event( + _: &mut Self, + _: &WlRegistry, + _: ::Event, + _: &GlobalListContents, + _: &Connection, + _: &QueueHandle, + ) { + } } impl Dispatch for State { - fn event(_: &mut Self, _: &ZwpIdleInhibitManagerV1, - _: ::Event, _: &(), - _: &Connection, _: &QueueHandle) {} + fn event( + _: &mut Self, + _: &ZwpIdleInhibitManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } } impl Dispatch for State { - fn event(_: &mut Self, _: &ZwpIdleInhibitorV1, - _: ::Event, _: &(), - _: &Connection, _: &QueueHandle) {} + fn event( + _: &mut Self, + _: &ZwpIdleInhibitorV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } } /// All fields are Send (wayland-client sys objects are Send+Sync). Only the @@ -52,9 +70,8 @@ impl WaylandInhibitor { let display_ptr = unsafe { gdk_wayland_sys::gdk_wayland_display_get_wl_display(display_ptr_gdk as *mut _) }; - let surface_ptr = unsafe { - gdk_wayland_sys::gdk_wayland_window_get_wl_surface(window_ptr_gdk as *mut _) - }; + let surface_ptr = + unsafe { gdk_wayland_sys::gdk_wayland_window_get_wl_surface(window_ptr_gdk as *mut _) }; if display_ptr.is_null() || surface_ptr.is_null() { log::warn!("GDK wl_display/wl_surface unavailable (not realized or not Wayland)"); return None; @@ -65,17 +82,16 @@ impl WaylandInhibitor { let (globals, mut queue) = registry_queue_init::(&conn).ok()?; let qh = queue.handle(); - let manager: ZwpIdleInhibitManagerV1 = match globals.bind::(&qh, 1..=1, ()) { - Ok(m) => m, - Err(e) => { - log::warn!("compositor has no zwp_idle_inhibit_manager_v1: {e}"); - return None; - } - }; + let manager: ZwpIdleInhibitManagerV1 = + match globals.bind::(&qh, 1..=1, ()) { + Ok(m) => m, + Err(e) => { + log::warn!("compositor has no zwp_idle_inhibit_manager_v1: {e}"); + return None; + } + }; - let id = unsafe { - ObjectId::from_ptr(WlSurface::interface(), surface_ptr as *mut _).ok()? - }; + let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface_ptr as *mut _).ok()? }; let surface = WlSurface::from_id(&conn, id).ok()?; let inhibitor = manager.create_inhibitor(&surface, &qh, ()); @@ -84,16 +100,22 @@ impl WaylandInhibitor { let _ = queue.roundtrip(&mut state); log::info!("Wayland idle inhibitor created"); - Some(Self { _conn: conn, inhibitor }) + Some(Self { + _conn: conn, + inhibitor, + }) } /// Public entry: hops to the GTK main thread to build the inhibitor. pub fn start(window: &tauri::WebviewWindow) -> Option { let (tx, rx) = mpsc::channel::>(); let win = window.clone(); - if window.run_on_main_thread(move || { - let _ = tx.send(WaylandInhibitor::build(&win)); - }).is_err() { + if window + .run_on_main_thread(move || { + let _ = tx.send(WaylandInhibitor::build(&win)); + }) + .is_err() + { return None; } match rx.recv_timeout(std::time::Duration::from_secs(5)) { diff --git a/src-tauri/src/idle_inhibit/x11.rs b/src-tauri/src/idle_inhibit/x11.rs index fbcff209..641d1b3c 100644 --- a/src-tauri/src/idle_inhibit/x11.rs +++ b/src-tauri/src/idle_inhibit/x11.rs @@ -28,7 +28,10 @@ impl X11Inhibitor { } }) .ok()?; - Some(Self { stop: stop_tx, handle: Some(handle) }) + Some(Self { + stop: stop_tx, + handle: Some(handle), + }) } pub fn stop(&mut self) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9017809d..156a313f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,15 +9,15 @@ mod embedded_librefm; mod error; mod idle_inhibit; pub mod logging; +pub mod mcp; #[cfg(target_os = "linux")] mod mpris; +mod pipeline_probe; mod scrobble; mod signal_path; -mod pipeline_probe; +mod tidal_api; #[cfg(target_os = "linux")] mod tray; -mod tidal_api; -pub mod mcp; pub mod overlay; pub use error::SoneError; @@ -27,6 +27,7 @@ use audio::{AudioDevice, AudioPlayer}; use cache::DiskCache; use crypto::Crypto; use serde::{Deserialize, Serialize}; +use std::ffi::OsStr; use std::fs; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -210,6 +211,60 @@ impl Default for Settings { } } +fn normalize_alsa_device_value(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +pub(crate) fn parse_alsa_device_cli_override(args: I) -> Option +where + I: IntoIterator, + S: AsRef, +{ + let mut args = args.into_iter().peekable(); + + while let Some(arg) = args.next() { + let arg = arg.as_ref().to_string_lossy().into_owned(); + + if arg == "--" { + break; + } + + if let Some(value) = arg.strip_prefix("--alsa-device=") { + if let Some(value) = normalize_alsa_device_value(value.to_owned()) { + return Some(value); + } + continue; + } + + if arg == "--alsa-device" { + if let Some(next) = args.next() { + let value = next.as_ref().to_string_lossy().into_owned(); + if let Some(value) = normalize_alsa_device_value(value) { + return Some(value); + } + } + } + } + + None +} + +pub(crate) fn read_alsa_device_env_override() -> Option { + std::env::var_os("SONE_ALSA_DEVICE") + .and_then(|value| normalize_alsa_device_value(value.to_string_lossy().into_owned())) +} + +pub(crate) fn resolve_alsa_device_override( + cli: Option, + env: Option, + saved: Option, +) -> Option { + cli.and_then(normalize_alsa_device_value) + .or_else(|| env.and_then(normalize_alsa_device_value)) + .or_else(|| saved.and_then(normalize_alsa_device_value)) +} + pub struct AppState { pub audio_player: Arc, pub pipeline_probe: Arc, @@ -225,6 +280,7 @@ pub struct AppState { pub bit_perfect: AtomicBool, pub gapless: AtomicBool, pub max_quality: std::sync::Mutex, + pub alsa_device_override: Option, pub exclusive_device: std::sync::Mutex>, pub cached_audio_devices: std::sync::Mutex>>, /// Current track's selected replay gain (dB) stored as f64 bits. NAN = no data. @@ -293,9 +349,7 @@ impl AppState { if let Ok(json) = serde_json::to_string_pretty(s) { if let Ok(encrypted) = crypto.encrypt(json.as_bytes()) { if let Err(e) = fs::write(&settings_path, encrypted) { - log::warn!( - "[migration] failed to persist titlebar_migration_v1: {e}" - ); + log::warn!("[migration] failed to persist titlebar_migration_v1: {e}"); } } } @@ -330,7 +384,12 @@ impl AppState { let exclusive_mode = saved.as_ref().map(|s| s.exclusive_mode).unwrap_or(false); let bit_perfect = saved.as_ref().map(|s| s.bit_perfect).unwrap_or(false); let gapless = saved.as_ref().map(|s| s.gapless).unwrap_or(true); - let exclusive_device = saved.as_ref().and_then(|s| s.exclusive_device.clone()); + let cli_alsa_device = parse_alsa_device_cli_override(std::env::args_os()); + let env_alsa_device = read_alsa_device_env_override(); + let saved_exclusive_device = saved.as_ref().and_then(|s| s.exclusive_device.clone()); + let alsa_device_override = + resolve_alsa_device_override(cli_alsa_device, env_alsa_device, None); + let exclusive_device = alsa_device_override.clone().or(saved_exclusive_device); let max_quality = saved .as_ref() .map(|s| s.max_quality.clone()) @@ -392,6 +451,7 @@ impl AppState { bit_perfect: AtomicBool::new(bit_perfect), gapless: AtomicBool::new(gapless), max_quality: std::sync::Mutex::new(max_quality), + alsa_device_override, exclusive_device: std::sync::Mutex::new(exclusive_device), cached_audio_devices: std::sync::Mutex::new(None), last_replay_gain: AtomicU64::new(f64::NAN.to_bits()), @@ -462,6 +522,57 @@ impl AppState { } } +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod tests { + use super::{parse_alsa_device_cli_override, resolve_alsa_device_override}; + + #[test] + fn resolve_alsa_device_override_prefers_cli_over_env_and_saved() { + let resolved = resolve_alsa_device_override( + Some("hw:CARD=cli,DEV=0".to_string()), + Some("hw:CARD=env,DEV=0".to_string()), + Some("hw:CARD=saved,DEV=0".to_string()), + ); + + assert_eq!(resolved.as_deref(), Some("hw:CARD=cli,DEV=0")); + } + + #[test] + fn resolve_alsa_device_override_trims_and_ignores_empty_values() { + let resolved = resolve_alsa_device_override( + Some(" ".to_string()), + Some(" hw:CARD=env,DEV=0 ".to_string()), + Some(" hw:CARD=saved,DEV=0 ".to_string()), + ); + + assert_eq!(resolved.as_deref(), Some("hw:CARD=env,DEV=0")); + } + + #[test] + fn parse_alsa_device_cli_override_supports_long_option_value() { + let args = ["sone", "--alsa-device", " hw:CARD=cli,DEV=0 "]; + + let parsed = parse_alsa_device_cli_override(args); + + assert_eq!(parsed.as_deref(), Some("hw:CARD=cli,DEV=0")); + } + + #[test] + fn parse_alsa_device_cli_override_ignores_empty_values() { + let args = [ + "sone", + "--alsa-device", + " ", + "--alsa-device=hw:CARD=cli,DEV=0", + ]; + + let parsed = parse_alsa_device_cli_override(args); + + assert_eq!(parsed.as_deref(), Some("hw:CARD=cli,DEV=0")); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // File logger setup. Must happen before Tauri builds so early log @@ -473,10 +584,7 @@ pub fn run() { .unwrap_or_else(|| std::path::PathBuf::from("./.sone")); let logging_toggle_path = sone_dir.join("logging.toggle"); let logging_enabled = crate::logging::read_logging_preference(&logging_toggle_path); - let _logger_handle = crate::logging::init_logging( - sone_dir.join("logs"), - logging_enabled, - ); + let _logger_handle = crate::logging::init_logging(sone_dir.join("logs"), logging_enabled); // Bind to a named local (not `let _ = ...`) so the handle lives until // the end of `run()`. flexi_logger flushes the log file on drop, so // the handle must outlive the Tauri event loop. @@ -492,15 +600,14 @@ pub fn run() { ) .setup(|app| { // Single-instance: focus existing window if launched again - app.handle().plugin( - tauri_plugin_single_instance::init(|app, _args, _cwd| { + app.handle() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.unminimize(); let _ = window.set_focus(); } - }), - )?; + }))?; // Deep link: register tidal:// scheme handler app.handle().plugin(tauri_plugin_deep_link::init())?; #[cfg(target_os = "linux")] @@ -550,10 +657,14 @@ pub fn run() { { let handle = app.handle().clone(); std::thread::spawn(move || { - if let Ok(devices) = crate::audio::list_alsa_devices() { + let override_device = { let state = handle.state::(); - *state.cached_audio_devices.lock().unwrap() = Some(devices); - } + state.alsa_device_override.clone() + }; + let devices = + crate::audio::list_alsa_devices_with_override(override_device.as_deref()); + let state = handle.state::(); + *state.cached_audio_devices.lock().unwrap() = Some(devices); }); } @@ -563,14 +674,13 @@ pub fn run() { tauri::async_runtime::spawn(async move { let state = handle.state::(); if let Some(settings) = state.load_settings() { - let http_client = crate::tidal_api::build_http_client( - &settings.proxy - ).unwrap_or_else(|_| { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .unwrap() - }); + let http_client = crate::tidal_api::build_http_client(&settings.proxy) + .unwrap_or_else(|_| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap() + }); // Last.fm if let Some(ref creds) = settings.scrobble.lastfm { @@ -618,8 +728,9 @@ pub fn run() { // ListenBrainz if let Some(ref creds) = settings.scrobble.listenbrainz { - let provider = - crate::scrobble::listenbrainz::ListenBrainzProvider::new(http_client.clone()); + let provider = crate::scrobble::listenbrainz::ListenBrainzProvider::new( + http_client.clone(), + ); provider .set_token(creds.token.clone(), creds.username.clone()) .await; @@ -716,7 +827,7 @@ pub fn run() { }) .ok(); } - + // tauri.conf.json sets decorations: false, so the window is // born without GTK CSD. Only re-enable native chrome if the // user has explicitly opted in via the escape-hatch toggle. @@ -771,45 +882,43 @@ pub fn run() { Ok(()) }) - .on_window_event(|window, event| { - match event { - tauri::WindowEvent::CloseRequested { api, .. } => { - if window.label() == "main" { - let app = window.app_handle(); - let state = app.state::(); - if state.minimize_to_tray.load(Ordering::Relaxed) { - api.prevent_close(); - let _ = window.hide(); - } - } else if window.label() == "miniplayer" { - let _ = window.app_handle().emit_to("main", "miniplayer-closed", ()); + .on_window_event(|window, event| match event { + tauri::WindowEvent::CloseRequested { api, .. } => { + if window.label() == "main" { + let app = window.app_handle(); + let state = app.state::(); + if state.minimize_to_tray.load(Ordering::Relaxed) { + api.prevent_close(); + let _ = window.hide(); } + } else if window.label() == "miniplayer" { + let _ = window.app_handle().emit_to("main", "miniplayer-closed", ()); } - tauri::WindowEvent::Destroyed => { - if window.label() == "miniplayer" { - let _ = window.app_handle().emit_to("main", "miniplayer-closed", ()); - } else if window.label() == "pkce-login" { - commands::auth::on_pkce_window_closed(window.app_handle()); - } + } + tauri::WindowEvent::Destroyed => { + if window.label() == "miniplayer" { + let _ = window.app_handle().emit_to("main", "miniplayer-closed", ()); + } else if window.label() == "pkce-login" { + commands::auth::on_pkce_window_closed(window.app_handle()); } - #[cfg(target_os = "linux")] - tauri::WindowEvent::Focused(true) => { - if window.label() == "miniplayer" { - if let Some(ww) = window.app_handle().get_webview_window("miniplayer") { - let _ = ww.with_webview(|webview| { - use gtk::prelude::WidgetExt; - let wv: webkit2gtk::WebView = webview.inner(); - if let Some(toplevel) = wv.toplevel() { - if let Some(gdk_win) = toplevel.window() { - gdk_win.set_shadow_width(12, 12, 12, 12); - } + } + #[cfg(target_os = "linux")] + tauri::WindowEvent::Focused(true) => { + if window.label() == "miniplayer" { + if let Some(ww) = window.app_handle().get_webview_window("miniplayer") { + let _ = ww.with_webview(|webview| { + use gtk::prelude::WidgetExt; + let wv: webkit2gtk::WebView = webview.inner(); + if let Some(toplevel) = wv.toplevel() { + if let Some(gdk_win) = toplevel.window() { + gdk_win.set_shadow_width(12, 12, 12, 12); } - }); - } + } + }); } } - _ => {} } + _ => {} }) .invoke_handler(tauri::generate_handler![ // auth @@ -970,6 +1079,7 @@ pub fn run() { commands::utility::get_exclusive_device, commands::utility::set_exclusive_device, commands::utility::list_audio_devices, + commands::utility::refresh_audio_devices, commands::utility::get_discord_rpc, commands::utility::set_discord_rpc, commands::utility::get_discord_status_text, @@ -1001,7 +1111,9 @@ pub fn run() { .run(|app, event| { if let tauri::RunEvent::Exit = event { let state = app.state::(); - state.discord.send(crate::discord::DiscordCommand::Disconnect); + state + .discord + .send(crate::discord::DiscordCommand::Disconnect); tauri::async_runtime::block_on(async { state.idle_inhibitor.lock().await.uninhibit().await; state.scrobble_manager.flush().await; diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index b56fe8cf..439dcc8d 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -21,7 +21,9 @@ pub fn read_logging_preference(path: &Path) -> bool { } } -use flexi_logger::{Cleanup, Criterion, Duplicate, FileSpec, Logger, LoggerHandle, Naming, WriteMode}; +use flexi_logger::{ + Cleanup, Criterion, Duplicate, FileSpec, Logger, LoggerHandle, Naming, WriteMode, +}; use std::path::PathBuf; /// Initialize the global logger. Must be called exactly once at startup, @@ -66,9 +68,9 @@ pub fn init_logging(log_dir: PathBuf, file_enabled: bool) -> LoggerHandle { .log_to_file(FileSpec::default().directory(&log_dir).basename("sone")) .duplicate_to_stderr(Duplicate::All) .rotate( - Criterion::Size(5_000_000), // 5 MB + Criterion::Size(5_000_000), // 5 MB Naming::Numbers, - Cleanup::KeepLogFiles(9), // 9 rotated + 1 active = ~50 MB max + Cleanup::KeepLogFiles(9), // 9 rotated + 1 active = ~50 MB max ) .format_for_files(flexi_logger::detailed_format) .write_mode(WriteMode::BufferAndFlush) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5bfaacff..640d9d3a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,8 +15,7 @@ fn main() { // // TODO: revisit when WebKitGTK resolves the NVIDIA DMA-BUF bug // (upstream #262607 is WONTFIX as of 2026). - let already_overridden = - std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(); + let already_overridden = std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_some(); if !already_overridden { let nvidia_loaded = std::fs::read_to_string("/proc/modules") diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 807b10e5..57c0ded2 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -5,9 +5,7 @@ pub mod state_mirror; pub mod tools; pub use server::{start_server, McpHandle}; -pub use state_mirror::{ - new_state, McpStateRef, NowPlayingSnapshot, QueueTrackSnapshot, -}; +pub use state_mirror::{new_state, McpStateRef, NowPlayingSnapshot, QueueTrackSnapshot}; /// Start the MCP server if it is enabled in settings and not already running. /// Holds the `mcp_handle` lock across the whole check→bind→store so that diff --git a/src-tauri/src/mcp/sanitizer.rs b/src-tauri/src/mcp/sanitizer.rs index e2994ad5..5dc20cef 100644 --- a/src-tauri/src/mcp/sanitizer.rs +++ b/src-tauri/src/mcp/sanitizer.rs @@ -1,6 +1,8 @@ use serde::Serialize; -use crate::tidal_api::{TidalAlbumDetail, TidalArtist, TidalArtistDetail, TidalPlaylist, TidalTrack}; +use crate::tidal_api::{ + TidalAlbumDetail, TidalArtist, TidalArtistDetail, TidalPlaylist, TidalTrack, +}; #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] @@ -42,8 +44,16 @@ impl SanitizedTrack { Self { id: t.id, title: t.title.clone(), - artist: t.artist.as_ref().map(|a| a.name.clone()).unwrap_or_default(), - album: t.album.as_ref().map(|a| a.title.clone()).unwrap_or_default(), + artist: t + .artist + .as_ref() + .map(|a| a.name.clone()) + .unwrap_or_default(), + album: t + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_default(), duration_seconds: t.duration, } } @@ -62,9 +72,10 @@ impl SanitizedAlbum { .map(|x| x.name.clone()) }) .unwrap_or_default(); - let release_year = a.release_date.as_ref().and_then(|d| { - d.split('-').next().and_then(|y| y.parse::().ok()) - }); + let release_year = a + .release_date + .as_ref() + .and_then(|d| d.split('-').next().and_then(|y| y.parse::().ok())); Self { id: a.id, title: a.title.clone(), @@ -77,11 +88,17 @@ impl SanitizedAlbum { impl SanitizedArtist { pub fn from_tidal(a: &TidalArtist) -> Self { - Self { id: a.id, name: a.name.clone() } + Self { + id: a.id, + name: a.name.clone(), + } } pub fn from_tidal_detail(a: &TidalArtistDetail) -> Self { - Self { id: a.id, name: a.name.clone() } + Self { + id: a.id, + name: a.name.clone(), + } } } diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index a6781c2f..2e56b3b2 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; -use rmcp::handler::server::ServerHandler; use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::ServerHandler; use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo}; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService}; diff --git a/src-tauri/src/mcp/tools/catalog.rs b/src-tauri/src/mcp/tools/catalog.rs index 3543c8ce..9cc24bb9 100644 --- a/src-tauri/src/mcp/tools/catalog.rs +++ b/src-tauri/src/mcp/tools/catalog.rs @@ -1,13 +1,15 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; -use rmcp::{ErrorData, tool_router}; +use rmcp::{tool_router, ErrorData}; use serde::Deserialize; use tauri::Manager; -use crate::AppState; -use crate::mcp::sanitizer::{SanitizedAlbum, SanitizedArtist, SanitizedPlaylist, backfill_and_sanitize_tracks}; +use crate::mcp::sanitizer::{ + backfill_and_sanitize_tracks, SanitizedAlbum, SanitizedArtist, SanitizedPlaylist, +}; use crate::mcp::server::SoneMcpServer; +use crate::AppState; #[derive(Deserialize, JsonSchema)] pub struct SearchTracksArgs { @@ -66,9 +68,21 @@ impl SoneMcpServer { .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; let tracks = backfill_and_sanitize_tracks(results.tracks); - let albums: Vec = results.albums.iter().map(SanitizedAlbum::from_tidal).collect(); - let artists: Vec = results.artists.iter().map(SanitizedArtist::from_tidal).collect(); - let playlists: Vec = results.playlists.iter().map(SanitizedPlaylist::from_tidal).collect(); + let albums: Vec = results + .albums + .iter() + .map(SanitizedAlbum::from_tidal) + .collect(); + let artists: Vec = results + .artists + .iter() + .map(SanitizedArtist::from_tidal) + .collect(); + let playlists: Vec = results + .playlists + .iter() + .map(SanitizedPlaylist::from_tidal) + .collect(); let json = serde_json::json!({ "tracks": tracks, "albums": albums, "artists": artists, "playlists": playlists }); Ok(CallToolResult::success(vec![rmcp::model::Content::text( diff --git a/src-tauri/src/mcp/tools/favorites.rs b/src-tauri/src/mcp/tools/favorites.rs index f8bff6c3..0ed444b0 100644 --- a/src-tauri/src/mcp/tools/favorites.rs +++ b/src-tauri/src/mcp/tools/favorites.rs @@ -1,14 +1,14 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; -use rmcp::{ErrorData, tool_router}; +use rmcp::{tool_router, ErrorData}; use serde::Deserialize; use tauri::{Emitter, Manager}; -use crate::AppState; -use crate::mcp::events::{EV_FAVORITE_CHANGED, FavoriteChangedPayload}; -use crate::mcp::sanitizer::{SanitizedAlbum, SanitizedArtist, backfill_and_sanitize_tracks}; +use crate::mcp::events::{FavoriteChangedPayload, EV_FAVORITE_CHANGED}; +use crate::mcp::sanitizer::{backfill_and_sanitize_tracks, SanitizedAlbum, SanitizedArtist}; use crate::mcp::server::SoneMcpServer; +use crate::AppState; use super::util::require_user_id; @@ -83,7 +83,8 @@ impl SoneMcpServer { .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; let total = resp.total_number_of_items; - let albums: Vec = resp.items.iter().map(SanitizedAlbum::from_tidal).collect(); + let albums: Vec = + resp.items.iter().map(SanitizedAlbum::from_tidal).collect(); let json = serde_json::json!({ "albums": albums, "total": total, @@ -113,7 +114,11 @@ impl SoneMcpServer { .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; let total = resp.total_number_of_items; - let artists: Vec = resp.items.iter().map(SanitizedArtist::from_tidal_detail).collect(); + let artists: Vec = resp + .items + .iter() + .map(SanitizedArtist::from_tidal_detail) + .collect(); let json = serde_json::json!({ "artists": artists, "total": total, diff --git a/src-tauri/src/mcp/tools/playback.rs b/src-tauri/src/mcp/tools/playback.rs index 5766da45..ba720029 100644 --- a/src-tauri/src/mcp/tools/playback.rs +++ b/src-tauri/src/mcp/tools/playback.rs @@ -1,15 +1,15 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; -use rmcp::{ErrorData, tool_router}; +use rmcp::{tool_router, ErrorData}; use serde::Deserialize; use tauri::Emitter; use crate::mcp::events::{ - EV_CLEAR_QUEUE, EV_PAUSE, EV_PLAY_SOURCE, EV_PLAY_TRACKS, EV_REMOVE_FROM_QUEUE, EV_RESUME, - EV_SEEK, EV_SET_REPEAT, EV_SET_VOLUME, EV_SHUFFLE_SOURCE, EV_SKIP_NEXT, EV_SKIP_PREVIOUS, - EV_TOGGLE_SHUFFLE, PlaySourcePayload, PlayTracksPayload, RemoveFromQueuePayload, RepeatPayload, - SeekPayload, VolumePayload, + PlaySourcePayload, PlayTracksPayload, RemoveFromQueuePayload, RepeatPayload, SeekPayload, + VolumePayload, EV_CLEAR_QUEUE, EV_PAUSE, EV_PLAY_SOURCE, EV_PLAY_TRACKS, EV_REMOVE_FROM_QUEUE, + EV_RESUME, EV_SEEK, EV_SET_REPEAT, EV_SET_VOLUME, EV_SHUFFLE_SOURCE, EV_SKIP_NEXT, + EV_SKIP_PREVIOUS, EV_TOGGLE_SHUFFLE, }; use crate::mcp::server::SoneMcpServer; @@ -66,7 +66,10 @@ impl SoneMcpServer { Parameters(args): Parameters, ) -> Result { if args.track_ids.is_empty() { - return Err(ErrorData::invalid_params("track_ids must not be empty", None)); + return Err(ErrorData::invalid_params( + "track_ids must not be empty", + None, + )); } if !matches!(args.action.as_str(), "play_now" | "queue" | "play_next") { return Err(ErrorData::invalid_params( @@ -76,10 +79,18 @@ impl SoneMcpServer { } let action = args.action.clone(); self.app_handle - .emit(EV_PLAY_TRACKS, PlayTracksPayload { track_ids: args.track_ids, action: args.action }) + .emit( + EV_PLAY_TRACKS, + PlayTracksPayload { + track_ids: args.track_ids, + action: args.action, + }, + ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": action }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -90,17 +101,28 @@ impl SoneMcpServer { &self, Parameters(args): Parameters, ) -> Result { - if !matches!(args.source_type.as_str(), "playlist" | "album" | "artist" | "mix") { + if !matches!( + args.source_type.as_str(), + "playlist" | "album" | "artist" | "mix" + ) { return Err(ErrorData::invalid_params( "source_type must be \"playlist\", \"album\", \"artist\", or \"mix\"", None, )); } self.app_handle - .emit(EV_PLAY_SOURCE, PlaySourcePayload { source_type: args.source_type, id: args.id }) + .emit( + EV_PLAY_SOURCE, + PlaySourcePayload { + source_type: args.source_type, + id: args.id, + }, + ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "playing" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -111,17 +133,28 @@ impl SoneMcpServer { &self, Parameters(args): Parameters, ) -> Result { - if !matches!(args.source_type.as_str(), "playlist" | "album" | "artist" | "mix") { + if !matches!( + args.source_type.as_str(), + "playlist" | "album" | "artist" | "mix" + ) { return Err(ErrorData::invalid_params( "source_type must be \"playlist\", \"album\", \"artist\", or \"mix\"", None, )); } self.app_handle - .emit(EV_SHUFFLE_SOURCE, PlaySourcePayload { source_type: args.source_type, id: args.id }) + .emit( + EV_SHUFFLE_SOURCE, + PlaySourcePayload { + source_type: args.source_type, + id: args.id, + }, + ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "shuffling" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "pause", description = "Pause playback.")] @@ -130,7 +163,9 @@ impl SoneMcpServer { .emit(EV_PAUSE, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "resume", description = "Resume playback.")] @@ -139,7 +174,9 @@ impl SoneMcpServer { .emit(EV_RESUME, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "skip_next", description = "Skip to the next track.")] @@ -151,7 +188,9 @@ impl SoneMcpServer { .emit(EV_SKIP_NEXT, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "skip_previous", description = "Go back to the previous track.")] @@ -163,7 +202,9 @@ impl SoneMcpServer { .emit(EV_SKIP_PREVIOUS, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "clear_queue", description = "Clear the play queue.")] @@ -175,7 +216,9 @@ impl SoneMcpServer { .emit(EV_CLEAR_QUEUE, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool(name = "toggle_shuffle", description = "Toggle shuffle on/off.")] @@ -187,7 +230,9 @@ impl SoneMcpServer { .emit(EV_TOGGLE_SHUFFLE, ()) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -199,10 +244,17 @@ impl SoneMcpServer { Parameters(args): Parameters, ) -> Result { self.app_handle - .emit(EV_SEEK, SeekPayload { position_seconds: args.position_seconds }) + .emit( + EV_SEEK, + SeekPayload { + position_seconds: args.position_seconds, + }, + ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "seeking" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -218,7 +270,9 @@ impl SoneMcpServer { .emit(EV_SET_VOLUME, VolumePayload { level }) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "ok" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -230,10 +284,17 @@ impl SoneMcpServer { Parameters(args): Parameters, ) -> Result { self.app_handle - .emit(EV_REMOVE_FROM_QUEUE, RemoveFromQueuePayload { track_id: args.track_id }) + .emit( + EV_REMOVE_FROM_QUEUE, + RemoveFromQueuePayload { + track_id: args.track_id, + }, + ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": "removed" }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } #[rmcp::tool( @@ -255,6 +316,8 @@ impl SoneMcpServer { .emit(EV_SET_REPEAT, RepeatPayload { mode: args.mode }) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; let json = serde_json::json!({ "status": mode }); - Ok(CallToolResult::success(vec![rmcp::model::Content::text(json.to_string())])) + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + json.to_string(), + )])) } } diff --git a/src-tauri/src/mcp/tools/playlists.rs b/src-tauri/src/mcp/tools/playlists.rs index 0750e3d8..1dab4664 100644 --- a/src-tauri/src/mcp/tools/playlists.rs +++ b/src-tauri/src/mcp/tools/playlists.rs @@ -1,18 +1,18 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; -use rmcp::{ErrorData, tool_router}; +use rmcp::{tool_router, ErrorData}; use serde::Deserialize; use tauri::{Emitter, Manager}; -use crate::AppState; use crate::mcp::events::{ - EV_PLAYLIST_CREATED, EV_PLAYLIST_DELETED, EV_PLAYLIST_TRACKS_CHANGED, EV_PLAYLIST_UPDATED, PlaylistDeletedPayload, PlaylistTracksChangedPayload, PlaylistUpdatedPayload, + EV_PLAYLIST_CREATED, EV_PLAYLIST_DELETED, EV_PLAYLIST_TRACKS_CHANGED, EV_PLAYLIST_UPDATED, }; -use crate::mcp::sanitizer::{SanitizedPlaylist, backfill_and_sanitize_tracks}; +use crate::mcp::sanitizer::{backfill_and_sanitize_tracks, SanitizedPlaylist}; use crate::mcp::server::SoneMcpServer; use crate::tidal_api::TidalClient; +use crate::AppState; use super::util::{require_user_id, NoArgs}; @@ -76,10 +76,7 @@ async fn resolve_playlist_uuid( .map(|s| s.trim()) .filter(|s| !s.is_empty()) .ok_or_else(|| { - ErrorData::invalid_params( - "playlist_uuid or playlist_name required".to_string(), - None, - ) + ErrorData::invalid_params("playlist_uuid or playlist_name required".to_string(), None) })?; let owned = client .get_user_playlists(user_id, 0, 200) @@ -90,14 +87,13 @@ async fn resolve_playlist_uuid( .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; let lower = needle.to_lowercase(); - owned.items + owned + .items .into_iter() .chain(favorited.items.into_iter()) .find(|p| p.title.to_lowercase() == lower) .map(|p| p.uuid) - .ok_or_else(|| { - ErrorData::invalid_params(format!("Playlist not found: {needle}"), None) - }) + .ok_or_else(|| ErrorData::invalid_params(format!("Playlist not found: {needle}"), None)) } #[tool_router(router = playlists_tools, vis = "pub(crate)")] @@ -128,7 +124,8 @@ impl SoneMcpServer { combined.push(p); } } - let playlists: Vec = combined.iter().map(SanitizedPlaylist::from_tidal).collect(); + let playlists: Vec = + combined.iter().map(SanitizedPlaylist::from_tidal).collect(); let json = serde_json::json!({ "playlists": playlists }); Ok(CallToolResult::success(vec![rmcp::model::Content::text( json.to_string(), @@ -213,7 +210,8 @@ impl SoneMcpServer { ) .map_err(|e| ErrorData::internal_error(format!("emit failed: {e}"), None))?; } - let json = serde_json::json!({ "uuid": uuid, "name": playlist.title, "trackCount": track_count }); + let json = + serde_json::json!({ "uuid": uuid, "name": playlist.title, "trackCount": track_count }); Ok(CallToolResult::success(vec![rmcp::model::Content::text( json.to_string(), )])) @@ -328,7 +326,10 @@ impl SoneMcpServer { Parameters(args): Parameters, ) -> Result { if args.track_ids.is_empty() { - return Err(ErrorData::invalid_params("track_ids must not be empty", None)); + return Err(ErrorData::invalid_params( + "track_ids must not be empty", + None, + )); } let state = self.app_handle.state::(); let mut client = state.tidal_client.lock().await; diff --git a/src-tauri/src/mcp/tools/state.rs b/src-tauri/src/mcp/tools/state.rs index fe077894..b5449778 100644 --- a/src-tauri/src/mcp/tools/state.rs +++ b/src-tauri/src/mcp/tools/state.rs @@ -1,12 +1,12 @@ use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::schemars::JsonSchema; -use rmcp::{ErrorData, tool_router}; +use rmcp::{tool_router, ErrorData}; use serde::Deserialize; use tauri::Manager; -use crate::AppState; use crate::mcp::server::SoneMcpServer; +use crate::AppState; use super::util::NoArgs; diff --git a/src-tauri/src/mcp/tools/util.rs b/src-tauri/src/mcp/tools/util.rs index ee23b7b3..6d7e3760 100644 --- a/src-tauri/src/mcp/tools/util.rs +++ b/src-tauri/src/mcp/tools/util.rs @@ -1,5 +1,5 @@ -use rmcp::ErrorData; use rmcp::schemars::JsonSchema; +use rmcp::ErrorData; use serde::Deserialize; use crate::tidal_api::TidalClient; diff --git a/src-tauri/src/mpris.rs b/src-tauri/src/mpris.rs index fbcb0160..6da19c6f 100644 --- a/src-tauri/src/mpris.rs +++ b/src-tauri/src/mpris.rs @@ -210,8 +210,7 @@ impl MprisHandle { if player_for_tick.playback_status() != PlaybackStatus::Playing { continue; } - let Some(state) = app_handle_for_tick.try_state::() - else { + let Some(state) = app_handle_for_tick.try_state::() else { continue; }; if let Ok(secs) = state.audio_player.get_position() { @@ -241,8 +240,7 @@ impl MprisHandle { user_rating, } => { let mut metadata = Metadata::new(); - let track_path = - format!("/org/mpris/MediaPlayer2/Track/{}", track_id); + let track_path = format!("/org/mpris/MediaPlayer2/Track/{}", track_id); if let Ok(tid) = TrackId::try_from(track_path.as_str()) { metadata.set_trackid(Some(tid)); } diff --git a/src-tauri/src/pipeline_probe.rs b/src-tauri/src/pipeline_probe.rs index 307c01cd..88e26f49 100644 --- a/src-tauri/src/pipeline_probe.rs +++ b/src-tauri/src/pipeline_probe.rs @@ -126,7 +126,10 @@ pub fn parse_pactl_info(stdout: &str) -> Option { server_raw = Some(v.to_string()); } else if let Some(v) = line.strip_prefix("Default Sink:").map(str::trim) { default_sink = Some(v.to_string()); - } else if let Some(v) = line.strip_prefix("Default Sample Specification:").map(str::trim) { + } else if let Some(v) = line + .strip_prefix("Default Sample Specification:") + .map(str::trim) + { spec = Some(v.to_string()); } } @@ -196,11 +199,17 @@ pub fn sink_volume_and_mute(stdout: &str, target_sink_name: &str) -> (f32, u32, let mut chosen_percent: Option = None; for part in rest.split(',') { // Anchor on " dB" at the end of the channel segment. - let Some(db_end) = part.rfind(" dB") else { continue }; + let Some(db_end) = part.rfind(" dB") else { + continue; + }; let before_db = &part[..db_end]; - let Some(last_slash) = before_db.rfind('/') else { continue }; + let Some(last_slash) = before_db.rfind('/') else { + continue; + }; let db_str = before_db[last_slash + 1..].trim(); - let Ok(db) = db_str.parse::() else { continue }; + let Ok(db) = db_str.parse::() else { + continue; + }; // Walk back from the slash-before-dB to find the "%" // token sitting between two earlier slashes: @@ -370,7 +379,10 @@ impl PipelineProbe { signal_path: Arc, audio_player: Arc, ) -> Self { - Self { signal_path, audio_player } + Self { + signal_path, + audio_player, + } } /// One-shot refresh: probe all sources, push into tracker. Cheap to call. @@ -378,8 +390,10 @@ impl PipelineProbe { /// shell-out to pactl. Never blocks on audio thread. pub fn refresh(&self) { // 1. Pad caps cells (mutex reads, very fast). - self.signal_path.set_decoded_caps(self.audio_player.snapshot_decoded_caps()); - self.signal_path.set_output_caps(self.audio_player.snapshot_output_caps()); + self.signal_path + .set_decoded_caps(self.audio_player.snapshot_decoded_caps()); + self.signal_path + .set_output_caps(self.audio_player.snapshot_output_caps()); // 2. OS mixer via pactl. Best-effort; None on failure. let mixer = query_os_mixer(); @@ -393,9 +407,16 @@ impl PipelineProbe { // Need sink → alsa.id mapping from `pactl list sinks`. let list_stdout = run_pactl(&["list", "sinks"]); let resolver = |sink: &str| { - list_stdout.as_deref().and_then(|out| sink_alsa_id(out, sink)) + list_stdout + .as_deref() + .and_then(|out| sink_alsa_id(out, sink)) }; - discover_active_card_with_resolver(Some(b), exclusive_device.as_deref(), mixer.as_ref(), &resolver) + discover_active_card_with_resolver( + Some(b), + exclusive_device.as_deref(), + mixer.as_ref(), + &resolver, + ) } else { discover_active_card(Some(b), exclusive_device.as_deref(), mixer.as_ref()) } @@ -419,7 +440,9 @@ pub fn read_hw_params(card_name: &str) -> Option { for pcm in 0..4u32 { for sub in 0..2u32 { let path = format!("{base}/pcm{pcm}p/sub{sub}/hw_params"); - let Ok(contents) = std::fs::read_to_string(&path) else { continue }; + let Ok(contents) = std::fs::read_to_string(&path) else { + continue; + }; let parsed = parse_hw_params_file(&contents)?; if parsed.state == HwParamsState::Closed && (pcm != 0 || sub != 0) { continue; // try the next subdevice @@ -445,11 +468,13 @@ fn read_card_index(card_name: &str) -> Option { // /proc/asound/ is sometimes a symlink to /proc/asound/cardN — read the link. let link = std::fs::read_link(format!("/proc/asound/{card_name}")).ok()?; let s = link.to_string_lossy(); - s.strip_prefix("card").and_then(|n| n.parse().ok()).or_else(|| { - // Fallback: read the id file (contains the same name; index unknown). - let _ = std::fs::read_to_string(id_path); - None - }) + s.strip_prefix("card") + .and_then(|n| n.parse().ok()) + .or_else(|| { + // Fallback: read the id file (contains the same name; index unknown). + let _ = std::fs::read_to_string(id_path); + None + }) } fn read_card_longname(card_name: &str) -> Option { @@ -591,7 +616,10 @@ Default Sink: alsa_output.usb-iFi-by-AMR-HD-USB-Audio.iec958-stereo fn detects_pipewire_server() { let info = parse_pactl_info(PACTL_INFO_PIPEWIRE).unwrap(); assert_eq!(info.server, "PipeWire"); - assert_eq!(info.default_sink_name, "alsa_output.pci-0000_01_00.1.hdmi-stereo"); + assert_eq!( + info.default_sink_name, + "alsa_output.pci-0000_01_00.1.hdmi-stereo" + ); assert_eq!(info.sink_format, "float32le"); assert_eq!(info.sink_rate, 48000); assert_eq!(info.sink_channels, 2); @@ -668,19 +696,24 @@ Sink #60 #[test] fn discover_directalsa_uses_exclusive_device() { - let card = discover_active_card( - Some("DirectAlsa"), - Some("hw:CARD=Audio,DEV=0"), - None, - ); + let card = discover_active_card(Some("DirectAlsa"), Some("hw:CARD=Audio,DEV=0"), None); assert_eq!(card.as_deref(), Some("Audio")); } #[test] fn parse_alsa_card_handles_named_form() { - assert_eq!(parse_alsa_card_from_device("hw:CARD=Audio,DEV=0").as_deref(), Some("Audio")); - assert_eq!(parse_alsa_card_from_device("plughw:CARD=Audio,DEV=0").as_deref(), Some("Audio")); - assert_eq!(parse_alsa_card_from_device("hw:CARD=Audio").as_deref(), Some("Audio")); + assert_eq!( + parse_alsa_card_from_device("hw:CARD=Audio,DEV=0").as_deref(), + Some("Audio") + ); + assert_eq!( + parse_alsa_card_from_device("plughw:CARD=Audio,DEV=0").as_deref(), + Some("Audio") + ); + assert_eq!( + parse_alsa_card_from_device("hw:CARD=Audio").as_deref(), + Some("Audio") + ); } #[test] @@ -704,7 +737,11 @@ Sink #60 }; // alsa.id resolution requires the list-sinks stdout — simulated via caller: let resolver = |sink: &str| { - if sink == "alsa_output.usb-iFi.iec958-stereo" { Some("Audio".into()) } else { None } + if sink == "alsa_output.usb-iFi.iec958-stereo" { + Some("Audio".into()) + } else { + None + } }; let card = discover_active_card_with_resolver(Some("Normal"), None, Some(&info), &resolver); assert_eq!(card.as_deref(), Some("Audio")); diff --git a/src-tauri/src/scrobble/listenbrainz.rs b/src-tauri/src/scrobble/listenbrainz.rs index a4b08c70..9fe427d6 100644 --- a/src-tauri/src/scrobble/listenbrainz.rs +++ b/src-tauri/src/scrobble/listenbrainz.rs @@ -44,7 +44,10 @@ impl ListenBrainzProvider { } /// Validate a ListenBrainz user token. Returns the username on success. - pub async fn validate_token(client: &reqwest::Client, token: &str) -> Result { + pub async fn validate_token( + client: &reqwest::Client, + token: &str, + ) -> Result { let resp = client .get(format!("{API_BASE}/1/validate-token")) .header("Authorization", format!("Token {token}")) diff --git a/src-tauri/src/signal_path.rs b/src-tauri/src/signal_path.rs index 89636d1f..1b723944 100644 --- a/src-tauri/src/signal_path.rs +++ b/src-tauri/src/signal_path.rs @@ -222,7 +222,9 @@ impl SignalPathTracker { pub fn clear_resample(&self) { let snap = { let mut s = self.state.lock().unwrap(); - if s.resampled_from.is_none() && s.resampled_to.is_none() { return; } + if s.resampled_from.is_none() && s.resampled_to.is_none() { + return; + } s.resampled_from = None; s.resampled_to = None; s.clone() @@ -233,7 +235,9 @@ impl SignalPathTracker { pub fn clear_format_fallback(&self) { let snap = { let mut s = self.state.lock().unwrap(); - if s.format_fallback_from.is_none() && s.format_fallback_to.is_none() { return; } + if s.format_fallback_from.is_none() && s.format_fallback_to.is_none() { + return; + } s.format_fallback_from = None; s.format_fallback_to = None; s.clone() diff --git a/src-tauri/src/tidal_api.rs b/src-tauri/src/tidal_api.rs index b8870e51..b6d26151 100644 --- a/src-tauri/src/tidal_api.rs +++ b/src-tauri/src/tidal_api.rs @@ -12,7 +12,10 @@ pub fn build_http_client(proxy: &ProxySettings) -> Result 0 { // Reject hosts with characters that could break URL parsing - if proxy.host.contains(|c: char| matches!(c, '@' | '/' | '?' | '#') || c.is_whitespace()) { + if proxy + .host + .contains(|c: char| matches!(c, '@' | '/' | '?' | '#') || c.is_whitespace()) + { return builder.build(); // return client without proxy if host is invalid } @@ -474,7 +477,11 @@ impl From for TidalPlaylist { duration: raw.duration, last_updated: raw.last_updated, access_type: raw.public_playlist.map(|p| { - if p { "PUBLIC".to_string() } else { "UNLISTED".to_string() } + if p { + "PUBLIC".to_string() + } else { + "UNLISTED".to_string() + } }), } } @@ -1525,8 +1532,9 @@ impl TidalClient { total_number_of_items: u32, } - let data: Resp = serde_json::from_str(&body) - .map_err(|e| SoneError::Parse(format!("{} - Body: {}", e, &body[..body.len().min(500)])))?; + let data: Resp = serde_json::from_str(&body).map_err(|e| { + SoneError::Parse(format!("{} - Body: {}", e, &body[..body.len().min(500)])) + })?; let playlists: Vec = data.items.into_iter().map(|p| p.into()).collect(); Ok(PaginatedResponse { items: playlists, @@ -2042,8 +2050,9 @@ impl TidalClient { limit: u32, } - let data: RecommendationsResponse = serde_json::from_str(&body) - .map_err(|e| SoneError::Parse(format!("{} - Body: {}", e, &body[..body.len().min(500)])))?; + let data: RecommendationsResponse = serde_json::from_str(&body).map_err(|e| { + SoneError::Parse(format!("{} - Body: {}", e, &body[..body.len().min(500)])) + })?; let mut tracks: Vec = data.items.into_iter().map(|w| w.item).collect(); for t in &mut tracks { @@ -3076,9 +3085,7 @@ impl TidalClient { if !cursor.is_empty() { params.push(("cursor", cursor)); } - let body = self - .api_get_body(&url, ¶ms) - .await?; + let body = self.api_get_body(&url, ¶ms).await?; log::debug!( "[get_playlist_folders]: body_preview={}", @@ -3641,11 +3648,8 @@ impl TidalClient { pub async fn get_track(&mut self, track_id: u64) -> Result { let cc = self.country_code.clone(); - self.api_get( - &format!("/tracks/{}", track_id), - &[("countryCode", &cc)], - ) - .await + self.api_get(&format!("/tracks/{}", track_id), &[("countryCode", &cc)]) + .await } pub async fn get_track_credits( @@ -4725,8 +4729,14 @@ impl TidalClient { "MIX_HEADER" => { if let Some(mix) = module.get("mix") { title = mix.get("title").and_then(|t| t.as_str()).map(String::from); - subtitle = mix.get("subTitle").and_then(|s| s.as_str()).map(String::from); - mix_type = mix.get("mixType").and_then(|t| t.as_str()).map(String::from); + subtitle = mix + .get("subTitle") + .and_then(|s| s.as_str()) + .map(String::from); + mix_type = mix + .get("mixType") + .and_then(|t| t.as_str()) + .map(String::from); // Extract image URL from images.LARGE.url (or MEDIUM, SMALL) if let Some(images) = mix.get("images") { image = images @@ -4740,13 +4750,16 @@ impl TidalClient { } } "TRACK_LIST" => { - if let Some(items) = module.get("pagedList") + if let Some(items) = module + .get("pagedList") .and_then(|p| p.get("items")) .and_then(|i| i.as_array()) { tracks = items .iter() - .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .filter_map(|item| { + serde_json::from_value::(item.clone()).ok() + }) .collect(); for t in &mut tracks { t.backfill_artist(); @@ -5218,7 +5231,8 @@ impl TidalClient { let body = self .api_get_body(&format!("/users/{}", user_id), &[("countryCode", &cc)]) .await?; - let json: Value = serde_json::from_str(&body).map_err(|e| SoneError::Parse(e.to_string()))?; + let json: Value = + serde_json::from_str(&body).map_err(|e| SoneError::Parse(e.to_string()))?; Ok(json.get("artistId").and_then(|v| v.as_u64())) } @@ -5291,11 +5305,7 @@ impl TidalClient { /// Best-effort follower/fan count. Tries the social-host profile endpoint /// first, then the openapi followers relationship. - async fn fetch_fan_count( - &mut self, - user_id: u64, - artist_id: &str, - ) -> Result { + async fn fetch_fan_count(&mut self, user_id: u64, artist_id: &str) -> Result { let primary = self .api_get_body( &format!("https://api.tidal.com/v2/profiles/{}", user_id), @@ -5319,7 +5329,8 @@ impl TidalClient { &[("countryCode", &self.country_code.clone())], ) .await?; - let json: Value = serde_json::from_str(&body).map_err(|e| SoneError::Parse(e.to_string()))?; + let json: Value = + serde_json::from_str(&body).map_err(|e| SoneError::Parse(e.to_string()))?; let count = json .get("data") .and_then(|d| d.as_array()) @@ -5365,7 +5376,10 @@ impl TidalClient { }); let response = self .client - .patch(format!("{}/artistBiographies/{}", TIDAL_OPENAPI_URL, bio_id)) + .patch(format!( + "{}/artistBiographies/{}", + TIDAL_OPENAPI_URL, bio_id + )) .header("Authorization", format!("Bearer {}", tokens.access_token)) .header("Content-Type", "application/vnd.api+json") .header("x-tidal-client-version", TIDAL_CLIENT_VERSION) @@ -6104,7 +6118,10 @@ mod profile_tests { assert_eq!(parts.bio.as_deref(), Some("A short bio.")); assert_eq!(parts.bio_id.as_deref(), Some("bio-1")); assert_eq!(parts.artwork_id.as_deref(), Some("art-1")); - assert_eq!(parts.blur_hash.as_deref(), Some("L6Pj0^jE.AyE_3t7t7R**0o#DgR4")); + assert_eq!( + parts.blur_hash.as_deref(), + Some("L6Pj0^jE.AyE_3t7t7R**0o#DgR4") + ); assert_eq!(parts.palette, vec!["#112233", "#445566"]); let widths: Vec = parts .picture_files @@ -6215,13 +6232,21 @@ mod profile_tests { #[test] fn build_external_links_body_maps_href_and_type() { let links = vec![ - ExternalLink { href: "https://instagram.com/me".into(), link_type: "INSTAGRAM".into() }, - ExternalLink { href: "https://me.com".into(), link_type: "OFFICIAL_HOMEPAGE".into() }, + ExternalLink { + href: "https://instagram.com/me".into(), + link_type: "INSTAGRAM".into(), + }, + ExternalLink { + href: "https://me.com".into(), + link_type: "OFFICIAL_HOMEPAGE".into(), + }, ]; let body = build_external_links_body(42, &links); assert_eq!(body["data"]["type"], "artists"); assert_eq!(body["data"]["id"], "42"); - let arr = body["data"]["attributes"]["externalLinks"].as_array().unwrap(); + let arr = body["data"]["attributes"]["externalLinks"] + .as_array() + .unwrap(); assert_eq!(arr.len(), 2); assert_eq!(arr[0]["href"], "https://instagram.com/me"); assert_eq!(arr[0]["meta"]["type"], "INSTAGRAM"); @@ -6231,7 +6256,9 @@ mod profile_tests { #[test] fn build_external_links_body_empty_clears() { let body = build_external_links_body(42, &[]); - let arr = body["data"]["attributes"]["externalLinks"].as_array().unwrap(); + let arr = body["data"]["attributes"]["externalLinks"] + .as_array() + .unwrap(); assert!(arr.is_empty()); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index fe634775..8c3c637d 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -61,9 +61,7 @@ pub(crate) fn restore_window(app: &tauri::AppHandle) { // so no desktop-specific skip. if std::env::var("WAYLAND_DISPLAY").is_ok() { let state = app.state::(); - let wants = state - .decorations - .load(std::sync::atomic::Ordering::Relaxed); + let wants = state.decorations.load(std::sync::atomic::Ordering::Relaxed); if wants { let _ = window.set_decorations(false); let _ = window.set_decorations(true); @@ -178,8 +176,7 @@ pub fn setup(app: &tauri::App) { // In Flatpak/Snap sandbox, ksni can't own a well-known D-Bus name // (would need --own-name with a dynamic PID-based name). // disable_dbus_name makes it register via the unique connection name instead. - let is_sandboxed = - std::env::var("FLATPAK_ID").is_ok() || std::env::var("SNAP").is_ok(); + let is_sandboxed = std::env::var("FLATPAK_ID").is_ok() || std::env::var("SNAP").is_ok(); match tray.disable_dbus_name(is_sandboxed).spawn().await { Ok(handle) => { app_handle.manage(TrayHandle(handle)); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index a172897c..6c1129b0 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -59,6 +59,19 @@ export default function UserMenu() { const { showToast } = useToast(); const menuRef = useRef(null); + const refreshAudioDevices = () => + invoke>("refresh_audio_devices") + .then((devices) => { + setAudioDevices(devices); + if (!exclusiveDevice && devices.length > 0) { + setExclusiveDevice(devices[0].id); + invoke("set_exclusive_device", { device: devices[0].id }).catch( + () => {}, + ); + } + }) + .catch(() => {}); + // Toggle shortcuts modal from ? key useEffect(() => { const handler = () => setShortcutsOpen((prev) => !prev); @@ -107,20 +120,17 @@ export default function UserMenu() { // Load audio devices when exclusive mode is enabled useEffect(() => { if (exclusiveMode) { - invoke>("list_audio_devices") - .then((devices) => { - setAudioDevices(devices); - if (!exclusiveDevice && devices.length > 0) { - setExclusiveDevice(devices[0].id); - invoke("set_exclusive_device", { device: devices[0].id }).catch( - () => {}, - ); - } - }) - .catch(() => {}); + refreshAudioDevices(); } }, [exclusiveMode]); + // Re-enumerate when the selector is opened so hotplugged devices can appear + useEffect(() => { + if (exclusiveMode && deviceDropdownOpen) { + refreshAudioDevices(); + } + }, [exclusiveMode, deviceDropdownOpen]); + // Close on click outside useEffect(() => { if (!open) return;