diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 104e0fdf8..ac62a9761 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -13,7 +13,13 @@ on: jobs: build-macos: - # Apple Silicon only — Intel Macs run via Rosetta 2 + strategy: + matrix: + include: + - target: aarch64-apple-darwin + artifact: qbz-macos-aarch64 + - target: x86_64-apple-darwin + artifact: qbz-macos-x86_64 runs-on: macos-latest permissions: contents: write @@ -78,29 +84,30 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin + targets: ${{ matrix.target }} - name: Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri -> target + shared-key: macos-${{ matrix.target }} - name: Install JS dependencies run: npm ci - name: Build DMG - run: npm run tauri build -- --target aarch64-apple-darwin --bundles dmg + run: npm run tauri build -- --target ${{ matrix.target }} --bundles dmg - name: Collect artifacts run: | mkdir -p dist - cp -v src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/*.dmg dist/ || true + cp -v src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg dist/ || true ls -la dist - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: qbz-macos-aarch64 + name: ${{ matrix.artifact }} path: dist/* - name: Publish GitHub Release Assets diff --git a/README.md b/README.md index f851bfb2f..f2e1b8a24 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Download from [Releases](https://github.com/vicrodh/qbz/releases): `chmod +x QBZ > **QBZ is a Linux-first application.** macOS support is experimental and limited. Features like PipeWire, ALSA Direct, casting, and device control are unavailable. -Download the unsigned DMG from [Releases](https://github.com/vicrodh/qbz/releases) (Apple Silicon only). +Download the unsigned DMG from [Releases](https://github.com/vicrodh/qbz/releases). Since the DMG is unsigned, you may need to allow it in System Settings > Privacy & Security after first launch. diff --git a/crates/qbz-audio/Cargo.toml b/crates/qbz-audio/Cargo.toml index 5e2537ed2..ce5a862ef 100644 --- a/crates/qbz-audio/Cargo.toml +++ b/crates/qbz-audio/Cargo.toml @@ -39,3 +39,7 @@ dirs = "5" [target.'cfg(target_os = "linux")'.dependencies] # ALSA for direct hardware access (bit-perfect) alsa = "0.9" + +[target.'cfg(target_os = "macos")'.dependencies] +# CoreAudio for device probing, sample rate switching, and (future) hog mode / exclusive mode +coreaudio-rs = "0.14.1" diff --git a/crates/qbz-audio/src/backend.rs b/crates/qbz-audio/src/backend.rs index 680093eb7..959531481 100644 --- a/crates/qbz-audio/src/backend.rs +++ b/crates/qbz-audio/src/backend.rs @@ -128,6 +128,10 @@ pub struct BackendConfig { /// When true, force PipeWire clock.force-quantum for bit-perfect playback pub pw_force_bitperfect: bool, + + /// When true, skip `pactl set-default-sink` on stream creation. + /// Preserves external routing (JACK, qjackctl, Reaper). + pub skip_sink_switch: bool, } /// Result type for backend operations @@ -391,15 +395,29 @@ impl AudioBackend for CpalDefaultBackend { .map(|desc| desc.name().to_string()) .unwrap_or_else(|_| "Unknown Device".to_string()); let is_default = name == default_name; + + // On macOS, probe device capabilities via CoreAudio + #[cfg(target_os = "macos")] + let (supported_rates, max_rate, bus_type, is_hw) = { + Self::probe_macos_device(&name) + }; + #[cfg(not(target_os = "macos"))] + let (supported_rates, max_rate, bus_type, is_hw): ( + Option>, + Option, + Option, + bool, + ) = (None, None, None, false); + devices.push(AudioDevice { id: name.clone(), name, description: None, is_default, - max_sample_rate: None, - supported_sample_rates: None, - device_bus: None, - is_hardware: false, + max_sample_rate: max_rate, + supported_sample_rates: supported_rates, + device_bus: bus_type, + is_hardware: is_hw, }); } @@ -407,6 +425,17 @@ impl AudioBackend for CpalDefaultBackend { } fn create_output_stream(&self, config: &BackendConfig) -> BackendResult { + // On macOS, switch device sample rate to match the requested rate + #[cfg(target_os = "macos")] + { + if let Some(ref device_id) = config.device_id { + Self::switch_sample_rate_if_needed(device_id, config.sample_rate); + } else { + // Switch default device rate + Self::switch_default_device_rate_if_needed(config.sample_rate); + } + } + let device = if let Some(ref device_id) = config.device_id { self.host .output_devices() @@ -443,3 +472,106 @@ impl AudioBackend for CpalDefaultBackend { self } } + +#[cfg(target_os = "macos")] +impl CpalDefaultBackend { + /// Probe a macOS audio device for capabilities via CoreAudio APIs. + /// Returns (supported_rates, max_rate, bus_type, is_hardware). + fn probe_macos_device( + device_name: &str, + ) -> (Option>, Option, Option, bool) { + use crate::coreaudio_direct; + + let device_id = match coreaudio_direct::find_device_by_name(device_name) { + Ok(Some(id)) => { + log::info!("[CoreAudio] Found device '{}' with ID {}", device_name, id); + id + } + Ok(None) => { + log::debug!("[CoreAudio] Device '{}' not found via CoreAudio", device_name); + return (None, None, None, false); + } + Err(e) => { + log::debug!("[CoreAudio] Error finding device '{}': {}", device_name, e); + return (None, None, None, false); + } + }; + + let supported_rates = coreaudio_direct::query_supported_sample_rates(device_id) + .inspect(|rates| log::info!("[CoreAudio] Device '{}' supported rates: {:?}", device_name, rates)) + .inspect_err(|e| log::warn!("[CoreAudio] Failed to query rates for '{}': {}", device_name, e)) + .ok() + .filter(|r| !r.is_empty()); + let max_rate = supported_rates + .as_ref() + .and_then(|rates| rates.iter().max().copied()); + let bus_type = coreaudio_direct::get_device_transport_type(device_id); + let is_hardware = bus_type + .as_deref() + .is_some_and(|t| t == "usb" || t == "built-in" || t == "thunderbolt" || t == "firewire"); + + (supported_rates, max_rate, bus_type, is_hardware) + } + + /// Switch device sample rate before stream creation (if device supports the target rate). + fn switch_sample_rate_if_needed(device_name: &str, target_rate: u32) { + use crate::coreaudio_direct; + + log::info!("[CoreAudio] Rate switch requested: device='{}' target={}Hz", device_name, target_rate); + + let device_id = match coreaudio_direct::find_device_by_name(device_name) { + Ok(Some(id)) => id, + Ok(None) => { + log::warn!("[CoreAudio] Cannot switch rate: device '{}' not found", device_name); + return; + } + Err(e) => { + log::warn!("[CoreAudio] Cannot switch rate: {}", e); + return; + } + }; + + // Check if device supports the target rate + if let Ok(rates) = coreaudio_direct::query_supported_sample_rates(device_id) { + if !rates.contains(&target_rate) { + log::debug!( + "[CoreAudio] Device '{}' does not support {}Hz, skipping rate switch", + device_name, + target_rate + ); + return; + } + } + + if let Err(e) = coreaudio_direct::set_nominal_sample_rate(device_id, target_rate) { + log::warn!("[CoreAudio] Failed to switch sample rate: {}", e); + } + } + + /// Switch the default output device's sample rate. + fn switch_default_device_rate_if_needed(target_rate: u32) { + use crate::coreaudio_direct; + + let device_id = match coreaudio_direct::get_default_output_device() { + Ok(id) => id, + Err(e) => { + log::debug!("[CoreAudio] Could not get default device for rate switch: {}", e); + return; + } + }; + + if let Ok(rates) = coreaudio_direct::query_supported_sample_rates(device_id) { + if !rates.contains(&target_rate) { + log::debug!( + "[CoreAudio] Default device does not support {}Hz, skipping rate switch", + target_rate + ); + return; + } + } + + if let Err(e) = coreaudio_direct::set_nominal_sample_rate(device_id, target_rate) { + log::warn!("[CoreAudio] Failed to switch default device sample rate: {}", e); + } + } +} diff --git a/crates/qbz-audio/src/coreaudio_direct.rs b/crates/qbz-audio/src/coreaudio_direct.rs new file mode 100644 index 000000000..99c9d3423 --- /dev/null +++ b/crates/qbz-audio/src/coreaudio_direct.rs @@ -0,0 +1,165 @@ +//! CoreAudio direct access for macOS +//! +//! Provides device capability probing and sample rate switching on macOS +//! using the coreaudio-rs safe wrappers. +//! +//! Phase 1: Device probing + nominal sample rate switching (shared mode) +//! Phase 2 (future): Hog mode + integer mode + IO proc for bit-perfect playback + +#[cfg(target_os = "macos")] +use coreaudio::audio_unit::macos_helpers; +#[cfg(target_os = "macos")] +use coreaudio::audio_unit::Scope; + +/// CoreAudio device ID (re-exported so callers don't need objc2_core_audio) +#[cfg(target_os = "macos")] +pub type AudioDeviceID = u32; + +// CoreAudio transport type constants (FourCC values from AudioHardware.h) +#[cfg(target_os = "macos")] +mod transport_types { + pub const BUILT_IN: u32 = 0x626c746e; // 'bltn' + pub const USB: u32 = 0x75736220; // 'usb ' + pub const BLUETOOTH: u32 = 0x626c7565; // 'blue' + pub const BLUETOOTH_LE: u32 = 0x626c6561; // 'blea' + pub const HDMI: u32 = 0x68646d69; // 'hdmi' + pub const DISPLAY_PORT: u32 = 0x64707274; // 'dprt' + pub const THUNDERBOLT: u32 = 0x7468756e; // 'thun' + pub const FIREWIRE: u32 = 0x31333934; // '1394' + pub const VIRTUAL: u32 = 0x76697274; // 'virt' + pub const AGGREGATE: u32 = 0x67727570; // 'grup' +} + +/// Common audio sample rates to check against device capabilities +#[cfg(target_os = "macos")] +const COMMON_SAMPLE_RATES: &[u32] = &[ + 44100, 48000, 88200, 96000, 176400, 192000, 352800, 384000, 705600, 768000, +]; + +/// Query supported sample rates for a CoreAudio device. +/// Returns discrete rates from the device's available nominal sample rate ranges. +#[cfg(target_os = "macos")] +pub fn query_supported_sample_rates(device_id: AudioDeviceID) -> Result, String> { + let ranges = macos_helpers::get_available_sample_rates(device_id) + .map_err(|e| format!("Failed to get sample rate ranges: {:?}", e))?; + + let mut rates = Vec::new(); + for range in &ranges { + if (range.mMinimum - range.mMaximum).abs() < 0.5 { + // Point value (min == max) + rates.push(range.mMinimum as u32); + } else { + // Continuous range — check which common rates fall within it + for &rate in COMMON_SAMPLE_RATES { + let rate_f = rate as f64; + if rate_f >= range.mMinimum && rate_f <= range.mMaximum { + rates.push(rate); + } + } + } + } + + rates.sort_unstable(); + rates.dedup(); + Ok(rates) +} + +/// Set the nominal sample rate of a device. +/// Delegates to coreaudio-rs which handles async confirmation with a 2-second timeout. +#[cfg(target_os = "macos")] +pub fn set_nominal_sample_rate(device_id: AudioDeviceID, target_rate: u32) -> Result<(), String> { + log::info!( + "[CoreAudio] Switching sample rate to {}Hz on device {}", + target_rate, + device_id + ); + + macos_helpers::set_device_sample_rate(device_id, target_rate as f64) + .map_err(|e| format!("Failed to set sample rate to {}Hz: {:?}", target_rate, e))?; + + log::info!("[CoreAudio] Sample rate switched to {}Hz", target_rate); + Ok(()) +} + +/// Get the default output device ID. +#[cfg(target_os = "macos")] +pub fn get_default_output_device() -> Result { + macos_helpers::get_default_device_id(false) + .ok_or_else(|| "No default output device found".to_string()) +} + +/// Get all output device IDs. +#[cfg(target_os = "macos")] +pub fn get_output_device_ids() -> Result, String> { + macos_helpers::get_audio_device_ids_for_scope(Scope::Output) + .map_err(|e| format!("Failed to enumerate output devices: {:?}", e)) +} + +/// Get the name of a CoreAudio device. +#[cfg(target_os = "macos")] +pub fn get_device_name(device_id: AudioDeviceID) -> Result { + macos_helpers::get_device_name(device_id) + .map_err(|e| format!("Failed to get device name: {:?}", e)) +} + +/// Find a CoreAudio output device ID by its name. +#[cfg(target_os = "macos")] +pub fn find_device_by_name(name: &str) -> Result, String> { + // get_device_id_from_name: input=false means output device + Ok(macos_helpers::get_device_id_from_name(name, false)) +} + +/// Get the transport type of a device (USB, built-in, Bluetooth, etc.) +#[cfg(target_os = "macos")] +pub fn get_device_transport_type(device_id: AudioDeviceID) -> Option { + let transport = macos_helpers::get_device_transport_type(device_id).ok()?; + + let transport_str = if transport == transport_types::BUILT_IN { + "built-in" + } else if transport == transport_types::USB { + "usb" + } else if transport == transport_types::BLUETOOTH + || transport == transport_types::BLUETOOTH_LE + { + "bluetooth" + } else if transport == transport_types::HDMI + || transport == transport_types::DISPLAY_PORT + { + "hdmi" + } else if transport == transport_types::THUNDERBOLT { + "thunderbolt" + } else if transport == transport_types::FIREWIRE { + "firewire" + } else if transport == transport_types::VIRTUAL { + "virtual" + } else if transport == transport_types::AGGREGATE { + "aggregate" + } else { + "unknown" + }; + + Some(transport_str.to_string()) +} + +// ---- Non-macOS stubs ---- + +/// Query supported sample rates (stub for non-macOS) +#[cfg(not(target_os = "macos"))] +pub fn query_supported_sample_rates(_device_name: &str) -> Result, String> { + Ok(Vec::new()) +} + +/// Get the current nominal sample rate (stub for non-macOS) +#[cfg(not(target_os = "macos"))] +pub fn get_nominal_sample_rate_by_name(_device_name: &str) -> Result { + Err("CoreAudio is only available on macOS".to_string()) +} + +/// Set the nominal sample rate (stub for non-macOS) +#[cfg(not(target_os = "macos"))] +pub fn set_nominal_sample_rate_by_name( + _device_name: &str, + _target_rate: u32, +) -> Result<(), String> { + Err("CoreAudio is only available on macOS".to_string()) +} diff --git a/crates/qbz-audio/src/lib.rs b/crates/qbz-audio/src/lib.rs index 1b63f4a8b..6ba78536e 100644 --- a/crates/qbz-audio/src/lib.rs +++ b/crates/qbz-audio/src/lib.rs @@ -34,6 +34,7 @@ pub mod alsa_backend; #[cfg(target_os = "linux")] pub mod pulse_backend; pub mod alsa_direct; +pub mod coreaudio_direct; pub mod analysis; pub mod analyzer_tap; pub mod diagnostic; diff --git a/crates/qbz-audio/src/pipewire_backend.rs b/crates/qbz-audio/src/pipewire_backend.rs index 38752c06b..cf880c04d 100644 --- a/crates/qbz-audio/src/pipewire_backend.rs +++ b/crates/qbz-audio/src/pipewire_backend.rs @@ -308,7 +308,10 @@ impl AudioBackend for PipeWireBackend { // Temporarily set default sink to target (if specified) // We DON'T restore it - let the user's system keep the selected device as default // This is actually the expected behavior: when you select a device, it becomes the default - if let Some(sink_name) = &target_sink { + // When skip_sink_switch is true, skip this entirely to preserve external routing (JACK/qjackctl) + if config.skip_sink_switch { + log::info!("[PipeWire Backend] Skipping set-default-sink (skip_sink_switch enabled)"); + } else if let Some(sink_name) = &target_sink { log::info!("[PipeWire Backend] Setting default sink to: {}", sink_name); let set_result = Command::new("pactl") diff --git a/crates/qbz-audio/src/settings.rs b/crates/qbz-audio/src/settings.rs index 10e03ad26..68fed1eae 100644 --- a/crates/qbz-audio/src/settings.rs +++ b/crates/qbz-audio/src/settings.rs @@ -50,6 +50,11 @@ pub struct AudioSettings { /// When true, force PipeWire clock.force-quantum alongside clock.force-rate for bit-perfect. /// Reset both to 0 on stop. PipeWire-only, requires dac_passthrough. pub pw_force_bitperfect: bool, + /// When true, skip `pactl set-default-sink` on stream creation. + /// Preserves external routing (JACK, qjackctl, Reaper). + pub skip_sink_switch: bool, + /// When true, automatically try lower quality tiers if the requested one fails. + pub allow_quality_fallback: bool, } impl Default for AudioSettings { @@ -72,6 +77,8 @@ impl Default for AudioSettings { normalization_target_lufs: -14.0, // Spotify/YouTube standard gapless_enabled: false, // Off by default — user opts in pw_force_bitperfect: false, // Off by default — experimental PipeWire feature + skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups + allow_quality_fallback: false, // Off by default — fail rather than silently downgrade } } } @@ -160,6 +167,14 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN pw_force_bitperfect INTEGER DEFAULT 0", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0", + [], + ); Ok(Self { conn }) } @@ -178,7 +193,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -215,6 +230,8 @@ impl AudioSettingsStore { normalization_target_lufs: row.get::<_, Option>(13)?.unwrap_or(-14.0) as f32, gapless_enabled: row.get::<_, Option>(14)?.unwrap_or(0) != 0, pw_force_bitperfect: row.get::<_, Option>(16)?.unwrap_or(0) != 0, + skip_sink_switch: row.get::<_, Option>(17)?.unwrap_or(0) != 0, + allow_quality_fallback: row.get::<_, Option>(18)?.unwrap_or(0) != 0, }) }, ) diff --git a/crates/qbz-cast/Cargo.toml b/crates/qbz-cast/Cargo.toml index d96203964..b8bbb31d2 100644 --- a/crates/qbz-cast/Cargo.toml +++ b/crates/qbz-cast/Cargo.toml @@ -6,7 +6,7 @@ description = "Casting support for QBZ - Chromecast, AirPlay, DLNA" [dependencies] # Chromecast -rust_cast = "0.18.1" +rust_cast = "0.21" # mDNS discovery mdns-sd = "0.11" diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index be4816d6d..88c7e86f7 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -110,6 +110,31 @@ impl QbzCore { } } + /// Restore a session from a saved OAuth user_auth_token. + pub async fn login_with_token(&self, token: &str) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + match client.login_with_token(token).await { + Ok(session) => { + self.emit(CoreEvent::LoggedIn { + session: session.clone(), + }) + .await; + Ok(session) + } + Err(e) => { + self.emit(CoreEvent::Error { + code: "OAUTH_TOKEN_FAILED".to_string(), + message: e.to_string(), + recoverable: true, + }) + .await; + Err(CoreError::AuthFailed(e.to_string())) + } + } + } + /// Inject an already-authenticated session (e.g. from OAuth flow). /// Emits a LoggedIn event so the rest of the system knows auth state changed. pub async fn set_session(&self, session: UserSession) -> Result<(), CoreError> { diff --git a/crates/qbz-integrations/src/musicbrainz/client.rs b/crates/qbz-integrations/src/musicbrainz/client.rs index 390ef34ad..00ebb019b 100644 --- a/crates/qbz-integrations/src/musicbrainz/client.rs +++ b/crates/qbz-integrations/src/musicbrainz/client.rs @@ -263,10 +263,25 @@ impl MusicBrainzClient { } /// Resolve an artist to get MusicBrainz ID + /// + /// Prefers exact name matches over highest score to avoid disambiguation + /// issues (e.g., multiple artists named "The Warning"). pub async fn resolve_artist(&self, name: &str) -> IntegrationResult> { - let response = self.search_artist(name, 5).await?; + let response = self.search_artist(name, 10).await?; - if let Some(artist) = response.artists.first() { + if response.artists.is_empty() { + return Ok(None); + } + + // Prefer exact name match (case-insensitive) + let target = name.trim().to_lowercase(); + let best = response + .artists + .iter() + .find(|a| a.name.trim().to_lowercase() == target && a.score.unwrap_or(0) >= 90) + .or_else(|| response.artists.first()); + + if let Some(artist) = best { let confidence = MatchConfidence::from_score(artist.score); return Ok(Some(ResolvedArtist { @@ -651,6 +666,73 @@ impl MusicBrainzClient { Ok(None) } + /// Resolve the country that an area belongs to by walking up the hierarchy. + /// + /// Frankfurt am Main → Hessen → Germany → returns ("Germany", Some("de")) + /// Monterrey → Nuevo León → Mexico → returns ("Mexico", Some("mx")) + /// + /// Returns (country_name, country_code_lowercase) or None. + pub async fn resolve_area_country( + &self, + area_id: &str, + ) -> IntegrationResult)>> { + let mut current_id = area_id.to_string(); + let max_hops = 5; + + for _hop in 0..max_hops { + let detail = self.get_area_with_relations(¤t_id).await?; + + // If current area IS a country, return it + if detail + .area_type + .as_deref() + .map(|t| t.eq_ignore_ascii_case("country")) + .unwrap_or(false) + { + let code = detail + .iso_codes + .as_ref() + .and_then(|c| c.first()) + .map(|c| c.to_lowercase()); + return Ok(Some((detail.name, code))); + } + + // Find "part of" parent + let parent = detail + .relations + .as_ref() + .and_then(|rels| { + rels.iter() + .find(|rel| { + rel.relation_type == "part of" + && rel.direction.as_deref() == Some("backward") + }) + .and_then(|rel| rel.area.as_ref()) + }); + + match parent { + Some(p) => { + if p.area_type + .as_deref() + .map(|t| t.eq_ignore_ascii_case("country")) + .unwrap_or(false) + { + let code = p + .iso_codes + .as_ref() + .and_then(|c| c.first()) + .map(|c| c.to_lowercase()); + return Ok(Some((p.name.clone(), code))); + } + current_id = p.id.clone(); + } + None => return Ok(None), + } + } + + Ok(None) + } + // ============ Internal Helpers ============ async fn check_enabled(&self) -> IntegrationResult<()> { diff --git a/crates/qbz-library/Cargo.toml b/crates/qbz-library/Cargo.toml index 3b6d31789..addd19f1a 100644 --- a/crates/qbz-library/Cargo.toml +++ b/crates/qbz-library/Cargo.toml @@ -24,7 +24,7 @@ walkdir = "2" dirs = "5" # Audio metadata -lofty = "0.18" +lofty = "0.23" # Image processing (thumbnails) image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/crates/qbz-library/src/metadata.rs b/crates/qbz-library/src/metadata.rs index fd9342f83..dd8190265 100644 --- a/crates/qbz-library/src/metadata.rs +++ b/crates/qbz-library/src/metadata.rs @@ -1,6 +1,8 @@ //! Metadata extraction for audio files -use lofty::{Accessor, AudioFile, ItemKey, Probe, TaggedFileExt}; +use lofty::prelude::*; +use lofty::probe::Probe; +use lofty::tag::ItemKey; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -452,7 +454,7 @@ impl MetadataExtractor { .or_else(|| fallback_artist.clone()) .unwrap_or_else(|| "Unknown Artist".to_string()), album: album_title, - album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()), + album_artist: tag.get_string(ItemKey::AlbumArtist).map(|s| s.to_string()), album_group_key, album_group_title, track_number: tag @@ -463,10 +465,10 @@ impl MetadataExtractor { .disk() .and_then(|d| if d > 0 { Some(d as u32) } else { None }) .or(inferred_disc), - year: tag.year().map(|y| y as u32), + year: tag.date().map(|ts| ts.year as u32), genre: tag.genre().map(|s| s.to_string()), catalog_number: tag - .get_string(&ItemKey::CatalogNumber) + .get_string(ItemKey::CatalogNumber) .map(|s| s.to_string()), duration_secs, format, diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index e2964364f..c8c03ff15 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -465,8 +465,16 @@ fn try_init_stream_with_backend( sample_rate: u32, channels: u16, ) -> Option> { - // Check if backend system is configured - let backend_type = audio_settings.backend_type?; + // Check if backend system is configured. + // On non-Linux, default to SystemDefault when not explicitly set, + // so macOS gets CoreAudio device probing and sample rate switching. + let backend_type = audio_settings.backend_type.or_else(|| { + if cfg!(not(target_os = "linux")) { + Some(qbz_audio::AudioBackendType::SystemDefault) + } else { + None + } + })?; log::info!( "Using backend system: {:?} (device: {:?}, plugin: {:?})", @@ -500,6 +508,7 @@ fn try_init_stream_with_backend( exclusive_mode: audio_settings.exclusive_mode, alsa_plugin: audio_settings.alsa_plugin, pw_force_bitperfect: audio_settings.pw_force_bitperfect, + skip_sink_switch: audio_settings.skip_sink_switch, }; // For ALSA backend with hw: devices, try direct ALSA first (Linux only) @@ -602,6 +611,8 @@ pub struct SharedState { gapless_ready: Arc, /// Track ID of the gapless-queued next track (0 = none) gapless_next_track_id: Arc, + /// Streaming buffer progress (0.0-1.0 stored as f32 bits, 0 = not streaming) + buffer_progress: Arc, } impl Default for SharedState { @@ -628,6 +639,7 @@ impl SharedState { normalization_gain: Arc::new(AtomicU32::new(0)), gapless_ready: Arc::new(AtomicBool::new(false)), gapless_next_track_id: Arc::new(AtomicU64::new(0)), + buffer_progress: Arc::new(AtomicU32::new(0)), } } @@ -671,6 +683,23 @@ impl SharedState { } } + /// Set streaming buffer progress (0.0 to 1.0). Pass 0.0 when not streaming. + pub fn set_buffer_progress(&self, progress: f32) { + self.buffer_progress + .store(progress.to_bits(), Ordering::SeqCst); + } + + /// Get streaming buffer progress (0.0 to 1.0). Returns None if not streaming. + pub fn get_buffer_progress(&self) -> Option { + let bits = self.buffer_progress.load(Ordering::SeqCst); + let progress = f32::from_bits(bits); + if progress <= 0.0 || progress >= 1.0 { + None + } else { + Some(progress) + } + } + pub fn set_current_device(&self, device: Option) { if let Ok(mut d) = self.current_device.write() { *d = device; @@ -2314,6 +2343,14 @@ impl Player { if now.duration_since(last_empty_check) >= Duration::from_millis(500) { last_empty_check = now; + // Update streaming buffer progress for UI seekbar + if let Some(streaming_src) = current_streaming_source.as_ref() { + let progress = streaming_src.progress().unwrap_or(1.0); + thread_state.set_buffer_progress(progress); + } else { + thread_state.set_buffer_progress(0.0); + } + // Streaming -> cached promotion: // once streaming download completes, persist full data and clear streaming marker. // This unlocks normal gapless pre-queue for the current track's tail. diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 18bdb3c83..71c4846d0 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -276,6 +276,62 @@ impl QobuzClient { } } + /// Restore a session from a previously saved OAuth user_auth_token. + /// + /// Used at startup when the user logged in via OAuth (web browser) and the + /// token was persisted. Calls POST /user/login with X-User-Auth-Token header. + /// Returns an error if the token has expired. + pub async fn login_with_token(&self, token: &str) -> Result { + use reqwest::header::{HeaderMap, HeaderValue}; + + let tokens = self.tokens.read().await; + let app_id = tokens + .as_ref() + .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))? + .app_id + .clone(); + drop(tokens); + + let user_login_url = endpoints::build_url(endpoints::paths::USER_LOGIN); + let mut headers = HeaderMap::new(); + headers.insert( + "X-App-Id", + HeaderValue::from_str(&app_id).map_err(|_| ApiError::InvalidAppId)?, + ); + headers.insert( + "X-User-Auth-Token", + HeaderValue::from_str(token) + .map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?, + ); + + log::info!("[OAuth] Restoring session from saved token"); + let resp = self + .http + .post(&user_login_url) + .headers(headers) + .header("Content-Type", "text/plain;charset=UTF-8") + .body("extra=partner") + .send() + .await?; + + match resp.status() { + StatusCode::OK => { + let json: serde_json::Value = resp.json().await?; + let session = super::auth::parse_login_response(&json)?; + *self.session.write().await = Some(session.clone()); + log::info!("[OAuth] Session restored from token"); + Ok(session) + } + StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError( + "OAuth token expired or invalid".to_string(), + )), + status => Err(ApiError::ApiResponse(format!( + "Token re-auth failed: {}", + status + ))), + } + } + /// Get current user info (display name, subscription, and expiry if available) pub async fn get_user_info(&self) -> Option<(String, String, Option)> { self.session.read().await.as_ref().map(|s| { diff --git a/crates/qconnect-protocol/src/decoder.rs b/crates/qconnect-protocol/src/decoder.rs index 3dccee2d8..463f7e0f4 100644 --- a/crates/qconnect-protocol/src/decoder.rs +++ b/crates/qconnect-protocol/src/decoder.rs @@ -1015,13 +1015,6 @@ fn queue_version_opt( Ok(Some(QueueVersion::new(major, minor))) } -fn required_i32_to_u64(value: Option, field_name: &str) -> Result { - let raw = value.ok_or_else(|| { - ProtocolError::InvalidPayload(format!("missing required numeric field '{field_name}'")) - })?; - i32_to_u64(raw) -} - fn optional_i32_to_u64_named( value: Option, field_name: &str, diff --git a/docs/release-1.2.2/CHANGELOG_FULL.md b/docs/release-1.2.2/CHANGELOG_FULL.md new file mode 100644 index 000000000..5b718396c --- /dev/null +++ b/docs/release-1.2.2/CHANGELOG_FULL.md @@ -0,0 +1,90 @@ +ea2280f8 chore: update codename to Bye bye plain auth +ee019851 chore: bump version to 1.2.2 +e66117fb feat(auth): remove basic auth login, OAuth only +adfc3900 fix(notifications): use released notify-rust 4.13.0 instead of fork +7a1890ad fix(audio): use released coreaudio-rs 0.14.1 instead of fork +b1000cb9 fix(macos): gate Linux graphics CLI flags and crash handler with cfg +34a18093 fix: restore albumId/artistId on session resume for player bar navigation +3ac7073b feat(qobuz): migrate login_with_token to V2 crates +211f45a5 fix(favorites): bulk make offline now actually downloads tracks +ba3ccf9a feat(settings): hide tray sub-settings on macOS and use menu bar terminology +7edffcf5 feat(i18n): add macOS-specific menu bar tray keys for all locales +84b1f1a5 docs: remove Apple Silicon only note from macOS section +833cc2b3 fix: boost immersive drag region z-index on macOS +8283bec6 feat: add Cmd+, keyboard shortcut to open settings +35d0ff39 chore: remove unused github.svg (replaced by simple-icons in PR #237) +b8af1763 feat(macos): add album artwork to notifications via image_path +a4a591e7 feat(macos): switch to notify-rust fork with image_path support +684dd558 fix(macos): scope title bar padding to non-home views only +9823dfb6 fix(macos): enable x86_64 cross-compilation +071865e7 fix: replace removed Github icon with simple-icons +cd2472f5 fix(macos): add top padding to clear native overlay title bar +b6ad1926 fix(macos): hide Linux-only settings on macOS +47835305 fix(macos): gate idle_inhibit module for Linux only +1e019bec fix: resolve correct artist and country for MusicBrainz metadata +1953774d fix: resolve birth country from area hierarchy instead of MB country field +305cffa0 fix: don't append country to birth location display +f702622e fix: correct artist birth/formation location display order +d19c142d fix: switch log upload to paste.rs (dpaste.org returns 403/405) +527d63ca fix: migrate log upload from 0x0.st to dpaste.org +a87e35d1 fix: restore MPRIS metadata and playback context on session resume +6acd8438 feat: add streaming buffer progress indicator to seekbar +18403bba fix: single ESC exits both immersive overlay and window fullscreen +b315b23b fix: F11 fullscreen toggle and ESC exit work globally +759149f3 feat(favorites): add bulk make-offline action to track selection bar +93374080 feat(titlebar): add Playlists to favorites dropdown in titlebar nav +b00f294e feat(sidebar): add Playlists to favorites quick access menu +080b916c feat: prevent system sleep during playback via XDG portal +2900fb88 feat: add log sanitization for sensitive IDs and UUIDs +4e526b1a chore(deps): upgrade vite 6->8, vite-plugin-svelte 5->7, kit 2.55 +a99d558e fix: update lofty 0.18 API calls to lofty 0.23 +166d3467 fix: update axum 0.7 API calls to axum 0.8 +a2fb4451 feat(macos): add deep link support for qobuzapp:// URLs +66a0c8f0 fix: update rand 0.8 API calls to rand 0.10 +00cf8cf4 refactor(icons): rename all deprecated lucide-svelte icons to v1 names +6bf397e0 feat(macos): auto-select SystemDefault backend on non-Linux +4c15a432 feat(macos): enhance CpalDefaultBackend with device capabilities +c51f07fe feat(macos): add CoreAudio device probing and sample rate switching +84c9b693 style: fix indentation and missing semicolon from PR #230 +38258ec9 fix(i18n): post-merge corrections for PR #226 +b6cebe26 feat(i18n): add translation strings to AboutModal +0549a701 feat(i18n): add french translation strings +67b8bf3c feat(i18n): add spanish translation strings +bc126341 feat(i18n): add english translation strings +881df48f feat(i18n): add translation strings to SettingsView +d97e8f86 feat(i18n): add translation strings to LocalLibraryView +28cf84a4 feat(i18n): translation strings in Sidebar +64061176 feat(i18n): translation strings in SearchView +38efc967 fix(i18n): middot in TopQView +935cf34c fix(i18n): date formatting in PurchasesView +34c6e413 fix(i18n): date formatting in PurchaseAlbumDetailView +0f0dfda1 fix(i18n): date formatting in BlacklistManagerView +17babe48 fix(i18n): date formatting in ArtistDetailView +40801dc7 feat(i18n): add translation strings to PlaylistmanagerView +fafd5473 Fix scroll position not being saved when navigating without scrolling +b7068f3d feat(i18n): add translation strings to MusicianPageView +95daf91d feat(i18n): add translation strings to LoginView +82d81352 feat(i18n): add translation strings to LabelView +868a6bdf fix(i18n): CD quality on HomeView +55b7d97a feat(i18n): add translation strings to ForYouTab +656866cd feat(i18n): add translation strings to FavQView +b2bd4a2a feat(i18n): add translation strings to DynamicSuggestView +37de089c feat(i18n): add translation strings to DiscoverBrowseView +eb7a6a9a feat(i18n): add translation strings to BlacklistManagerView +55bc56ae fix(i18n): title sort on artist master-detail view +46943be4 Fix scroll restoration for virtualized containers +b879a8f9 feat(i18n): adjustments on ArtistsByLocationView +cddb98ed feat(i18n): add english translations +d6a4b11a feat(i18n): add french translations +c0a29020 feat(i18n): add strings to FavoritesView +2e450fec feat(i18n): adjustments on artist detailed view +5609cc1c feat(i18n): album scrolling prev and next strings +18f99c7b feat(i18n): localization for CD Quality on quality badge +e4b98223 feat(i18n): localized release date on album credits +d6eedb7d feat(i18n): add language strings for artist page +0082f474 feat(i18n): add translation strings for CD Quality +b935b836 feat(i18n): add localization to album release dates +4a7cc604 feat(i18n): add translation strings in ArtistDetailView +1c51eac5 docs: add contributors section to README, remove inline credits +e34212a5 docs: add macOS experimental section to README +88c352f6 fix(gentoo): use libayatana-appindicator instead of libappindicator diff --git a/docs/release-1.2.2/TAG_DETAILS.md b/docs/release-1.2.2/TAG_DETAILS.md new file mode 100644 index 000000000..e30cc27c9 --- /dev/null +++ b/docs/release-1.2.2/TAG_DETAILS.md @@ -0,0 +1,70 @@ +# 1.2.2 — Bye bye plain auth + +About a month and a half ago, Qobuz began making changes to their authentication system. This was the reason we introduced OAuth login early on — and fortunately, doing so ahead of time meant no one was truly locked out of the app. This release simply removes direct credential login, making OAuth the sole authentication method. There is also some bugfixing for issues reported on GitHub. + +We are considering requesting official API access again, and looking for ways to coordinate with the community to support this request to Qobuz — so that a more severe change in the future does not leave us out. + +Version 1.2.2 was supposed to take a little longer to arrive, with initial support for BSD and headless mode, but this authentication issue has forced us to bring it forward; there aren't really many new features this time. + +Thank you all for the continued support for QBZ. + +--- + +## Authentication + + - **OAuth-only login** — username/password authentication has been removed; OAuth via browser is now the sole login method + - **System browser login** — new option to authenticate via your default browser (Firefox, Chrome, etc.) instead of the embedded WebView; supports password managers and saved sessions + - **Bootstrap no longer attempts basic auth** — users with old saved credentials now see the login screen instead of hitting Qobuz's deprecated endpoint + - **Token login migrated to V2 crates** — OAuth token handling moved to the V2 core architecture + +## Bug Fixes + + - **"Go to Album" / "Go to Artist" broken after launch** — navigation from the player bar now works without starting new playback first (#252) + - **MPRIS metadata missing on session restore** — playback context and metadata now populate correctly on startup (#240) + - **Incorrect artist location in Artist Network** — birth/formation location display fixed for MusicBrainz area hierarchy (#235) + - **Bulk "make available offline" not downloading** — the action now actually triggers track downloads (#231) + - **F11 fullscreen and ESC exit** — both now work globally across all views (#202) + - **Cached tracks playing at wrong quality** — cache now validates sample rate and bit depth against the requested quality; stale fallback-quality files are re-downloaded transparently (#270) + - **JACK/qjackctl routing broken between tracks** — new "Lock Output Device" option prevents QBZ from changing the system default sink on stream recreation (#263) + - **Scroll position lost on navigation** — scroll state is now saved even without explicit scrolling + - **Virtualized container scroll restoration** — fixed for all virtualized list and grid components + +## New Features + + - **Playlists in favorites quick access** — added to both the sidebar menu and the titlebar dropdown (#233) + - **Bulk make-offline action** — select multiple tracks and download them all at once from the selection bar (#231) + - **Sleep inhibition** — system sleep is now prevented during active playback via XDG portal (#229) + - **Log sanitization** — sensitive IDs and UUIDs are now stripped from logs before upload (#213) + - **Streaming buffer indicator** — progress bar in the seekbar shows how much of the track has been buffered (#194) + - **Lock Output Device** — opt-in PipeWire setting for JACK/DAW users; skips pactl set-default-sink to preserve external routing (#263) + +## macOS Improvements + + - **CoreAudio device probing** — sample rate switching and device capability detection + - **Deep link support** — qobuzapp:// URLs handled natively + - **Rich notifications** — album artwork in desktop notifications via image_path + - **Platform-aware settings** — Linux-only options hidden on macOS; menu bar terminology for tray settings + - **Cmd+, shortcut** — opens settings (standard macOS convention) + - **Graphics CLI flags gated** — --autoconfig-graphics, --reset-graphics, --reset-dmabuf are Linux-only + - **Released upstream deps** — coreaudio-rs 0.14.1 and notify-rust 4.13.0 replace git forks + - **x86_64 cross-compilation** — macOS builds now support Intel Macs + +## i18n + + - **20+ views and components** — hardcoded strings replaced with translation keys + - **Date formatting fixes** — corrected across ArtistDetailView, PurchasesView, BlacklistManagerView, and PurchaseAlbumDetailView + - **CD quality label** — localized across all badge and quality displays + - **macOS-specific keys** — menu bar terminology for all 5 locales + +## Other + + - **Log upload migrated to paste.rs** — dpaste.org now returns 403/405 + - **Gentoo overlay** — use libayatana-appindicator instead of libappindicator (#262) + - **Security** — esbuild upgraded to 0.25.x (CVE in <= 0.24.2); CodeQL cleartext-logging alerts resolved + - **Dependency updates** — vite 8, lofty 0.23, axum 0.8, rand 0.10, souvlaki 0.8.3, lucide-svelte 1.0 + +--- + +Thanks to @afonsojramos, @GwendalBeaumont, and @AdamArstall for their contributions to this release. + +Full changelog: https://github.com/vicrodh/qbz/compare/v1.2.1...v1.2.2 diff --git a/flake.nix b/flake.nix index 4cae7a891..8d886f99a 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ # VERSION BUMP: update version, rev, and hashes # when tagging a new release. # ────────────────────────────────────────────── - qbzVersion = "1.2.1"; + qbzVersion = "1.2.2"; qbzRev = "v${qbzVersion}"; srcHash = ""; # nix build will report the correct hash on first run npmHash = ""; # nix build will report the correct hash on first run diff --git a/package-lock.json b/package-lock.json index 72ec3386a..a7a073d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,33 +1,35 @@ { "name": "qbz-nix", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qbz-nix", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { + "@icons-pack/svelte-simple-icons": "^7.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2.5.0", "@tauri-apps/plugin-opener": "^2", - "lucide-svelte": "^0.562.0", + "@tauri-apps/plugin-os": "^2", + "lucide-svelte": "^1.0.1", "svelte-i18n": "^4.0.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", - "@sveltejs/kit": "^2.52.2", - "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tauri-apps/cli": "^2", "@testing-library/svelte": "^5.3.1", "jsdom": "^27.4.0", - "svelte": "^5.54.0", - "svelte-check": "^4.4.5", + "svelte": "^5.55.0", + "svelte-check": "^4.4.6", "typescript": "~5.9.3", - "vite": "^6.0.3", - "vitest": "^4.1.0" + "vite": "^8.0.3", + "vitest": "^4.1.2" } }, "node_modules/@acemir/cssom": { @@ -242,6 +244,40 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -727,6 +763,20 @@ "tslib": "^2.8.0" } }, + "node_modules/@icons-pack/svelte-simple-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@icons-pack/svelte-simple-icons/-/svelte-simple-icons-7.2.0.tgz", + "integrity": "sha512-zH4YByltlMBipdijJ1ZN4YGmxn2LKLCt+wCDC1rF0+3yor95Df090lZ5wfsN/UN4JsLjr7e6nxp6MOsEfYv/cQ==", + "license": "MIT", + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.5.0", + "svelte": "^5.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -772,368 +822,289 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", "cpu": [ - "arm" + "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", "cpu": [ "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { @@ -1159,7 +1130,6 @@ "version": "2.55.0", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", - "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1198,43 +1168,22 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "node_modules/@tauri-apps/api": { @@ -1491,6 +1440,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-os": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", + "integrity": "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1551,6 +1509,16 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1573,7 +1541,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, "license": "MIT" }, "node_modules/@types/deep-eql": { @@ -1596,31 +1563,31 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1629,7 +1596,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1651,26 +1618,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { @@ -1678,14 +1645,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1694,9 +1661,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", "dev": true, "license": "MIT", "funding": { @@ -1704,15 +1671,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1864,7 +1831,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1980,6 +1946,15 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devalue": { "version": "5.6.4", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", @@ -2175,7 +2150,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -2193,7 +2167,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -2342,12 +2315,260 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -2374,9 +2595,9 @@ } }, "node_modules/lucide-svelte": { - "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.562.0.tgz", - "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", "license": "ISC", "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" @@ -2440,7 +2661,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2457,7 +2677,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2482,7 +2701,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -2513,14 +2731,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -2530,10 +2746,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -2614,49 +2829,37 @@ "node": ">=0.10.0" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, "node_modules/sade": { @@ -2688,7 +2891,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, "license": "MIT" }, "node_modules/siginfo": { @@ -2702,7 +2904,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -2717,7 +2918,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2738,9 +2938,9 @@ "license": "MIT" }, "node_modules/svelte": { - "version": "5.54.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", - "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -2765,9 +2965,9 @@ } }, "node_modules/svelte-check": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", - "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2872,7 +3072,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -2919,7 +3118,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2967,7 +3165,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2978,24 +3176,22 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3004,14 +3200,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -3020,15 +3217,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -3056,7 +3256,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -3073,19 +3272,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3096,8 +3295,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -3113,13 +3312,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { diff --git a/package.json b/package.json index 1ef619e3a..4a7c2b35f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qbz-nix", - "version": "1.2.1", + "version": "1.2.2", "description": "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)", "type": "module", "scripts": { @@ -26,28 +26,30 @@ }, "license": "MIT", "dependencies": { + "@icons-pack/svelte-simple-icons": "^7.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2.5.0", "@tauri-apps/plugin-opener": "^2", - "lucide-svelte": "^0.562.0", + "@tauri-apps/plugin-os": "^2", + "lucide-svelte": "^1.0.1", "svelte-i18n": "^4.0.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", - "@sveltejs/kit": "^2.52.2", - "@sveltejs/vite-plugin-svelte": "^5.1.1", + "@sveltejs/kit": "^2.55.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tauri-apps/cli": "^2", "@testing-library/svelte": "^5.3.1", "jsdom": "^27.4.0", - "svelte": "^5.54.0", - "svelte-check": "^4.4.5", + "svelte": "^5.55.0", + "svelte-check": "^4.4.6", "typescript": "~5.9.3", - "vite": "^6.0.3", - "vitest": "^4.1.0" + "vite": "^8.0.3", + "vitest": "^4.1.2" }, "overrides": { - "esbuild": "^0.25.0", - "cookie": "^0.7.0" + "cookie": "^0.7.0", + "esbuild": "^0.25.0" } } diff --git a/packaging/flatpak/com.blitzfc.qbz.metainfo.xml b/packaging/flatpak/com.blitzfc.qbz.metainfo.xml index 59e3c6f72..718b71a6d 100644 --- a/packaging/flatpak/com.blitzfc.qbz.metainfo.xml +++ b/packaging/flatpak/com.blitzfc.qbz.metainfo.xml @@ -53,6 +53,26 @@ + + +

About a month and a half ago, Qobuz began making changes to their authentication system. This was the reason we introduced OAuth login early on — and fortunately, doing so ahead of time meant no one was truly locked out of the app. This release simply removes direct credential login, making OAuth the sole authentication method. There is also some bugfixing for issues reported on GitHub.

+

We are considering requesting official API access again, and looking for ways to coordinate with the community to support this request to Qobuz — so that a more severe change in the future does not leave us out. Thank you all for the continued support for QBZ.

+
    +
  • OAuth is now the only login method; username/password authentication has been removed
  • +
  • Fixed "Go to Album" and "Go to Artist" navigation failing until starting new playback (#252)
  • +
  • Fixed MPRIS metadata not populating on session restore (#240)
  • +
  • Fixed incorrect artist birth/formation location display in Artist Network (#235)
  • +
  • Added Playlists to the favorites quick access menu and titlebar dropdown (#233)
  • +
  • Added bulk "make available offline" action to track selection bar (#231)
  • +
  • System sleep is now prevented during active playback via XDG portal (#229)
  • +
  • Sensitive IDs and UUIDs are now sanitized from logs (#213)
  • +
  • F11 fullscreen toggle and ESC exit now work globally (#202)
  • +
  • Added streaming buffer progress indicator to the seekbar (#194)
  • +
  • Initial macOS support: CoreAudio device probing, deep links, rich notifications, platform-aware settings
  • +
  • Dependency updates: coreaudio-rs, notify-rust, axum, lofty, rand, souvlaki, lucide-svelte, vite
  • +
+
+

Resilience: exponential backoff retry (1s/2s/4s) with quality fallback when Qobuz servers return 504s. User preference modal for retry behavior (ask/always fallback/always skip), persisted in settings. Degraded service indicator with 5-minute auto-clear. Manual network check button. macOS port foundation (gated backends, native title bar, CI workflow). Offline mode improvements (last known profile restore, dismissible banner, network folders). WebKitGTK 2.50+ image flicker fix. Hybrid Intel+NVIDIA compositing fix. QConnect fixes for queue_version tolerance and ALSA volume control. i18n: hardcoded strings replaced in SearchView and PlaylistDetailView. Security: aws-lc-sys 0.39.0, rustls-webpki 0.103.10.

diff --git a/packaging/gentoo/qbz.ebuild.template b/packaging/gentoo/qbz.ebuild.template index 98f5a084c..cf1f31a3e 100644 --- a/packaging/gentoo/qbz.ebuild.template +++ b/packaging/gentoo/qbz.ebuild.template @@ -3,21 +3,19 @@ EAPI=8 -inherit cargo xdg +inherit xdg DESCRIPTION="Native hi-fi Qobuz desktop player for Linux" HOMEPAGE="https://qbz.lol https://github.com/vicrodh/qbz" MY_PV="${PV}" -SRC_URI=" - https://github.com/vicrodh/qbz/archive/refs/tags/v${MY_PV}.tar.gz -> ${P}.tar.gz - ${CARGO_CRATE_URIS} -" +SRC_URI="https://github.com/vicrodh/qbz/archive/refs/tags/v${MY_PV}.tar.gz -> ${P}.tar.gz" LICENSE="MIT" SLOT="0" KEYWORDS="~amd64 ~arm64" IUSE="pipewire pulseaudio" +RESTRICT="mirror network-sandbox" RDEPEND=" net-libs/webkit-gtk:4.1 @@ -28,7 +26,7 @@ RDEPEND=" x11-libs/libxkbcommon gnome-base/librsvg:2 pipewire? ( - media-video/pipewire[alsa-plugin] + media-video/pipewire[pipewire-alsa] ) pulseaudio? ( media-libs/libpulse @@ -36,49 +34,36 @@ RDEPEND=" " BDEPEND=" - virtual/rust + dev-lang/rust net-libs/nodejs[npm] - sys-devel/clang + llvm-core/clang virtual/pkgconfig " -# alsa-utils improves device detection for bit-perfect playback PDEPEND=" media-sound/alsa-utils " S="${WORKDIR}/qbz-${MY_PV}" -src_prepare() { - default - - # Generate cargo config for offline build if needed - cargo_src_prepare -} - -src_configure() { - cargo_src_configure -} - src_compile() { + # Install npm dependencies (network required) + npm ci || die "npm ci failed" + # Build the SvelteKit frontend - npm ci --prefer-offline || die "npm ci failed" npm run build || die "frontend build failed" - # Build the Tauri/Rust backend (no bundler — we install manually) + # Build the Tauri/Rust backend cd src-tauri || die - cargo_src_compile --bin qbz + cargo build --release --bin qbz || die "cargo build failed" } src_install() { - # Install the binary dobin "src-tauri/target/release/qbz" - # Desktop file insinto /usr/share/applications newins packaging/arch/qbz.desktop qbz.desktop - # Icons local size for size in 32 48 64 128 256; do local src_icon="src-tauri/icons/${size}x${size}.png" @@ -88,7 +73,6 @@ src_install() { fi done - # Use the high-res icon as a fallback for missing sizes local fallback="src-tauri/icons/128x128.png" for size in 48 64; do if [[ ! -f "src-tauri/icons/${size}x${size}.png" ]]; then diff --git a/snapcraft.yaml b/snapcraft.yaml index 66974c81d..8cbbb16d8 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -3,7 +3,7 @@ name: qbz-player title: "QBZ — Native Qobuz Client for Linux" -version: '1.2.1' +version: '1.2.2' summary: Native Qobuz player for Linux with bit-perfect audio and DAC control description: | QBZ is a native Linux desktop client for Qobuz™, designed for high-fidelity playback. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c97bd5d5f..0a6a5c97b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -145,9 +145,9 @@ checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -230,6 +230,7 @@ dependencies = [ "futures-util", "getrandom 0.4.2", "serde", + "serde_repr", "tokio", "zbus", ] @@ -463,14 +464,14 @@ dependencies = [ [[package]] name = "axum" -version = "0.7.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", "axum-core", "base64 0.22.1", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -483,15 +484,14 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -500,19 +500,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -929,6 +927,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -1077,6 +1095,20 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "coreaudio-rs" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16dd574a72a021b90c7656c474ea31d11a2f0366a8eff574186e761e0b9e3586" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "cpal" version = "0.17.1" @@ -1084,7 +1116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ "alsa 0.10.0", - "coreaudio-rs", + "coreaudio-rs 0.13.0", "dasp_sample", "jni", "js-sys", @@ -1438,6 +1470,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1593,9 +1634,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -2596,7 +2637,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -2759,9 +2800,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2770,8 +2811,8 @@ dependencies = [ "num-traits", "png 0.18.1", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -3160,9 +3201,9 @@ dependencies = [ [[package]] name = "lofty" -version = "0.18.2" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75066eb1d25a7047fb2667edb410ae2592439ed81546f95c28b0a1c7d7d3818" +checksum = "6d0c107dba5af049cf1c36b646fc1ba0cd2705f40d766d2c4c64f1b797c5fbed" dependencies = [ "byteorder", "data-encoding", @@ -3175,9 +3216,9 @@ dependencies = [ [[package]] name = "lofty_attr" -version = "0.9.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764b60e1ddd07e5665a6a17636a95cd7d8f3b86c73503a69c32979d05f72f3cf" +checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557" dependencies = [ "proc-macro2", "quote", @@ -3265,9 +3306,9 @@ checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -3363,9 +3404,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -3471,6 +3512,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3498,9 +3551,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.12.0" +version = "4.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +checksum = "9612133a804b4bc753f9f806de73fc9730b7694eb1bada2dc252cce022638be8" dependencies = [ "futures-lite", "log", @@ -3656,6 +3709,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-audio" version = "0.3.2" @@ -3679,6 +3743,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3705,6 +3779,38 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -3763,8 +3869,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -3784,9 +3909,9 @@ dependencies = [ [[package]] name = "ogg_pager" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b0bef808533c5890ab77279538212efdbbbd9aa4ef1ccdfcfbf77a42f7e6fa" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" dependencies = [ "byteorder", ] @@ -3880,6 +4005,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3890,6 +4025,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -4419,9 +4570,9 @@ dependencies = [ [[package]] name = "protobuf" -version = "3.2.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55bad9126f378a853655831eb7363b7b01b81d19f8cb1218861086ca4a1a61e" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" dependencies = [ "once_cell", "protobuf-support", @@ -4430,9 +4581,9 @@ dependencies = [ [[package]] name = "protobuf-codegen" -version = "3.2.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd418ac3c91caa4032d37cb80ff0d44e2ebe637b2fb243b6234bf89cdac4901" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" dependencies = [ "anyhow", "once_cell", @@ -4445,12 +4596,12 @@ dependencies = [ [[package]] name = "protobuf-parse" -version = "3.2.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d39b14605eaa1f6a340aec7f320b34064feb26c93aec35d6a9a2272a8ddfa49" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" dependencies = [ "anyhow", - "indexmap 1.9.3", + "indexmap 2.13.0", "log", "protobuf", "protobuf-support", @@ -4461,9 +4612,9 @@ dependencies = [ [[package]] name = "protobuf-support" -version = "3.2.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d4d7b8601c814cfb36bcebb79f0e61e45e1e93640cf778837833bbed05c372" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" dependencies = [ "thiserror 1.0.69", ] @@ -4496,6 +4647,7 @@ version = "0.1.0" dependencies = [ "alsa 0.9.1", "async-trait", + "coreaudio-rs 0.14.1", "dirs 5.0.1", "ebur128", "log", @@ -4598,7 +4750,7 @@ dependencies = [ [[package]] name = "qbz-nix" -version = "1.2.1" +version = "1.2.2" dependencies = [ "aes-gcm", "alsa 0.9.1", @@ -4638,7 +4790,7 @@ dependencies = [ "qconnect-core", "qconnect-transport-ws", "qrcode-generator", - "rand 0.8.5", + "rand 0.10.0", "rcgen", "regex", "reqwest 0.12.28", @@ -4654,8 +4806,10 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-os", "tauri-plugin-single-instance", "tempfile", "thiserror 2.0.18", @@ -4755,7 +4909,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", ] [[package]] @@ -5307,19 +5461,30 @@ dependencies = [ ] [[package]] -name = "rust_cast" -version = "0.18.1" +name = "rust-ini" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9b210a559ed66ebb5096ebb754ad14e197bf8954c029bae6bfc82f0f51e460" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_cast" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235d09067a35143d271fdeaf28298b4f729b5da50abdbf3b9e6395262d8bc7a9" dependencies = [ "byteorder", "log", - "openssl", "protobuf", "protobuf-codegen", + "rustls", + "rustls-native-certs", "serde", - "serde_derive", "serde_json", + "thiserror 2.0.18", ] [[package]] @@ -5393,6 +5558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -5401,6 +5567,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -5880,10 +6058,11 @@ dependencies = [ [[package]] name = "souvlaki" -version = "0.7.3" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4544ba17df4ac03d6503ae8abba19adad3ae89203a425945dc4c12d7790bfa" +checksum = "5855c8f31521af07d896b852eaa9eca974ddd3211fc2ae292e58dda8eb129bc8" dependencies = [ + "base64 0.22.1", "block", "cocoa", "core-graphics 0.22.3", @@ -6231,6 +6410,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -6469,6 +6657,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -6531,6 +6740,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.0" @@ -6540,6 +6767,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", @@ -6660,9 +6888,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -6724,16 +6952,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -6767,6 +6995,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -6877,10 +7114,22 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.24.0", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7153,6 +7402,23 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -7314,9 +7580,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7936,6 +8202,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -8748,34 +9025,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 789c2ac08..6e5107811 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qbz-nix" -version = "1.2.1" +version = "1.2.2" description = "Native high-fidelity client for Qobuz™ subscribers on Linux with Hi-Fi audio support (uses the Qobuz API; not certified by Qobuz)" authors = ["blitzkriegfc"] edition = "2021" @@ -34,7 +34,9 @@ qconnect-transport-ws = { path = "../crates/qconnect-transport-ws" } # Tauri tauri = { version = "2", features = ["protocol-asset", "tray-icon", "macos-private-api"] } tauri-plugin-opener = "2" -tauri-plugin-single-instance = "2" +tauri-plugin-os = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } +tauri-plugin-deep-link = "2" # Serialization serde = { version = "1", features = ["derive"] } @@ -49,7 +51,7 @@ async-trait = "0.1" tokio-stream = { version = "0.1", features = ["sync"] } # Local API server -axum = { version = "0.7", features = ["ws"] } +axum = { version = "0.8", features = ["ws"] } axum-server = { version = "0.8", features = ["tls-rustls"] } rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } tower-http = { version = "0.5", features = ["cors"] } @@ -78,7 +80,7 @@ log = "0.4" env_logger = "0.11" # Random number generation (for shuffle) -rand = "0.8" +rand = "0.10" # Audio playback rodio = { version = "0.22", features = ["symphonia-all"] } @@ -91,7 +93,7 @@ spectrum-analyzer = "1.5" ebur128 = "0.1" # Media controls (MPRIS on Linux) -souvlaki = "0.7" +souvlaki = "0.8" # Desktop notifications → ashpd in [target.linux] deps below @@ -99,23 +101,20 @@ souvlaki = "0.7" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } # PDF rendering with MuPDF engine (high-quality rasterization for booklet viewer) -# NOTE: Use sys-lib-* for zlib/harfbuzz/freetype to avoid symbol conflicts with -# GTK/GdkPixbuf. But NOT libjpeg — Ubuntu ships libjpeg8 (libjpeg.so.8) while -# Debian/Fedora ship libjpeg62-turbo (libjpeg.so.62). Let mupdf vendor its own -# libjpeg to avoid cross-distro breakage (GitHub #161, #162). -mupdf = { version = "0.6", default-features = false, features = [ - "system-fonts", - "sys-lib-zlib", - "sys-lib-harfbuzz", - "sys-lib-freetype", -] } +# NOTE: On Linux, use sys-lib-* for zlib/harfbuzz/freetype to avoid symbol +# conflicts with GTK/GdkPixbuf. But NOT libjpeg — Ubuntu ships libjpeg8 while +# Debian/Fedora ship libjpeg62-turbo. Let mupdf vendor its own libjpeg to avoid +# cross-distro breakage (GitHub #161, #162). +# On macOS, let mupdf vendor everything (no GTK conflicts, and system libs +# don't support x86_64 cross-compilation from Apple Silicon). +mupdf = { version = "0.6", default-features = false, features = ["system-fonts"] } # URL opening open = "5" # Local library walkdir = "2" -lofty = "0.18" +lofty = "0.23" rusqlite = { version = "0.31", features = ["bundled"] } dirs = "5" filetime = "0.2" @@ -145,14 +144,15 @@ futures-util = "0.3" # Platform-specific dependencies [target.'cfg(target_os = "linux")'.dependencies] tauri-plugin-dialog = { version = "2.6.0" } -ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification"] } +ashpd = { version = "0.13", default-features = false, features = ["tokio", "notification", "inhibit"] } +mupdf = { version = "0.6", features = ["sys-lib-zlib", "sys-lib-harfbuzz", "sys-lib-freetype"] } # Direct ALSA access for bit-perfect playback (bypasses CPAL limitations) alsa = "0.9" [target.'cfg(target_os = "macos")'.dependencies] tauri-plugin-dialog = { version = "2.6.0" } -notify-rust = "4" +notify-rust = "4.13.0" [target.'cfg(target_os = "windows")'.dependencies] tauri-plugin-dialog = { version = "2.6.0" } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index dfb83b2d9..30f7e27b4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -33,6 +33,7 @@ "core:webview:allow-webview-close", "core:webview:allow-set-webview-zoom", "opener:default", + "os:default", "dialog:default", "clipboard-manager:default", "clipboard-manager:allow-write-text" diff --git a/src-tauri/src/api_server/mod.rs b/src-tauri/src/api_server/mod.rs index 806287c05..69b90012b 100644 --- a/src-tauri/src/api_server/mod.rs +++ b/src-tauri/src/api_server/mod.rs @@ -505,8 +505,8 @@ fn build_router(ctx: ApiContext, allowed_origins: Vec) -> Router { .route("/api/favorites/add", post(add_favorite)) .route("/api/favorites/remove", post(remove_favorite)) .route("/api/album/play", post(play_album)) - .route("/api/album/:id", get(get_album)) - .route("/api/artist/:id", get(get_artist)) + .route("/api/album/{id}", get(get_album)) + .route("/api/artist/{id}", get(get_artist)) .route("/api/playback/preferences", get(get_playback_preferences)) .route("/api/playback/autoplay", post(set_autoplay)) .route("/api/ws", get(ws_handler)) @@ -1061,7 +1061,7 @@ async fn handle_ws(mut socket: WebSocket, mut rx: broadcast::Receiver p, Err(_) => continue, }; - if socket.send(Message::Text(payload)).await.is_err() { + if socket.send(Message::Text(payload.into())).await.is_err() { break; } } diff --git a/src-tauri/src/artist_vectors/suggestions.rs b/src-tauri/src/artist_vectors/suggestions.rs index e403c409f..e402608a1 100644 --- a/src-tauri/src/artist_vectors/suggestions.rs +++ b/src-tauri/src/artist_vectors/suggestions.rs @@ -447,7 +447,7 @@ impl SuggestionsEngine { // 6. Shuffle tracks for variety (so same artist doesn't dominate) use rand::seq::SliceRandom; - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); all_tracks.shuffle(&mut rng); // 7. Limit pool size diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 6be0a9218..670d0f762 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -72,8 +72,9 @@ use ashpd::desktop::Icon; use md5::{Digest, Md5}; use std::collections::HashSet; use std::fs; +use std::io::Write; #[cfg(target_os = "linux")] -use std::io::{Cursor, Write}; +use std::io::Cursor; use std::path::PathBuf; use std::sync::Mutex; @@ -167,6 +168,8 @@ pub(crate) fn convert_to_qbz_audio_settings(settings: &AudioSettings) -> qbz_aud normalization_target_lufs: settings.normalization_target_lufs, gapless_enabled: settings.gapless_enabled, pw_force_bitperfect: settings.pw_force_bitperfect, + skip_sink_switch: settings.skip_sink_switch, + allow_quality_fallback: settings.allow_quality_fallback, } } @@ -287,22 +290,50 @@ fn limit_quality_for_device(quality: Quality, max_sample_rate: Option) -> Q } } -/// Probe sample rate from FLAC audio data by reading the STREAMINFO header. -/// Returns None for non-FLAC data or data too short to parse. -#[cfg(target_os = "linux")] -fn probe_flac_sample_rate(data: &[u8]) -> Option { - // FLAC format: "fLaC" magic + metadata blocks - // First block is always STREAMINFO (34 bytes) - // Bytes 18-20 of STREAMINFO contain: sample_rate (20 bits) | channels (3 bits) | bps (5 bits) | ... +/// Probe sample rate and bit depth from FLAC STREAMINFO header. +/// Returns (sample_rate, bit_depth) or None for non-FLAC / truncated data. +fn probe_flac_format(data: &[u8]) -> Option<(u32, u32)> { + // FLAC: "fLaC" magic + metadata blocks. First block = STREAMINFO (34 bytes). + // Byte layout at offset 18: sample_rate (20 bits) | channels (3 bits) | bps (5 bits) | ... if data.len() < 22 || &data[0..4] != b"fLaC" { return None; } let sr = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4); - if sr > 0 { - Some(sr) - } else { - None + let bps = ((data[20] as u32) & 0x01) << 4 | ((data[21] as u32) >> 4); + let bit_depth = bps + 1; // FLAC stores bps-1 + if sr > 0 { Some((sr, bit_depth)) } else { None } +} + +/// Convenience wrapper used by ALSA hardware check (Linux only). +#[cfg(target_os = "linux")] +fn probe_flac_sample_rate(data: &[u8]) -> Option { + probe_flac_format(data).map(|(sr, _)| sr) +} + +/// Check if cached audio quality is below what the user requested. +/// Returns true if the cache should be skipped in favor of a fresh download. +fn cached_quality_below_requested(data: &[u8], requested: Quality) -> bool { + let (sample_rate, bit_depth) = match probe_flac_format(data) { + Some(fmt) => fmt, + None => return false, // Can't parse — assume compatible + }; + + let dominated = match requested { + // Hi-Res+: expect 24-bit AND >96kHz + Quality::UltraHiRes => bit_depth < 24 || sample_rate <= 96000, + // Hi-Res: expect 24-bit + Quality::HiRes => bit_depth < 24, + // Lossless / Mp3: any FLAC is fine + _ => false, + }; + + if dominated { + log::info!( + "[V2/Quality] Cached audio is {}Hz/{}bit but requested {:?} — will try re-download", + sample_rate, bit_depth, requested + ); } + dominated } /// Check if cached audio data has a sample rate that the current ALSA hardware @@ -802,7 +833,7 @@ const PORTAL_NOTIFICATION_ICON_MAX_EDGE: u32 = 512; #[cfg(target_os = "linux")] const PORTAL_NOTIFICATION_ICON_MAX_BYTES: usize = 4 * 1024 * 1024; -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn v2_get_notification_artwork_cache_dir() -> Result { let cache_dir = dirs::cache_dir() .ok_or_else(|| "Could not find cache directory".to_string())? @@ -814,7 +845,7 @@ fn v2_get_notification_artwork_cache_dir() -> Result { Ok(cache_dir) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn v2_resolve_local_artwork(url: &str) -> Option { if let Some(path) = url.strip_prefix("file://") { return Some(PathBuf::from(path)); @@ -826,7 +857,7 @@ fn v2_resolve_local_artwork(url: &str) -> Option { None } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn v2_cache_notification_artwork(url: &str) -> Result { if let Some(local_path) = v2_resolve_local_artwork(url) { if local_path.exists() { @@ -845,14 +876,16 @@ fn v2_cache_notification_artwork(url: &str) -> Result { let response = reqwest::blocking::Client::new() .get(url) + .header("User-Agent", "Mozilla/5.0") .timeout(std::time::Duration::from_secs(5)) .send() .map_err(|e| format!("Failed to download artwork: {}", e))?; if !response.status().is_success() { return Err(format!( - "Failed to download artwork: HTTP {}", - response.status() + "Failed to download artwork: HTTP {} (url: {})", + response.status(), + url.split('?').next().unwrap_or(url) )); } @@ -1040,136 +1073,11 @@ pub async fn runtime_bootstrap( return Ok(status); } - // Step 3: Check for saved credentials and attempt auto-login. - // NOTE: user_id hint is optional; credentials are the source of truth. - // This keeps bootstrap robust even if last_user_id is missing/corrupt. - let creds = crate::credentials::load_qobuz_credentials(); - let last_user_id_hint = crate::user_data::UserDataPaths::load_last_user_id(); - - if let Ok(Some(creds)) = creds { - if last_user_id_hint.is_some() { - log::info!("[Runtime] Found saved credentials, attempting auto-login"); - } else { - log::info!("[Runtime] Found saved credentials, attempting auto-login (no hint)"); - } - - // Login to legacy client - let client = app_state.client.read().await; - match client.login(&creds.email, &creds.password).await { - Ok(session) => { - log::info!("[Runtime] Legacy auth successful"); - manager.set_legacy_auth(true, Some(session.user_id)).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::AuthChanged { - logged_in: true, - user_id: Some(session.user_id), - }, - ); - - // Step 4: Wait for CoreBridge init, then authenticate V2 - REQUIRED per ADR - let cb_start = std::time::Instant::now(); - let cb_timeout = std::time::Duration::from_secs(30); - let cb_poll = std::time::Duration::from_millis(100); - - loop { - if core_bridge.try_get().await.is_some() { - break; - } - let elapsed = cb_start.elapsed(); - if elapsed > cb_timeout { - log::error!("[Runtime] CoreBridge not available after 30s"); - manager.set_bootstrap_in_progress(false).await; - return Err(RuntimeError::V2NotInitialized); - } - if elapsed.as_secs() % 5 == 0 && elapsed.as_millis() % 5000 < 150 { - log::info!( - "[Runtime] Waiting for CoreBridge... ({:.0}s)", - elapsed.as_secs_f64() - ); - } - tokio::time::sleep(cb_poll).await; - } - log::info!("[Runtime] CoreBridge ready after {:?}", cb_start.elapsed()); - - if let Some(bridge) = core_bridge.try_get().await { - match bridge.login(&creds.email, &creds.password).await { - Ok(_) => { - log::info!("[Runtime] CoreBridge auth successful"); - manager.set_corebridge_auth(true).await; - } - Err(e) => { - log::error!("[Runtime] CoreBridge auth failed: {}", e); - let _ = app.emit( - "runtime:event", - RuntimeEvent::CoreBridgeAuthFailed { - error: e.to_string(), - }, - ); - manager.set_bootstrap_in_progress(false).await; - return Err(RuntimeError::V2AuthFailed(e)); - } - } - } else { - log::error!("[Runtime] CoreBridge disappeared after ready check"); - manager.set_bootstrap_in_progress(false).await; - return Err(RuntimeError::V2NotInitialized); - } - - // Step 5: Activate per-user session - REQUIRED (FATAL if fails) - // This initializes all per-user stores and sets runtime state - // Session activation failure is FATAL per parity with v2_auto_login/v2_manual_login - if let Err(e) = - crate::session_lifecycle::activate_session(&app, session.user_id).await - { - log::error!("[Runtime] Session activation failed: {}", e); - // Rollback auth state since session is not usable - manager.set_legacy_auth(false, None).await; - manager.set_corebridge_auth(false).await; - let reason = DegradedReason::SessionActivationFailed(e.clone()); - manager.set_degraded(reason.clone()).await; - manager.set_bootstrap_in_progress(false).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::RuntimeDegraded { - reason: reason.clone(), - }, - ); - return Err(RuntimeError::RuntimeDegraded(reason)); - } - - // Step 6: Optionally sync audio settings after session activation. - // Only runs if user has enabled sync_audio_on_startup (opt-in). - // Useful when Player::new() may hold stale settings (e.g., Flatpak updates). - let should_sync = audio_settings - .store - .lock() - .ok() - .and_then(|g| g.as_ref().and_then(|s| s.get_settings().ok())) - .map(|s| s.sync_audio_on_startup) - .unwrap_or(false); - - if should_sync { - if let Some(bridge) = core_bridge.try_get().await { - let player = bridge.player(); - if let Ok(guard) = audio_settings.store.lock() { - if let Some(store) = guard.as_ref() { - if let Ok(fresh) = store.get_settings() { - log::info!("[Runtime] Syncing audio settings to player (sync_audio_on_startup=true)"); - let _ = player - .reload_settings(convert_to_qbz_audio_settings(&fresh)); - } - } - } - } - } - } - Err(e) => { - log::warn!("[Runtime] Auto-login failed: {}", e); - // Not a fatal error - user can login manually - } - } - } else if let Ok(Some(oauth_token)) = crate::credentials::load_oauth_token() { + // Step 3: Attempt auto-login via saved OAuth token. + // Basic auth (email/password) is no longer supported by Qobuz — any saved + // credentials from older versions are ignored. Users will see the login + // screen and authenticate via OAuth. + if let Ok(Some(oauth_token)) = crate::credentials::load_oauth_token() { // No email/password credentials but there IS a saved OAuth token. // Try to restore the session by calling /user/login with the stored token. log::info!("[Runtime] No email/password credentials, trying saved OAuth token..."); @@ -1365,304 +1273,54 @@ pub async fn v2_get_user_info( })) } -/// Auto-login using saved credentials (Phase 2 of runtime initialization) +/// Auto-login using saved credentials. /// -/// This command: -/// 1. Loads saved credentials from keyring -/// 2. Authenticates with legacy client -/// 3. Authenticates with CoreBridge/V2 (BLOCKING - required per ADR) -/// 4. Updates RuntimeManager state -/// -/// V2 auth is REQUIRED - if it fails, the whole login fails. -/// ToS acceptance is REQUIRED - checked in backend before any auth attempt. +/// Basic auth (email/password) is no longer supported by Qobuz. +/// This command now always returns no_credentials. Session restore +/// is handled by the OAuth token path in runtime_bootstrap. #[tauri::command] pub async fn v2_auto_login( - app: tauri::AppHandle, - runtime: State<'_, RuntimeManagerState>, - app_state: State<'_, AppState>, - core_bridge: State<'_, CoreBridgeState>, - legal_state: State<'_, LegalSettingsState>, + _app: tauri::AppHandle, + _runtime: State<'_, RuntimeManagerState>, + _app_state: State<'_, AppState>, + _core_bridge: State<'_, CoreBridgeState>, + _legal_state: State<'_, LegalSettingsState>, ) -> Result { - let manager = runtime.manager(); - - log::info!("[V2] v2_auto_login starting..."); - - // Load saved credentials - let creds = match crate::credentials::load_qobuz_credentials() { - Ok(Some(c)) => c, - Ok(None) => { - return Ok(V2LoginResponse { - success: false, - user_name: None, - user_id: None, - subscription: None, - subscription_valid_until: None, - error: Some("No saved credentials".to_string()), - error_code: Some("no_credentials".to_string()), - }); - } - Err(e) => { - return Ok(V2LoginResponse { - success: false, - user_name: None, - user_id: None, - subscription: None, - subscription_valid_until: None, - error: Some(e), - error_code: Some("credentials_error".to_string()), - }); - } - }; - - // Legacy auth - let client = app_state.client.read().await; - let session = match client.login(&creds.email, &creds.password).await { - Ok(s) => s, - Err(e) => { - let error_code = if matches!(e, crate::api::error::ApiError::IneligibleUser) { - Some("ineligible_user".to_string()) - } else { - None - }; - return Ok(V2LoginResponse { - success: false, - user_name: None, - user_id: None, - subscription: None, - subscription_valid_until: None, - error: Some(e.to_string()), - error_code, - }); - } - }; - drop(client); - - log::info!("[V2] Legacy auth successful"); - manager.set_legacy_auth(true, Some(session.user_id)).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::AuthChanged { - logged_in: true, - user_id: Some(session.user_id), - }, - ); - - // V2 CoreBridge auth - REQUIRED per ADR Runtime Session Contract - if let Some(bridge) = core_bridge.try_get().await { - match bridge.login(&creds.email, &creds.password).await { - Ok(_) => { - log::info!("[V2] CoreBridge auth successful"); - manager.set_corebridge_auth(true).await; - } - Err(e) => { - log::error!("[V2] CoreBridge auth failed: {}", e); - rollback_auth_state(&manager, &app).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::CoreBridgeAuthFailed { - error: e.to_string(), - }, - ); - // V2 auth failed - return failure per ADR - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: Some(format!("V2 authentication failed: {}", e)), - error_code: Some("v2_auth_failed".to_string()), - }); - } - } - } else { - log::error!("[V2] CoreBridge not initialized - cannot complete auth"); - rollback_auth_state(&manager, &app).await; - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: Some("V2 CoreBridge not initialized".to_string()), - error_code: Some("v2_not_initialized".to_string()), - }); - } - - // Activate per-user session (initializes all per-user stores) - // This is REQUIRED - without it, user has auth but no stores, causing UserSessionNotActivated errors - if let Err(e) = crate::session_lifecycle::activate_session(&app, session.user_id).await { - log::error!("[V2] Session activation failed: {}", e); - rollback_auth_state(&manager, &app).await; - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name.clone()), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label.clone()), - subscription_valid_until: session.subscription_valid_until.clone(), - error: Some(format!("Session activation failed: {}", e)), - error_code: Some("session_activation_failed".to_string()), - }); - } - - // Persist ToS acceptance now that login succeeded. - // The frontend checkbox already gated the UI; we store the value so - // subsequent bootstrap auto-logins pass without requiring the user to - // re-accept (e.g. after a factory reset that wiped the DB). - accept_tos_best_effort(&legal_state); - - // Emit ready event - let _ = app.emit( - "runtime:event", - RuntimeEvent::RuntimeReady { - user_id: session.user_id, - }, - ); - + log::info!("[V2] v2_auto_login: basic auth no longer supported, returning no_credentials"); Ok(V2LoginResponse { - success: true, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: None, - error_code: None, + success: false, + user_name: None, + user_id: None, + subscription: None, + subscription_valid_until: None, + error: Some("Basic auth no longer supported. Please use OAuth.".to_string()), + error_code: Some("no_credentials".to_string()), }) } -/// Manual login with email and password (V2 - with blocking CoreBridge auth) +/// Manual login with email and password. /// -/// This command: -/// 1. Authenticates with legacy client -/// 2. Authenticates with CoreBridge/V2 (BLOCKING - required per ADR) -/// 3. Updates RuntimeManager state -/// -/// V2 auth is REQUIRED - if it fails, the whole login fails. -/// ToS acceptance is REQUIRED - checked in backend before any auth attempt. +/// Basic auth is no longer supported by Qobuz. This command is kept for +/// backward compatibility but always returns an error directing users to OAuth. #[tauri::command] pub async fn v2_manual_login( - email: String, - password: String, - app: tauri::AppHandle, - runtime: State<'_, RuntimeManagerState>, - app_state: State<'_, AppState>, - core_bridge: State<'_, CoreBridgeState>, - legal_state: State<'_, LegalSettingsState>, + _email: String, + _password: String, + _app: tauri::AppHandle, + _runtime: State<'_, RuntimeManagerState>, + _app_state: State<'_, AppState>, + _core_bridge: State<'_, CoreBridgeState>, + _legal_state: State<'_, LegalSettingsState>, ) -> Result { - let manager = runtime.manager(); - - log::info!("[V2] v2_manual_login starting..."); - - // Legacy auth - let client = app_state.client.read().await; - let session = match client.login(&email, &password).await { - Ok(s) => s, - Err(e) => { - let error_code = if matches!(e, crate::api::error::ApiError::IneligibleUser) { - Some("ineligible_user".to_string()) - } else { - None - }; - return Ok(V2LoginResponse { - success: false, - user_name: None, - user_id: None, - subscription: None, - subscription_valid_until: None, - error: Some(e.to_string()), - error_code, - }); - } - }; - drop(client); - - log::info!("[V2] Legacy auth successful"); - manager.set_legacy_auth(true, Some(session.user_id)).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::AuthChanged { - logged_in: true, - user_id: Some(session.user_id), - }, - ); - - // V2 CoreBridge auth - REQUIRED per ADR Runtime Session Contract - if let Some(bridge) = core_bridge.try_get().await { - match bridge.login(&email, &password).await { - Ok(_) => { - log::info!("[V2] CoreBridge auth successful"); - manager.set_corebridge_auth(true).await; - } - Err(e) => { - log::error!("[V2] CoreBridge auth failed: {}", e); - rollback_auth_state(&manager, &app).await; - let _ = app.emit( - "runtime:event", - RuntimeEvent::CoreBridgeAuthFailed { - error: e.to_string(), - }, - ); - // V2 auth failed - return failure per ADR - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: Some(format!("V2 authentication failed: {}", e)), - error_code: Some("v2_auth_failed".to_string()), - }); - } - } - } else { - log::error!("[V2] CoreBridge not initialized - cannot complete auth"); - rollback_auth_state(&manager, &app).await; - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: Some("V2 CoreBridge not initialized".to_string()), - error_code: Some("v2_not_initialized".to_string()), - }); - } - - // Activate per-user session (initializes all per-user stores) - // This is REQUIRED - without it, user has auth but no stores, causing UserSessionNotActivated errors - if let Err(e) = crate::session_lifecycle::activate_session(&app, session.user_id).await { - log::error!("[V2] Session activation failed: {}", e); - rollback_auth_state(&manager, &app).await; - return Ok(V2LoginResponse { - success: false, - user_name: Some(session.display_name.clone()), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label.clone()), - subscription_valid_until: session.subscription_valid_until.clone(), - error: Some(format!("Session activation failed: {}", e)), - error_code: Some("session_activation_failed".to_string()), - }); - } - - // Persist ToS acceptance now that login succeeded. - accept_tos_best_effort(&legal_state); - - // Emit ready event - let _ = app.emit( - "runtime:event", - RuntimeEvent::RuntimeReady { - user_id: session.user_id, - }, - ); - + log::info!("[V2] v2_manual_login: basic auth no longer supported"); Ok(V2LoginResponse { - success: true, - user_name: Some(session.display_name), - user_id: Some(session.user_id), - subscription: Some(session.subscription_label), - subscription_valid_until: session.subscription_valid_until, - error: None, - error_code: None, + success: false, + user_name: None, + user_id: None, + subscription: None, + subscription_valid_until: None, + error: Some("Basic auth no longer supported. Please use OAuth.".to_string()), + error_code: Some("basic_auth_deprecated".to_string()), }) } @@ -1684,7 +1342,6 @@ pub async fn v2_start_oauth_login( core_bridge: State<'_, CoreBridgeState>, legal_state: State<'_, LegalSettingsState>, ) -> Result { - let manager = runtime.manager(); log::info!("[V2] v2_start_oauth_login starting..."); // Get app_id from initialized client @@ -1856,12 +1513,31 @@ pub async fn v2_start_oauth_login( } }; - log::info!("[V2] OAuth code received, exchanging for session..."); + finalize_oauth_login(&code, &app, &runtime, &app_state, &core_bridge, &legal_state).await +} - // Exchange code for UserSession via legacy client +/// Complete the OAuth login flow after obtaining an authorization code. +/// +/// Frontend-agnostic: does not depend on how the code was obtained (WebView, +/// system browser, manual paste, etc.). Suitable for GUI, TUI, and headless. +/// +/// Steps: exchange code → establish session → inject into CoreBridge → +/// activate per-user data → persist token. +async fn finalize_oauth_login( + code: &str, + app: &tauri::AppHandle, + runtime: &State<'_, RuntimeManagerState>, + app_state: &State<'_, AppState>, + core_bridge: &State<'_, CoreBridgeState>, + legal_state: &State<'_, LegalSettingsState>, +) -> Result { + let manager = runtime.manager(); + log::info!("[OAuth] Exchanging authorization code for session..."); + + // Exchange code for UserSession let session = { let client = app_state.client.read().await; - match client.login_with_oauth_code(&code).await { + match client.login_with_oauth_code(code).await { Ok(s) => s, Err(e) => { return Ok(V2LoginResponse { @@ -1877,7 +1553,7 @@ pub async fn v2_start_oauth_login( } }; - log::info!("[V2] OAuth session established"); + log::info!("[OAuth] Session established, activating..."); manager.set_legacy_auth(true, Some(session.user_id)).await; let _ = app.emit( "runtime:event", @@ -1888,13 +1564,12 @@ pub async fn v2_start_oauth_login( ); // Convert api::models::UserSession → qbz_models::UserSession for CoreBridge - // Both types are structurally identical; serde round-trip is safe. let core_session: UserSession = match serde_json::to_value(&session).and_then(serde_json::from_value) { Ok(s) => s, Err(e) => { - log::error!("[V2] Failed to convert session for CoreBridge: {}", e); - rollback_auth_state(&manager, &app).await; + log::error!("[OAuth] Failed to convert session for CoreBridge: {}", e); + rollback_auth_state(&manager, app).await; return Ok(V2LoginResponse { success: false, user_name: None, @@ -1907,16 +1582,16 @@ pub async fn v2_start_oauth_login( } }; - // CoreBridge auth — inject session directly (OAuth has no email/password) + // Inject session into CoreBridge if let Some(bridge) = core_bridge.try_get().await { match bridge.login_with_session(core_session).await { Ok(_) => { - log::info!("[V2] CoreBridge session injected for OAuth user"); + log::info!("[OAuth] CoreBridge session injected"); manager.set_corebridge_auth(true).await; } Err(e) => { - log::error!("[V2] CoreBridge session injection failed: {}", e); - rollback_auth_state(&manager, &app).await; + log::error!("[OAuth] CoreBridge session injection failed: {}", e); + rollback_auth_state(&manager, app).await; let _ = app.emit( "runtime:event", RuntimeEvent::CoreBridgeAuthFailed { @@ -1935,8 +1610,8 @@ pub async fn v2_start_oauth_login( } } } else { - log::error!("[V2] CoreBridge not initialized for OAuth login"); - rollback_auth_state(&manager, &app).await; + log::error!("[OAuth] CoreBridge not initialized"); + rollback_auth_state(&manager, app).await; return Ok(V2LoginResponse { success: false, user_name: Some(session.display_name), @@ -1948,10 +1623,10 @@ pub async fn v2_start_oauth_login( }); } - // Activate per-user session (same as manual login) - if let Err(e) = crate::session_lifecycle::activate_session(&app, session.user_id).await { - log::error!("[V2] Session activation failed after OAuth: {}", e); - rollback_auth_state(&manager, &app).await; + // Activate per-user session + if let Err(e) = crate::session_lifecycle::activate_session(app, session.user_id).await { + log::error!("[OAuth] Session activation failed: {}", e); + rollback_auth_state(&manager, app).await; return Ok(V2LoginResponse { success: false, user_name: Some(session.display_name.clone()), @@ -1963,14 +1638,12 @@ pub async fn v2_start_oauth_login( }); } - // Persist the OAuth token so bootstrap can restore the session on next launch. - // Non-fatal: if saving fails, the user just has to re-login via OAuth. + // Persist OAuth token for session restore on next launch if let Err(e) = crate::credentials::save_oauth_token(&session.user_auth_token) { - log::warn!("[V2] Failed to persist OAuth token: {}", e); + log::warn!("[OAuth] Failed to persist token: {}", e); } - // Persist ToS acceptance now that login succeeded. - accept_tos_best_effort(&legal_state); + accept_tos_best_effort(legal_state); let _ = app.emit( "runtime:event", @@ -1990,6 +1663,130 @@ pub async fn v2_start_oauth_login( }) } +/// OAuth login via the user's system browser. +/// +/// Spawns a temporary local HTTP server, opens the system browser to the Qobuz +/// OAuth page with a localhost redirect, captures the authorization code when +/// Qobuz redirects back, then completes the login via `finalize_oauth_login`. +#[tauri::command] +pub async fn v2_start_system_browser_oauth( + app: tauri::AppHandle, + runtime: State<'_, RuntimeManagerState>, + app_state: State<'_, AppState>, + core_bridge: State<'_, CoreBridgeState>, + legal_state: State<'_, LegalSettingsState>, +) -> Result { + log::info!("[V2] System browser OAuth starting..."); + + // Get app_id from initialized client + let app_id = { + let client = app_state.client.read().await; + client.app_id().await.map_err(|e| e.to_string())? + }; + + // Bind to a random available port + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| format!("Failed to bind local OAuth listener: {}", e))?; + let port = listener + .local_addr() + .map_err(|e| format!("Failed to get listener address: {}", e))? + .port(); + + let oauth_url = format!( + "https://www.qobuz.com/signin/oauth?ext_app_id={}&redirect_url={}", + app_id, + urlencoding::encode(&format!("http://localhost:{}", port)), + ); + + // Channel for the authorization code + let (tx, mut rx) = tokio::sync::mpsc::channel::(1); + + // Local HTTP handler: serves a success page and forwards the code + let oauth_handler = axum::Router::new().route( + "/", + axum::routing::get(move |axum::extract::Query(params): axum::extract::Query>| { + let tx = tx.clone(); + async move { + if let Some(code) = params.get("code_autorisation").or_else(|| params.get("code")) { + let _ = tx.send(code.clone()).await; + axum::response::Html( + "\ +

Login successful

\ +

You can close this tab and return to QBZ.

\ + " + ) + } else { + axum::response::Html( + "\ +

Login failed

\ +

No authorization code received. Please try again.

\ + " + ) + } + } + }), + ); + + // Spawn the server in the background + let server_handle = tokio::spawn(async move { + axum::serve(listener, oauth_handler).await.ok(); + }); + + // Open the user's default browser + log::info!("[OAuth] Opening system browser to Qobuz login (port {})", port); + if let Err(e) = open::that(&oauth_url) { + log::error!("[OAuth] Failed to open system browser: {}", e); + server_handle.abort(); + return Ok(V2LoginResponse { + success: false, + user_name: None, + user_id: None, + subscription: None, + subscription_valid_until: None, + error: Some(format!("Failed to open browser: {}", e)), + error_code: Some("browser_open_failed".to_string()), + }); + } + + // Wait up to 5 minutes for the redirect + let code = tokio::time::timeout( + std::time::Duration::from_secs(300), + rx.recv(), + ) + .await; + + server_handle.abort(); + + let code = match code { + Ok(Some(c)) => c, + Ok(None) => { + return Ok(V2LoginResponse { + success: false, + user_name: None, + user_id: None, + subscription: None, + subscription_valid_until: None, + error: Some("OAuth login cancelled".to_string()), + error_code: Some("oauth_cancelled".to_string()), + }); + } + Err(_) => { + return Ok(V2LoginResponse { + success: false, + user_name: None, + user_id: None, + subscription: None, + subscription_valid_until: None, + error: Some("OAuth login timed out after 5 minutes".to_string()), + error_code: Some("oauth_timeout".to_string()), + }); + } + }; + + finalize_oauth_login(&code, &app, &runtime, &app_state, &core_bridge, &legal_state).await +} + // ==================== Prefetch (V2) ==================== /// Number of Qobuz tracks to prefetch (not total tracks, just Qobuz) @@ -7968,6 +7765,10 @@ pub async fn v2_play_track( let bridge_guard = bridge.get().await; let player = bridge_guard.player(); + // Fallback: if cache has lower quality than requested but network fails, + // play the cached version rather than failing entirely. + let mut low_quality_fallback: Option> = None; + // Check offline cache (persistent disk cache) { let cached_path = { @@ -7995,38 +7796,29 @@ pub async fn v2_play_track( RuntimeError::Internal(format!("Failed to read cached file: {}", e)) })?; - // Check if cached audio is compatible with hardware + // Check hardware compatibility (ALSA only) #[cfg(target_os = "linux")] - if cached_audio_incompatible_with_hw(&audio_data, &audio_settings) { + let hw_incompatible = + cached_audio_incompatible_with_hw(&audio_data, &audio_settings); + #[cfg(not(target_os = "linux"))] + let hw_incompatible = false; + + // Check quality mismatch (all platforms) + let quality_mismatch = cached_quality_below_requested(&audio_data, final_quality); + + if hw_incompatible { log::info!( "[V2/Quality] Skipping OFFLINE cache for track {} - incompatible sample rate", track_id ); - // Fall through to network path which will request compatible quality + } else if quality_mismatch { + // Keep as fallback — don't discard, network might fail + low_quality_fallback = Some(audio_data); } else { player .play_data(audio_data, track_id) .map_err(RuntimeError::Internal)?; - // Prefetch next tracks in background (using CoreBridge queue) - let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; - drop(bridge_guard); - spawn_v2_prefetch( - bridge.0.clone(), - app_state.audio_cache.clone(), - upcoming_tracks, - final_quality, - streaming_only, - ); - return Ok(V2PlayTrackResult { format_id: None }); - } - - #[cfg(not(target_os = "linux"))] - { - player - .play_data(audio_data, track_id) - .map_err(RuntimeError::Internal)?; - let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; drop(bridge_guard); spawn_v2_prefetch( @@ -8042,75 +7834,35 @@ pub async fn v2_play_track( } } - // Check memory cache (L1) - using AppState's audio_cache for now - // TODO: Move cache to qbz-core in future refactor + // Check memory cache (L1) let cache = app_state.audio_cache.clone(); - if let Some(cached) = cache.get(track_id) { - log::info!( - "[V2/CACHE HIT] Track {} from MEMORY cache ({} bytes)", - track_id, - cached.size_bytes - ); - - // Check if cached audio is compatible with hardware - #[cfg(target_os = "linux")] - let skip_cache = cached_audio_incompatible_with_hw(&cached.data, &audio_settings); - #[cfg(not(target_os = "linux"))] - let skip_cache = false; - - if skip_cache { + if low_quality_fallback.is_none() { + if let Some(cached) = cache.get(track_id) { log::info!( - "[V2/Quality] Skipping MEMORY cache for track {} - incompatible sample rate", - track_id - ); - // Fall through to network path which will request compatible quality - } else { - player - .play_data(cached.data, track_id) - .map_err(RuntimeError::Internal)?; - - // Prefetch next tracks in background (using CoreBridge queue) - let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; - drop(bridge_guard); - spawn_v2_prefetch( - bridge.0.clone(), - cache.clone(), - upcoming_tracks, - final_quality, - streaming_only, - ); - return Ok(V2PlayTrackResult { format_id: None }); - } - } - - // Check playback cache (L2 - disk) - if let Some(playback_cache) = cache.get_playback_cache() { - if let Some(audio_data) = playback_cache.get(track_id) { - log::info!( - "[V2/CACHE HIT] Track {} from DISK cache ({} bytes)", + "[V2/CACHE HIT] Track {} from MEMORY cache ({} bytes)", track_id, - audio_data.len() + cached.size_bytes ); - // Check if cached audio is compatible with hardware #[cfg(target_os = "linux")] - let skip_cache = cached_audio_incompatible_with_hw(&audio_data, &audio_settings); + let hw_incompatible = cached_audio_incompatible_with_hw(&cached.data, &audio_settings); #[cfg(not(target_os = "linux"))] - let skip_cache = false; + let hw_incompatible = false; - if skip_cache { + let quality_mismatch = cached_quality_below_requested(&cached.data, final_quality); + + if hw_incompatible { log::info!( - "[V2/Quality] Skipping DISK cache for track {} - incompatible sample rate", + "[V2/Quality] Skipping MEMORY cache for track {} - incompatible sample rate", track_id ); - // Fall through to network path which will request compatible quality + } else if quality_mismatch { + low_quality_fallback = Some(cached.data); } else { - cache.insert(track_id, audio_data.clone()); player - .play_data(audio_data, track_id) + .play_data(cached.data, track_id) .map_err(RuntimeError::Internal)?; - // Prefetch next tracks in background (using CoreBridge queue) let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; drop(bridge_guard); spawn_v2_prefetch( @@ -8125,16 +7877,96 @@ pub async fn v2_play_track( } } - // Not in any cache - get stream URL from Qobuz via CoreBridge - log::info!( - "[V2] Track {} not in cache, fetching from network...", - track_id - ); + // Check playback cache (L2 - disk) + if low_quality_fallback.is_none() { + if let Some(playback_cache) = cache.get_playback_cache() { + if let Some(audio_data) = playback_cache.get(track_id) { + log::info!( + "[V2/CACHE HIT] Track {} from DISK cache ({} bytes)", + track_id, + audio_data.len() + ); - let mut stream_url = bridge_guard + #[cfg(target_os = "linux")] + let hw_incompatible = + cached_audio_incompatible_with_hw(&audio_data, &audio_settings); + #[cfg(not(target_os = "linux"))] + let hw_incompatible = false; + + let quality_mismatch = + cached_quality_below_requested(&audio_data, final_quality); + + if hw_incompatible { + log::info!( + "[V2/Quality] Skipping DISK cache for track {} - incompatible sample rate", + track_id + ); + } else if quality_mismatch { + low_quality_fallback = Some(audio_data); + } else { + cache.insert(track_id, audio_data.clone()); + player + .play_data(audio_data, track_id) + .map_err(RuntimeError::Internal)?; + + let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; + drop(bridge_guard); + spawn_v2_prefetch( + bridge.0.clone(), + cache.clone(), + upcoming_tracks, + final_quality, + streaming_only, + ); + return Ok(V2PlayTrackResult { format_id: None }); + } + } + } + } + + // Not in cache (or cached at lower quality) — fetch from network + if low_quality_fallback.is_some() { + log::info!( + "[V2] Track {} cached at lower quality, re-downloading at {:?}...", + track_id, final_quality + ); + } else { + log::info!( + "[V2] Track {} not in cache, fetching from network...", + track_id + ); + } + + let stream_url_result = bridge_guard .get_stream_url(track_id, final_quality) - .await - .map_err(RuntimeError::Internal)?; + .await; + + let mut stream_url = match stream_url_result { + Ok(url) => url, + Err(e) => { + // Network failed — use lower-quality fallback if available + if let Some(fallback_data) = low_quality_fallback { + log::warn!( + "[V2] Network failed for track {}: {}. Playing cached lower-quality version.", + track_id, e + ); + player + .play_data(fallback_data, track_id) + .map_err(RuntimeError::Internal)?; + let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await; + drop(bridge_guard); + spawn_v2_prefetch( + bridge.0.clone(), + cache.clone(), + upcoming_tracks, + final_quality, + streaming_only, + ); + return Ok(V2PlayTrackResult { format_id: None }); + } + return Err(RuntimeError::Internal(e)); + } + }; log::info!( "[V2] Got stream URL for track {} (format_id={})", track_id, @@ -8892,6 +8724,69 @@ pub async fn v2_set_audio_gapless_enabled( Ok(()) } +/// Set allow quality fallback (V2) +#[tauri::command] +pub async fn v2_set_audio_allow_quality_fallback( + enabled: bool, + state: State<'_, AudioSettingsState>, +) -> Result<(), RuntimeError> { + log::info!("[V2] set_audio_allow_quality_fallback: {}", enabled); + let guard = state + .store + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + let store = guard + .as_ref() + .ok_or(RuntimeError::UserSessionNotActivated)?; + store + .set_allow_quality_fallback(enabled) + .map_err(RuntimeError::Internal)?; + Ok(()) +} + +/// Set skip sink switch (V2) — preserves JACK/qjackctl routing +#[tauri::command] +pub async fn v2_set_audio_skip_sink_switch( + enabled: bool, + state: State<'_, AudioSettingsState>, + bridge: State<'_, CoreBridgeState>, +) -> Result<(), RuntimeError> { + log::info!("[V2] set_audio_skip_sink_switch: {}", enabled); + let fresh_settings = { + let guard = state + .store + .lock() + .map_err(|e| RuntimeError::Internal(format!("Lock error: {}", e)))?; + let store = guard + .as_ref() + .ok_or(RuntimeError::UserSessionNotActivated)?; + + // Constraint: cannot enable when dac_passthrough is on + if enabled { + let current = store.get_settings().map_err(RuntimeError::Internal)?; + if current.dac_passthrough { + return Err(RuntimeError::Internal( + "Cannot enable skip sink switch while DAC passthrough is active".to_string(), + )); + } + } + + store + .set_skip_sink_switch(enabled) + .map_err(RuntimeError::Internal)?; + store.get_settings().ok() + }; + + if let Some(fresh) = fresh_settings { + if let Some(b) = bridge.try_get().await { + let _ = b + .player() + .reload_settings(convert_to_qbz_audio_settings(&fresh)); + } + } + Ok(()) +} + /// Set normalization enabled (V2) #[tauri::command] pub async fn v2_set_audio_normalization_enabled( @@ -10195,7 +10090,24 @@ pub async fn v2_musicbrainz_get_artist_metadata( drop(client); // Extract metadata using the location discovery module (now uses V2 types) - let metadata = crate::musicbrainz::location_discovery::extract_metadata(&artist); + let mut metadata = crate::musicbrainz::location_discovery::extract_metadata(&artist); + + // Resolve real country from begin_area hierarchy (MB's "country" field is + // where the artist is active, not where they were born/formed) + if let Some(ref mut loc) = metadata.location { + if loc.city.is_some() { + if let Some(ref area_id) = loc.area_id { + let client = state.client.lock().await; + if let Ok(Some((country_name, country_code))) = + client.resolve_area_country(area_id).await + { + loc.display_name = format!("{}, {}", loc.display_name, country_name); + loc.country = Some(country_name); + loc.country_code = country_code; + } + } + } + } // Cache to V2 cache { @@ -11440,16 +11352,34 @@ pub async fn v2_show_track_notification( #[cfg(target_os = "macos")] { - let _ = &artwork_url; // macOS notify-rust doesn't support custom artwork - // Fire-and-forget: notification delivery shouldn't block track playback response tokio::task::spawn_blocking(move || { let _ = notify_rust::set_application("com.blitzfc.qbz"); - if let Err(e) = notify_rust::Notification::new() - .summary(&title) - .body(&body_text) - .show() - { + + // Cache artwork to disk if available (image_path needs a file path) + let artwork_path = artwork_url.as_deref().and_then(|url_str| { + match v2_cache_notification_artwork(url_str) { + Ok(path) => { + log::debug!("Notification artwork cached: {:?}", path); + Some(path) + } + Err(e) => { + log::debug!("Could not prepare notification artwork: {}", e); + None + } + } + }); + + let mut notification = notify_rust::Notification::new(); + notification.summary(&title).body(&body_text); + + if let Some(ref path) = artwork_path { + if let Some(path_str) = path.to_str() { + notification.image_path(path_str); + } + } + + if let Err(e) = notification.show() { log::warn!("Failed to show macOS notification: {}", e); } }); @@ -11952,7 +11882,7 @@ fn spawn_track_cache_download( let (bit_depth_detected, sample_rate_detected) = match lofty::read_from_path(&new_path) { Ok(tagged_file) => { - use lofty::AudioFile; + use lofty::prelude::*; let properties = tagged_file.properties(); ( properties.bit_depth().map(|bd| bd as u32), diff --git a/src-tauri/src/config/audio_settings.rs b/src-tauri/src/config/audio_settings.rs index adc780ed8..7adf3d65a 100644 --- a/src-tauri/src/config/audio_settings.rs +++ b/src-tauri/src/config/audio_settings.rs @@ -55,6 +55,13 @@ pub struct AudioSettings { /// Values: "ask" (default), "always_fallback", "always_skip" /// Protected by ADR-003: must survive reset_all() and migrations. pub quality_fallback_behavior: String, + /// When true, skip `pactl set-default-sink` on stream creation. + /// Preserves external routing (JACK, qjackctl, Reaper). + /// Mutually exclusive with dac_passthrough. + pub skip_sink_switch: bool, + /// When true, automatically try lower quality tiers if the requested one fails. + /// When false (default), playback or download fails if the exact quality is unavailable. + pub allow_quality_fallback: bool, } impl Default for AudioSettings { @@ -79,6 +86,8 @@ impl Default for AudioSettings { pw_force_bitperfect: false, // Off by default — experimental PipeWire feature sync_audio_on_startup: false, // Off by default — opt-in for stale-settings edge case quality_fallback_behavior: "ask".to_string(), + skip_sink_switch: false, // Off by default — only for JACK/DAW routing setups + allow_quality_fallback: false, // Off by default — fail rather than silently downgrade } } } @@ -175,6 +184,14 @@ impl AudioSettingsStore { "ALTER TABLE audio_settings ADD COLUMN quality_fallback_behavior TEXT DEFAULT 'ask'", [], ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN skip_sink_switch INTEGER DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE audio_settings ADD COLUMN allow_quality_fallback INTEGER DEFAULT 0", + [], + ); Ok(Self { conn }) } @@ -193,7 +210,7 @@ impl AudioSettingsStore { pub fn get_settings(&self) -> Result { self.conn .query_row( - "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior FROM audio_settings WHERE id = 1", + "SELECT output_device, exclusive_mode, dac_passthrough, preferred_sample_rate, backend_type, alsa_plugin, alsa_hardware_volume, stream_first_track, stream_buffer_seconds, streaming_only, limit_quality_to_device, device_max_sample_rate, normalization_enabled, normalization_target_lufs, gapless_enabled, device_sample_rate_limits, pw_force_bitperfect, sync_audio_on_startup, quality_fallback_behavior, skip_sink_switch, allow_quality_fallback FROM audio_settings WHERE id = 1", [], |row| { // Parse backend_type from JSON string @@ -234,6 +251,8 @@ impl AudioSettingsStore { quality_fallback_behavior: row .get::<_, Option>(18)? .unwrap_or_else(|| "ask".to_string()), + skip_sink_switch: row.get::<_, Option>(19)?.unwrap_or(0) != 0, + allow_quality_fallback: row.get::<_, Option>(20)?.unwrap_or(0) != 0, }) }, ) @@ -447,6 +466,26 @@ impl AudioSettingsStore { Ok(()) } + pub fn set_allow_quality_fallback(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE audio_settings SET allow_quality_fallback = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set allow_quality_fallback: {}", e))?; + Ok(()) + } + + pub fn set_skip_sink_switch(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE audio_settings SET skip_sink_switch = ?1 WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set skip_sink_switch: {}", e))?; + Ok(()) + } + pub fn set_pw_force_bitperfect(&self, enabled: bool) -> Result<(), String> { self.conn .execute( @@ -543,7 +582,9 @@ impl AudioSettingsStore { gapless_enabled = ?15, device_sample_rate_limits = ?16, pw_force_bitperfect = ?17, - sync_audio_on_startup = ?18 + sync_audio_on_startup = ?18, + skip_sink_switch = ?19, + allow_quality_fallback = ?20 WHERE id = 1", params![ defaults.output_device, @@ -564,6 +605,8 @@ impl AudioSettingsStore { limits_json, defaults.pw_force_bitperfect as i64, defaults.sync_audio_on_startup as i64, + defaults.skip_sink_switch as i64, + defaults.allow_quality_fallback as i64, ], ) .map_err(|e| format!("Failed to reset audio settings: {}", e))?; @@ -873,6 +916,29 @@ pub fn set_audio_normalization_target( store.set_normalization_target_lufs(target_lufs) } +#[tauri::command] +pub fn set_audio_skip_sink_switch( + state: tauri::State<'_, AudioSettingsState>, + enabled: bool, +) -> Result<(), String> { + log::info!("Command: set_audio_skip_sink_switch {}", enabled); + let guard = state + .store + .lock() + .map_err(|e| format!("Lock error: {}", e))?; + let store = guard.as_ref().ok_or("No active session - please log in")?; + + // Constraint: cannot enable skip_sink_switch when dac_passthrough is on + if enabled { + let settings = store.get_settings()?; + if settings.dac_passthrough { + return Err("Cannot enable skip sink switch while DAC passthrough is active".to_string()); + } + } + + store.set_skip_sink_switch(enabled) +} + #[tauri::command] pub fn set_audio_gapless_enabled( state: tauri::State<'_, AudioSettingsState>, diff --git a/src-tauri/src/config/remote_control_settings.rs b/src-tauri/src/config/remote_control_settings.rs index 86a894d9e..e509814d8 100644 --- a/src-tauri/src/config/remote_control_settings.rs +++ b/src-tauri/src/config/remote_control_settings.rs @@ -6,7 +6,7 @@ //! - token: pairing token used by the remote control PWA use base64::Engine; -use rand::RngCore; +use rand::RngExt; use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -248,7 +248,7 @@ impl RemoteControlSettingsState { fn generate_token() -> String { let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); + rand::rng().fill(&mut bytes); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) } diff --git a/src-tauri/src/credentials/mod.rs b/src-tauri/src/credentials/mod.rs index c0bed00ea..5ba072bb2 100644 --- a/src-tauri/src/credentials/mod.rs +++ b/src-tauri/src/credentials/mod.rs @@ -12,7 +12,7 @@ use aes_gcm::{ }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use keyring::Entry; -use rand::RngCore; +use rand::RngExt; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; @@ -89,7 +89,7 @@ fn load_or_create_installation_salt() -> Result, String> { } let mut salt = [0u8; 32]; - rand::rngs::OsRng.fill_bytes(&mut salt); + rand::rng().fill(&mut salt); fs::write(&path, BASE64.encode(salt)) .map_err(|e| format!("Failed to write installation salt: {}", e))?; @@ -119,7 +119,7 @@ fn load_or_create_machine_id_fallback() -> Result, String> { } let mut machine_fallback = [0u8; 32]; - rand::rngs::OsRng.fill_bytes(&mut machine_fallback); + rand::rng().fill(&mut machine_fallback); fs::write(&path, BASE64.encode(machine_fallback)) .map_err(|e| format!("Failed to write machine fallback id: {}", e))?; @@ -178,7 +178,7 @@ fn encrypt_credentials(credentials: &QobuzCredentials) -> Result // Generate random nonce let mut nonce_raw = [0u8; 12]; - rand::rngs::OsRng.fill_bytes(&mut nonce_raw); + rand::rng().fill(&mut nonce_raw); let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from(nonce_raw).into(); let nonce = Nonce::from_slice(&nonce_bytes); diff --git a/src-tauri/src/idle_inhibit.rs b/src-tauri/src/idle_inhibit.rs new file mode 100644 index 000000000..c45d28587 --- /dev/null +++ b/src-tauri/src/idle_inhibit.rs @@ -0,0 +1,66 @@ +//! Idle/sleep inhibitor via XDG Desktop Portal. +//! +//! Prevents screen blanking and system suspend while audio is playing. +//! Works on GNOME, KDE, Sway, XFCE — and inside Flatpak/Snap sandboxes. + +use ashpd::desktop::inhibit::{InhibitFlags, InhibitOptions, InhibitProxy}; +use ashpd::desktop::Request; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Manages the XDG portal idle inhibit lifecycle. +pub struct IdleInhibitor { + handle: Arc>>>, +} + +impl IdleInhibitor { + pub fn new() -> Self { + Self { + handle: Arc::new(Mutex::new(None)), + } + } + + /// Acquire idle+suspend inhibit. No-op if already held. + pub async fn inhibit(&self) { + let mut guard = self.handle.lock().await; + if guard.is_some() { + return; + } + + match try_inhibit().await { + Ok(request) => { + log::info!("[IdleInhibit] Acquired idle+suspend inhibit via XDG portal"); + *guard = Some(request); + } + Err(e) => { + log::debug!("[IdleInhibit] Could not acquire inhibit: {e}"); + } + } + } + + /// Release the inhibit. No-op if not held. + pub async fn uninhibit(&self) { + let mut guard = self.handle.lock().await; + if guard.take().is_some() { + log::info!("[IdleInhibit] Released idle+suspend inhibit"); + } + } +} + +async fn try_inhibit() -> Result, ashpd::Error> { + let proxy = InhibitProxy::new().await?; + let request = proxy + .inhibit( + None, + InhibitFlags::Idle | InhibitFlags::Suspend, + InhibitOptions::default(), + ) + .await?; + Ok(request) +} + +impl Default for IdleInhibitor { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8726a759e..f390ef186 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ pub mod session_lifecycle; pub mod tauri_adapter; pub mod auto_theme; +#[cfg(target_os = "linux")] pub mod autoconfig_graphics; pub mod api; @@ -27,10 +28,13 @@ pub mod config; pub mod credentials; pub mod discogs; pub mod flatpak; +#[cfg(target_os = "linux")] +pub mod idle_inhibit; pub mod image_cache; pub mod lastfm; pub mod library; pub mod listenbrainz; +pub mod log_sanitize; pub mod logging; pub mod lyrics; pub mod media_controls; @@ -82,6 +86,8 @@ pub struct AppState { pub lastfm: Arc>, pub songlink: SongLinkClient, pub visualizer: Visualizer, + #[cfg(target_os = "linux")] + pub idle_inhibitor: idle_inhibit::IdleInhibitor, } impl AppState { @@ -138,6 +144,8 @@ impl AppState { lastfm: Arc::new(Mutex::new(LastFmClient::default())), songlink: SongLinkClient::new(), visualizer, + #[cfg(target_os = "linux")] + idle_inhibitor: idle_inhibit::IdleInhibitor::new(), } } } @@ -421,6 +429,42 @@ fn should_use_main_window_transparency() -> bool { .unwrap_or(false) } +/// Check if a string looks like a Qobuz link (custom scheme or web URL). +fn is_qobuz_link(s: &str) -> bool { + s.starts_with("qobuzapp://") + || s.starts_with("https://play.qobuz.com/") + || s.starts_with("http://play.qobuz.com/") + || s.starts_with("https://open.qobuz.com/") + || s.starts_with("http://open.qobuz.com/") +} + +/// Resolve a Qobuz link and emit the result to the frontend. +/// If `delay` is true, waits 1500ms to give the frontend time to mount (first launch). +fn handle_qobuz_link(handle: &tauri::AppHandle, url: &str, delay: bool) { + if !is_qobuz_link(url) { + return; + } + match qbz_qobuz::resolve_link(url) { + Ok(resolved) => { + log::info!("[Link] Resolved: {:?}", resolved); + if delay { + let h = handle.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + if let Err(e) = h.emit("link:resolved", &resolved) { + log::error!("[Link] Failed to emit resolved link: {}", e); + } + }); + } else if let Err(e) = handle.emit("link:resolved", &resolved) { + log::error!("[Link] Failed to emit resolved link: {}", e); + } + } + Err(e) => { + log::debug!("[Link] Failed to resolve '{}': {}", url.split('?').next().unwrap_or(url), e); + } + } +} + pub fn run() { // Load .env file if present (for development) // Silently ignore if not found (production builds use compile-time env vars) @@ -693,19 +737,15 @@ pub fn run() { // Check if second instance was launched with a Qobuz link arg for arg in &args { - if arg.starts_with("qobuzapp://") - || arg.contains("play.qobuz.com/") - || arg.contains("open.qobuz.com/") - { - if let Ok(resolved) = qbz_qobuz::resolve_link(arg) { - log::info!("Single-instance forwarding link: {:?}", resolved); - let _ = app.emit("link:resolved", &resolved); - } + if is_qobuz_link(arg) { + handle_qobuz_link(app, arg, false); break; } } })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(AppState::with_device_and_settings( @@ -856,30 +896,36 @@ pub fn run() { // NOTE: Subscription purge check moved to activate_user_session // (runs after login when per-user state is available) - // Check if app was launched with a Qobuz link argument + // Check if app was launched with a Qobuz link argument. // (first launch, not single-instance — that's handled by the plugin above) + // Note: on macOS, URLs arrive via Apple Events (deep-link handler below), + // not CLI args, so this path is only active on Linux/Windows. { - let launch_handle = app.handle().clone(); let args: Vec = std::env::args().collect(); for arg in &args[1..] { - // skip binary name - if arg.starts_with("qobuzapp://") - || arg.contains("play.qobuz.com/") - || arg.contains("open.qobuz.com/") - { - if let Ok(resolved) = qbz_qobuz::resolve_link(arg) { - log::info!("Launch arg link resolved: {:?}", resolved); - // Delay emission to give frontend time to mount - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - let _ = launch_handle.emit("link:resolved", &resolved); - }); - } + if is_qobuz_link(arg) { + handle_qobuz_link(app.handle(), arg, true); break; } } } + // Register deep link handler for qobuzapp:// URLs. + // On macOS, URLs are delivered via Apple Events (not CLI args), so + // this is the only way to receive them. On Linux/Windows, the + // single-instance plugin forwards URL args to this handler too. + { + use tauri_plugin_deep_link::DeepLinkExt; + let deep_link_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + let url_str = url.as_str(); + log::debug!("[Deep Link] Received URL: {}", url_str.split('?').next().unwrap_or(url_str)); + handle_qobuz_link(&deep_link_handle, url_str, false); + } + }); + } + // Start background task to emit playback events let app_handle = app.handle().clone(); let legacy_player_state = app.state::().player.state.clone(); @@ -907,6 +953,7 @@ pub fn run() { normalization_gain, gapless_ready, gapless_next_track_id, + buffer_progress, ) = if let Some(ref v2_state) = v2_state_opt { let v2_track_id = v2_state.current_track_id(); if v2_track_id != 0 { @@ -922,6 +969,7 @@ pub fn run() { v2_state.get_normalization_gain(), v2_state.is_gapless_ready(), v2_state.get_gapless_next_track_id(), + v2_state.get_buffer_progress(), ) } else { // Fallback to legacy player @@ -936,6 +984,7 @@ pub fn run() { legacy_player_state.get_normalization_gain(), legacy_player_state.is_gapless_ready(), legacy_player_state.get_gapless_next_track_id(), + None, ) } } else { @@ -951,6 +1000,7 @@ pub fn run() { legacy_player_state.get_normalization_gain(), legacy_player_state.is_gapless_ready(), legacy_player_state.get_gapless_next_track_id(), + None, ) }; @@ -995,6 +1045,7 @@ pub fn run() { normalization_gain, gapless_ready, gapless_next_track_id, + buffer_progress, }; let _ = app_handle.emit("playback:state", &event); api_server::broadcast_playback_event(&app_handle, &event); @@ -1012,6 +1063,17 @@ pub fn run() { } } + // Idle inhibit: prevent screen/suspend while playing (Linux only, via XDG Portal) + #[cfg(target_os = "linux")] + if is_playing != last_is_playing || track_cleared { + let inhibitor = &app_handle.state::().idle_inhibitor; + if is_playing { + inhibitor.inhibit().await; + } else { + inhibitor.uninhibit().await; + } + } + // Adaptive polling: // - fast (250ms) when playing - improves seekbar/lyrics sync // - slow (1000ms) when paused/stopped with a track loaded @@ -1156,6 +1218,7 @@ pub fn run() { commands_v2::v2_auto_login, commands_v2::v2_manual_login, commands_v2::v2_start_oauth_login, + commands_v2::v2_start_system_browser_oauth, commands_v2::v2_get_user_info, commands_v2::v2_save_credentials, commands_v2::v2_clear_saved_credentials, @@ -1503,6 +1566,8 @@ pub fn run() { commands_v2::v2_set_audio_backend_type, commands_v2::v2_set_audio_alsa_plugin, commands_v2::v2_set_audio_gapless_enabled, + commands_v2::v2_set_audio_allow_quality_fallback, + commands_v2::v2_set_audio_skip_sink_switch, commands_v2::v2_set_audio_normalization_enabled, commands_v2::v2_set_audio_normalization_target, commands_v2::v2_set_audio_device_max_sample_rate, diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index 9f3794c70..6721824c5 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -2247,7 +2247,9 @@ pub async fn library_write_album_metadata_to_files_impl( let write_result = tokio::task::spawn_blocking({ let request = request.clone(); move || -> Result<(), String> { - use lofty::{Accessor, AudioFile, ItemKey, Tag, TaggedFileExt}; + use lofty::prelude::*; + use lofty::tag::{ItemKey, Tag}; + use lofty::config::WriteOptions; // Ensure we only write each file once. let mut by_file: HashMap = HashMap::new(); @@ -2300,16 +2302,19 @@ pub async fn library_write_album_metadata_to_files_impl( // Album artist (not part of Accessor). if request.album_artist.trim().is_empty() { - tag.remove_key(&ItemKey::AlbumArtist); + tag.remove_key(ItemKey::AlbumArtist); } else { tag.insert_text(ItemKey::AlbumArtist, request.album_artist.trim().to_string()); } // Year / Genre if let Some(year) = request.year { - tag.set_year(year); + tag.set_date(lofty::tag::items::Timestamp { + year: year as u16, + ..Default::default() + }); } else { - tag.remove_year(); + tag.remove_date(); } if let Some(ref genre) = request.genre { @@ -2326,17 +2331,17 @@ pub async fn library_write_album_metadata_to_files_impl( if let Some(ref cat) = request.catalog_number { let c = cat.trim(); if c.is_empty() { - tag.remove_key(&ItemKey::CatalogNumber); + tag.remove_key(ItemKey::CatalogNumber); } else { tag.insert_text(ItemKey::CatalogNumber, c.to_string()); } } else { - tag.remove_key(&ItemKey::CatalogNumber); + tag.remove_key(ItemKey::CatalogNumber); } } tagged_file - .save_to_path(path) + .save_to_path(path, WriteOptions::default()) .map_err(|_| "Failed to write tags to audio files. Check that the album folder is mounted read-write and you have permissions.".to_string())?; } diff --git a/src-tauri/src/log_sanitize.rs b/src-tauri/src/log_sanitize.rs new file mode 100644 index 000000000..660f9e543 --- /dev/null +++ b/src-tauri/src/log_sanitize.rs @@ -0,0 +1,84 @@ +//! Sanitization helpers for sensitive values in log output. +//! +//! Masks UUIDs and numeric IDs so logs remain useful for debugging +//! without exposing full identifiers to security scanners or uploaded +//! log files. + +use std::fmt::Display; + +/// Mask a UUID string, preserving first and last 8 characters. +/// +/// `"123e4567-e89b-12d3-a456-426614174000"` → `"123e4567-****-****-****-****14174000"` +/// +/// Non-UUID strings are returned with middle characters masked. +pub fn mask_uuid(uuid: &str) -> String { + // Standard UUID: 8-4-4-4-12 = 36 chars + if uuid.len() == 36 && uuid.chars().filter(|c| *c == '-').count() == 4 { + let first = &uuid[..8]; + let last = &uuid[28..]; + format!("{first}-****-****-****-****{last}") + } else if uuid.len() > 8 { + let quarter = uuid.len() / 4; + let first = &uuid[..quarter]; + let last = &uuid[uuid.len() - quarter..]; + format!("{first}****{last}") + } else { + "****".to_string() + } +} + +/// Mask a numeric or string ID, preserving at most the first 4 characters. +/// +/// `12345678` → `"1234****"` +/// `42` → `"****"` +pub fn mask_id(id: impl Display) -> String { + let s = id.to_string(); + if s.len() > 4 { + format!("{}****", &s[..4]) + } else { + "****".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_uuid_standard() { + assert_eq!( + mask_uuid("123e4567-e89b-12d3-a456-426614174000"), + "123e4567-****-****-****-****14174000" + ); + } + + #[test] + fn test_mask_uuid_short() { + assert_eq!(mask_uuid("abcd"), "****"); + } + + #[test] + fn test_mask_uuid_non_standard() { + assert_eq!(mask_uuid("abcdef1234567890"), "abcd****7890"); + } + + #[test] + fn test_mask_id_long() { + assert_eq!(mask_id(12345678), "1234****"); + } + + #[test] + fn test_mask_id_short() { + assert_eq!(mask_id(42), "****"); + } + + #[test] + fn test_mask_id_zero() { + assert_eq!(mask_id(0), "****"); + } + + #[test] + fn test_mask_id_five_digits() { + assert_eq!(mask_id(10001), "1000****"); + } +} diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index d9172d89c..7f827cee0 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -73,21 +73,15 @@ pub fn get_backend_logs() -> Vec { #[tauri::command] pub async fn upload_logs_to_paste(content: String) -> Result { - let form = reqwest::multipart::Form::new().part( - "file", - reqwest::multipart::Part::text(content) - .file_name("qbz-logs.txt") - .mime_str("text/plain") - .map_err(|e| format!("Failed to create multipart part: {}", e))?, - ); - let client = reqwest::Client::builder() .user_agent("QBZ/1.0") .build() .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + let response = client - .post("https://0x0.st") - .multipart(form) + .post("https://paste.rs/") + .header("Content-Type", "text/plain") + .body(content) .send() .await .map_err(|e| format!("Failed to upload logs: {}", e))?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1f3fb4ec0..f81436014 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -81,83 +81,88 @@ fn is_intel_gpu() -> bool { } fn main() { - // CLI flag: --autoconfig-graphics — detect environment and apply optimal settings - if std::env::args().any(|a| a == "--autoconfig-graphics") { - qbz_nix_lib::autoconfig_graphics::run(); - return; - } + // CLI flags for Linux graphics troubleshooting (X11/Wayland, GDK, GSK, DMA-BUF) + // These are no-ops on macOS/Windows where the graphics stack is managed by the OS. + #[cfg(target_os = "linux")] + { + // CLI flag: --autoconfig-graphics — detect environment and apply optimal settings + if std::env::args().any(|a| a == "--autoconfig-graphics") { + qbz_nix_lib::autoconfig_graphics::run(); + return; + } - // CLI flag: --reset-graphics — resets ALL graphics/composition settings to defaults - if std::env::args().any(|a| a == "--reset-graphics") { - eprintln!("[QBZ] Resetting all graphics settings to defaults..."); - let mut errors = Vec::new(); + // CLI flag: --reset-graphics — resets ALL graphics/composition settings to defaults + if std::env::args().any(|a| a == "--reset-graphics") { + eprintln!("[QBZ] Resetting all graphics settings to defaults..."); + let mut errors = Vec::new(); - // Reset graphics settings (force_x11, gdk_scale, gdk_dpi_scale) - match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new() { - Ok(store) => { - if let Err(e) = store.set_force_x11(false) { - errors.push(format!("force_x11: {}", e)); + // Reset graphics settings (force_x11, gdk_scale, gdk_dpi_scale) + match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new() { + Ok(store) => { + if let Err(e) = store.set_force_x11(false) { + errors.push(format!("force_x11: {}", e)); + } + if let Err(e) = store.set_gdk_scale(None) { + errors.push(format!("gdk_scale: {}", e)); + } + if let Err(e) = store.set_gdk_dpi_scale(None) { + errors.push(format!("gdk_dpi_scale: {}", e)); + } + if let Err(e) = store.set_gsk_renderer(None) { + errors.push(format!("gsk_renderer: {}", e)); + } + if let Err(e) = store.set_hardware_acceleration(true) { + errors.push(format!("hardware_acceleration: {}", e)); + } } - if let Err(e) = store.set_gdk_scale(None) { - errors.push(format!("gdk_scale: {}", e)); + Err(e) => errors.push(format!("graphics settings store: {}", e)), + } + + // Reset developer settings (force_dmabuf) + match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { + Ok(store) => { + if let Err(e) = store.set_force_dmabuf(false) { + errors.push(format!("force_dmabuf: {}", e)); + } } - if let Err(e) = store.set_gdk_dpi_scale(None) { - errors.push(format!("gdk_dpi_scale: {}", e)); - } - if let Err(e) = store.set_gsk_renderer(None) { - errors.push(format!("gsk_renderer: {}", e)); - } - if let Err(e) = store.set_hardware_acceleration(true) { - errors.push(format!("hardware_acceleration: {}", e)); + Err(e) => errors.push(format!("developer settings store: {}", e)), + } + + if errors.is_empty() { + eprintln!("[QBZ] All graphics settings have been reset:"); + eprintln!("[QBZ] - hardware_acceleration: true"); + eprintln!("[QBZ] - force_x11: false"); + eprintln!("[QBZ] - gdk_scale: auto"); + eprintln!("[QBZ] - gdk_dpi_scale: auto"); + eprintln!("[QBZ] - gsk_renderer: auto"); + eprintln!("[QBZ] - force_dmabuf: false"); + eprintln!("[QBZ] You can now start QBZ normally."); + eprintln!( + "[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings." + ); + } else { + eprintln!("[QBZ] Some settings could not be reset:"); + for e in &errors { + eprintln!("[QBZ] - {}", e); } } - Err(e) => errors.push(format!("graphics settings store: {}", e)), + return; } - // Reset developer settings (force_dmabuf) - match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { - Ok(store) => { - if let Err(e) = store.set_force_dmabuf(false) { - errors.push(format!("force_dmabuf: {}", e)); - } + // CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits + if std::env::args().any(|a| a == "--reset-dmabuf") { + match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { + Ok(store) => match store.set_force_dmabuf(false) { + Ok(()) => { + eprintln!("[QBZ] Developer force_dmabuf has been reset to false."); + eprintln!("[QBZ] You can now start QBZ normally."); + } + Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e), + }, + Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e), } - Err(e) => errors.push(format!("developer settings store: {}", e)), + return; } - - if errors.is_empty() { - eprintln!("[QBZ] All graphics settings have been reset:"); - eprintln!("[QBZ] - hardware_acceleration: true"); - eprintln!("[QBZ] - force_x11: false"); - eprintln!("[QBZ] - gdk_scale: auto"); - eprintln!("[QBZ] - gdk_dpi_scale: auto"); - eprintln!("[QBZ] - gsk_renderer: auto"); - eprintln!("[QBZ] - force_dmabuf: false"); - eprintln!("[QBZ] You can now start QBZ normally."); - eprintln!( - "[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings." - ); - } else { - eprintln!("[QBZ] Some settings could not be reset:"); - for e in &errors { - eprintln!("[QBZ] - {}", e); - } - } - return; - } - - // CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits - if std::env::args().any(|a| a == "--reset-dmabuf") { - match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { - Ok(store) => match store.set_force_dmabuf(false) { - Ok(()) => { - eprintln!("[QBZ] Developer force_dmabuf has been reset to false."); - eprintln!("[QBZ] You can now start QBZ normally."); - } - Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e), - }, - Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e), - } - return; } // Set the application name/class for Linux window managers @@ -537,7 +542,7 @@ fn main() { qbz_nix_lib::logging::log_startup(&format!("[QBZ] GPU rendering: {}", gpu_status)); } - // Catch GTK initialization panics and show a recovery message + // Catch panics during startup and show a recovery message let result = std::panic::catch_unwind(|| qbz_nix_lib::run()); if let Err(panic_info) = result { @@ -549,33 +554,36 @@ fn main() { "Unknown panic".to_string() }; - let is_gtk_failure = msg.contains("Failed to initialize gtk") - || msg.contains("Failed to initialize GTK") - || msg.contains("GDK_BACKEND"); + // GTK initialization failures only happen on Linux (X11/Wayland graphics stack) + #[cfg(target_os = "linux")] + { + let is_gtk_failure = msg.contains("Failed to initialize gtk") + || msg.contains("Failed to initialize GTK") + || msg.contains("GDK_BACKEND"); - if is_gtk_failure { - eprintln!(); - eprintln!("╔══════════════════════════════════════════════════════════════╗"); - eprintln!("║ QBZ failed to start: GTK initialization error ║"); - eprintln!("╠══════════════════════════════════════════════════════════════╣"); - eprintln!("║ ║"); - eprintln!("║ This is usually caused by incompatible graphics settings. ║"); - eprintln!("║ To fix it, run: ║"); - eprintln!("║ ║"); - eprintln!("║ qbz --reset-graphics ║"); - eprintln!("║ ║"); - eprintln!("║ Or for Flatpak: ║"); - eprintln!("║ ║"); - eprintln!("║ flatpak run com.blitzfc.qbz --reset-graphics ║"); - eprintln!("║ ║"); - eprintln!("╚══════════════════════════════════════════════════════════════╝"); - eprintln!(); - eprintln!("[QBZ] Error detail: {}", msg); - } else { - eprintln!("[QBZ] Fatal error: {}", msg); - eprintln!("[QBZ] If the app fails to start, try: qbz --reset-graphics"); + if is_gtk_failure { + eprintln!(); + eprintln!("╔══════════════════════════════════════════════════════════════╗"); + eprintln!("║ QBZ failed to start: GTK initialization error ║"); + eprintln!("╠══════════════════════════════════════════════════════════════╣"); + eprintln!("║ ║"); + eprintln!("║ This is usually caused by incompatible graphics settings. ║"); + eprintln!("║ To fix it, run: ║"); + eprintln!("║ ║"); + eprintln!("║ qbz --reset-graphics ║"); + eprintln!("║ ║"); + eprintln!("║ Or for Flatpak: ║"); + eprintln!("║ ║"); + eprintln!("║ flatpak run com.blitzfc.qbz --reset-graphics ║"); + eprintln!("║ ║"); + eprintln!("╚══════════════════════════════════════════════════════════════╝"); + eprintln!(); + eprintln!("[QBZ] Error detail: {}", msg); + std::process::exit(1); + } } + eprintln!("[QBZ] Fatal error: {}", msg); std::process::exit(1); } } diff --git a/src-tauri/src/musicbrainz/location_discovery.rs b/src-tauri/src/musicbrainz/location_discovery.rs index 5667b8524..5e18d2f46 100644 --- a/src-tauri/src/musicbrainz/location_discovery.rs +++ b/src-tauri/src/musicbrainz/location_discovery.rs @@ -57,68 +57,25 @@ fn resolve_location( .map(|t| t.eq_ignore_ascii_case("subdivision")) .unwrap_or(false); - let country_name = area - .and_then(|a| { - if a.area_type - .as_deref() - .map(|t| t.eq_ignore_ascii_case("country")) - .unwrap_or(false) - { - Some(a.name.clone()) - } else { - None - } - }) - .or_else(|| country.map(|c| country_code_to_name(c))); - - if is_city { - let display = if let Some(ref cn) = country_name { - format!("{}, {}", cn, ba.name) - } else { - ba.name.clone() - }; - - return Some(ArtistLocation { - city: Some(ba.name.clone()), - area_id: Some(ba.id.clone()), - country: country_name, - country_code: cc, - display_name: display, - precision: LocationPrecision::City, - }); - } - - if is_subdivision { - let display = if let Some(ref cn) = country_name { - format!("{}, {}", cn, ba.name) - } else { - ba.name.clone() - }; - - return Some(ArtistLocation { - city: Some(ba.name.clone()), - area_id: Some(ba.id.clone()), - country: country_name, - country_code: cc, - display_name: display, - precision: LocationPrecision::State, - }); - } - - // begin_area exists but type unknown — still use it as best guess - let display = if let Some(ref cn) = country_name { - format!("{}, {}", cn, ba.name) + // MB's "country" field is where the artist is active (not where born). + // When we have a city-level begin_area, display only the city name + // to avoid incorrect country attribution (e.g., Zimmer: born Frankfurt, + // but country=US because he works in the US). + let precision = if is_city { + LocationPrecision::City + } else if is_subdivision { + LocationPrecision::State } else { - ba.name.clone() + LocationPrecision::City // best guess }; return Some(ArtistLocation { city: Some(ba.name.clone()), area_id: Some(ba.id.clone()), - country: country_name, + country: country.map(|c| country_code_to_name(c)), country_code: cc, - display_name: display, - precision: LocationPrecision::City, + display_name: ba.name.clone(), + precision, }); } @@ -144,7 +101,7 @@ fn resolve_location( // Non-country area (could be city without begin_area) let country_name = country.map(|c| country_code_to_name(c)); let display = if let Some(ref cn) = country_name { - format!("{}, {}", cn, a.name) + format!("{}, {}", a.name, cn) } else { a.name.clone() }; diff --git a/src-tauri/src/offline_cache/commands.rs b/src-tauri/src/offline_cache/commands.rs index 74b1289ca..de4ed36a8 100644 --- a/src-tauri/src/offline_cache/commands.rs +++ b/src-tauri/src/offline_cache/commands.rs @@ -64,7 +64,7 @@ async fn post_process_cached_track( }; // 6. Extract audio properties from FLAC file - use lofty::AudioFile; + use lofty::prelude::*; let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) { Ok(tagged_file) => { let properties = tagged_file.properties(); @@ -137,6 +137,7 @@ pub async fn cache_track_for_offline( state: State<'_, AppState>, cache_state: State<'_, OfflineCacheState>, library_state: State<'_, crate::library::commands::LibraryState>, + audio_settings: State<'_, crate::config::audio_settings::AudioSettingsState>, app_handle: AppHandle, ) -> Result<(), String> { log::info!( @@ -171,6 +172,15 @@ pub async fn cache_track_for_offline( db.insert_track(&track_info, &file_path_str)?; } + // Read quality fallback preference before spawning + let allow_fallback = audio_settings + .store + .lock() + .ok() + .and_then(|g| g.as_ref().and_then(|s| s.get_settings().ok())) + .map(|s| s.allow_quality_fallback) + .unwrap_or(false); + // Clone what we need for the spawn let client = state.client.clone(); let fetcher = cache_state.fetcher.clone(); @@ -222,12 +232,18 @@ pub async fn cache_track_for_offline( }), ); - // Get stream URL with highest quality available + // Get stream URL — respect quality fallback preference let stream_url = { let client_guard = client.read().await; - client_guard - .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) - .await + if allow_fallback { + client_guard + .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) + .await + } else { + client_guard + .get_stream_url(track_id, Quality::UltraHiRes) + .await + } }; let url = match stream_url { @@ -324,9 +340,15 @@ pub async fn cache_track_for_offline( tokio::time::sleep(std::time::Duration::from_millis(500)).await; let retry_url = { let client_guard = client.read().await; - client_guard - .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) - .await + if allow_fallback { + client_guard + .get_stream_url_with_fallback(track_id, Quality::UltraHiRes) + .await + } else { + client_guard + .get_stream_url(track_id, Quality::UltraHiRes) + .await + } }; let retry_result = match retry_url { diff --git a/src-tauri/src/offline_cache/metadata.rs b/src-tauri/src/offline_cache/metadata.rs index de3971f47..78412962c 100644 --- a/src-tauri/src/offline_cache/metadata.rs +++ b/src-tauri/src/offline_cache/metadata.rs @@ -2,7 +2,10 @@ //! //! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags. -use lofty::{Accessor, AudioFile, ItemKey, Picture, PictureType, Tag, TagExt, TaggedFileExt}; +use lofty::config::WriteOptions; +use lofty::picture::{MimeType, Picture, PictureType}; +use lofty::prelude::*; +use lofty::tag::{ItemKey, Tag}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -139,7 +142,10 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res } if let Some(year) = metadata.year { - tag.set_year(year); + tag.set_date(lofty::tag::items::Timestamp { + year: year as u16, + ..Default::default() + }); } if let Some(genre) = &metadata.genre { @@ -147,7 +153,7 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res } if let Some(isrc) = &metadata.isrc { - tag.insert_text(ItemKey::Unknown("ISRC".to_string()), isrc.clone()); + tag.insert_text(ItemKey::Isrc, isrc.clone()); } if let Some(label) = &metadata.label { @@ -164,7 +170,7 @@ pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Res // Save tags tagged_file - .save_to_path(path) + .save_to_path(path, WriteOptions::default()) .map_err(|e| format!("Failed to save tags: {}", e))?; Ok(()) @@ -186,20 +192,18 @@ pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), Str // Determine MIME type from URL let mime_type = if artwork_url.contains(".jpg") || artwork_url.contains(".jpeg") { - lofty::MimeType::Jpeg + MimeType::Jpeg } else if artwork_url.contains(".png") { - lofty::MimeType::Png + MimeType::Png } else { - lofty::MimeType::Jpeg // Default to JPEG + MimeType::Jpeg // Default to JPEG }; // Create picture - let picture = Picture::new_unchecked( - PictureType::CoverFront, - Some(mime_type), - None, - artwork_bytes.to_vec(), - ); + let picture = Picture::unchecked(artwork_bytes.to_vec()) + .pic_type(PictureType::CoverFront) + .mime_type(mime_type) + .build(); // Read file let path = Path::new(file_path); @@ -218,7 +222,7 @@ pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), Str // Save tagged_file - .save_to_path(path) + .save_to_path(path, WriteOptions::default()) .map_err(|e| format!("Failed to save artwork: {}", e))?; Ok(()) diff --git a/src-tauri/src/offline_cache/migration.rs b/src-tauri/src/offline_cache/migration.rs index bf8dd6d70..de361bb99 100644 --- a/src-tauri/src/offline_cache/migration.rs +++ b/src-tauri/src/offline_cache/migration.rs @@ -119,7 +119,7 @@ async fn migrate_single_track( }; // 6. Extract audio properties from FLAC file - use lofty::AudioFile; + use lofty::prelude::*; let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) { Ok(tagged_file) => { let properties = tagged_file.properties(); diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 33f4b31fb..bc1b24954 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -513,6 +513,7 @@ fn try_init_stream_with_backend( exclusive_mode: audio_settings.exclusive_mode, alsa_plugin: audio_settings.alsa_plugin, pw_force_bitperfect: audio_settings.pw_force_bitperfect, + skip_sink_switch: audio_settings.skip_sink_switch, }; // For ALSA backend with hw: devices, try direct ALSA first (Linux only) @@ -580,6 +581,9 @@ pub struct PlaybackEvent { /// Track ID of the gapless-queued next track (0 = none queued) #[serde(default)] pub gapless_next_track_id: u64, + /// Streaming buffer progress (0.0-1.0), None when fully cached + #[serde(skip_serializing_if = "Option::is_none")] + pub buffer_progress: Option, } /// Shared state between main thread and audio thread @@ -2776,6 +2780,7 @@ impl Player { normalization_gain: self.state.get_normalization_gain(), gapless_ready: self.state.is_gapless_ready(), gapless_next_track_id: self.state.get_gapless_next_track_id(), + buffer_progress: None, // Legacy player doesn't track streaming buffer } } } diff --git a/src-tauri/src/qconnect_service.rs b/src-tauri/src/qconnect_service.rs index e40a9c217..b731edf05 100644 --- a/src-tauri/src/qconnect_service.rs +++ b/src-tauri/src/qconnect_service.rs @@ -3926,7 +3926,7 @@ async fn deferred_renderer_join( log::error!("[QConnect] Deferred renderer max quality report failed: {err}"); } - log::info!("[QConnect] Deferred renderer join complete for session {session_uuid}"); + log::info!("[QConnect] Deferred renderer join complete"); // Re-request session state so the server sends an updated renderer list // (including ourselves). Without this, the frontend may not see QBZ as a @@ -4151,7 +4151,7 @@ pub async fn v2_qconnect_send_command_with_admission( .await { Ok(uuid) => { - log::info!("[QConnect] send_command_with_admission: sent uuid={uuid}"); + log::info!("[QConnect] send_command_with_admission: sent uuid={}", crate::log_sanitize::mask_uuid(&uuid)); Ok(uuid) } Err(err) => { diff --git a/src-tauri/src/queue/mod.rs b/src-tauri/src/queue/mod.rs index c1f7aabec..767d8c785 100644 --- a/src-tauri/src/queue/mod.rs +++ b/src-tauri/src/queue/mod.rs @@ -724,7 +724,7 @@ impl QueueManager { return; } - use rand::{Rng, SeedableRng}; + use rand::{RngExt, SeedableRng}; use std::time::{SystemTime, UNIX_EPOCH}; // Create seeded RNG from current timestamp @@ -736,7 +736,7 @@ impl QueueManager { // Proper Fisher-Yates shuffle - each iteration gets a NEW random number for i in (1..order.len()).rev() { - let j = rng.gen_range(0..=i); + let j = rng.random_range(0..=i); order.swap(i, j); } } diff --git a/src-tauri/src/reco_store/commands.rs b/src-tauri/src/reco_store/commands.rs index 2efbcc619..4f5b63945 100644 --- a/src-tauri/src/reco_store/commands.rs +++ b/src-tauri/src/reco_store/commands.rs @@ -901,7 +901,7 @@ pub async fn reco_get_home_resolved( // Shuffle artists so every favorite gets a fair chance let shuffled_artists = { let mut ids = favorite_artist_ids.clone(); - ids.shuffle(&mut rand::thread_rng()); + ids.shuffle(&mut rand::rng()); ids }; @@ -958,7 +958,7 @@ pub async fn reco_get_home_resolved( let mut seen = std::collections::HashSet::new(); all_albums.retain(|a| seen.insert(a.id.clone())); } - all_albums.shuffle(&mut rand::thread_rng()); + all_albums.shuffle(&mut rand::rng()); all_albums.truncate(limit_favorites as usize); all_albums }; diff --git a/src-tauri/src/session_lifecycle.rs b/src-tauri/src/session_lifecycle.rs index 4d00fdf64..a97c2dacf 100644 --- a/src-tauri/src/session_lifecycle.rs +++ b/src-tauri/src/session_lifecycle.rs @@ -345,8 +345,7 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri // settings, and cached data. Fall back to 0 if never logged in. let offline_user_id = UserDataPaths::load_last_user_id().unwrap_or(0); log::info!( - "[SessionLifecycle] Activating offline session (user_id={}{})", - offline_user_id, + "[SessionLifecycle] Activating offline session{}", if offline_user_id == 0 { " — no previous session" } else { "" } ); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3478ca73e..4fc459ad2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "QBZ", - "version": "1.2.1", + "version": "1.2.2", "identifier": "com.blitzfc.qbz", "build": { "beforeDevCommand": "npm run dev", @@ -31,6 +31,13 @@ } } }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["qobuzapp"] + } + } + }, "bundle": { "active": true, "targets": "all", @@ -45,7 +52,8 @@ "shortDescription": "Native Qobuz client with hi-res audio support", "longDescription": "QBZ is a native high-fidelity client for Qobuz\u2122 subscribers on Linux, designed for audiophiles who need bit-perfect playback without browser sample rate limitations. This application uses the Qobuz API but is not certified by Qobuz.", "macOS": { - "minimumSystemVersion": "10.15" + "minimumSystemVersion": "10.15", + "signingIdentity": "-" }, "linux": { "deb": { diff --git a/src/lib/components/AboutModal.svelte b/src/lib/components/AboutModal.svelte index 24897f169..e9277c86e 100644 --- a/src/lib/components/AboutModal.svelte +++ b/src/lib/components/AboutModal.svelte @@ -1,9 +1,12 @@
-
+
{#each coverflowItems as item (item.index)}
- Now Playing + {$t('player.nowPlaying')}
{/if}

{trackTitle}

{#if explicit} - + {/if}

{artist}

@@ -176,7 +150,6 @@ padding-left: 40px; padding-right: 40px; z-index: 5; - perspective: 1000px; } .coverflow-container { @@ -185,10 +158,9 @@ align-items: center; justify-content: center; width: 100%; - max-width: 900px; - height: min(45vh, 360px); - perspective: 1200px; - transform-style: preserve-3d; + max-width: 1200px; + height: min(55vh, 480px); + perspective: 1400px; } .coverflow-item { @@ -198,7 +170,6 @@ padding: 0; cursor: pointer; transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1), opacity 500ms cubic-bezier(0.4, 0, 0.2, 1); - transform-style: preserve-3d; } .coverflow-item:disabled { @@ -211,8 +182,8 @@ .cover-wrapper { position: relative; - width: min(38vh, 300px); - height: min(38vh, 300px); + width: min(42vh, 380px); + height: min(42vh, 380px); border-radius: 8px; overflow: visible; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); @@ -250,65 +221,48 @@ /* Center item */ .coverflow-item.center { - z-index: 5; - transform: translateX(0) rotateY(0deg) scale(1); + z-index: 10; + transform: translateX(0) scale(1); } .coverflow-item.center .cover-wrapper { - width: min(45vh, 360px); - height: min(45vh, 360px); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); + width: min(52vh, 460px); + height: min(52vh, 460px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); } - /* Left items */ + /* Left items — z-index < center so they render behind it */ .coverflow-item.left-1 { z-index: 4; - transform: translateX(-180px) rotateY(45deg) scale(0.85); - --hover-x: -170px; + transform: translateX(-200px) rotateY(45deg) scale(0.82); + --hover-x: -190px; --hover-rotate: 40deg; - opacity: 0.9; + opacity: 0.85; } .coverflow-item.left-2 { z-index: 3; - transform: translateX(-300px) rotateY(55deg) scale(0.7); - --hover-x: -290px; + transform: translateX(-340px) rotateY(55deg) scale(0.65); + --hover-x: -330px; --hover-rotate: 50deg; - opacity: 0.6; + opacity: 0.55; } - /* Right items */ + /* Right items — mirror of left */ .coverflow-item.right-1 { z-index: 4; - transform: translateX(180px) rotateY(-45deg) scale(0.85); - --hover-x: 170px; + transform: translateX(200px) rotateY(-45deg) scale(0.82); + --hover-x: 190px; --hover-rotate: -40deg; - opacity: 0.9; + opacity: 0.85; } .coverflow-item.right-2 { z-index: 3; - transform: translateX(300px) rotateY(-55deg) scale(0.7); - --hover-x: 290px; + transform: translateX(340px) rotateY(-55deg) scale(0.65); + --hover-x: 330px; --hover-rotate: -50deg; - opacity: 0.6; - } - - /* Animation classes */ - .coverflow-container.animate-left .coverflow-item { - animation: slideLeft 500ms cubic-bezier(0.4, 0, 0.2, 1); - } - - .coverflow-container.animate-right .coverflow-item { - animation: slideRight 500ms cubic-bezier(0.4, 0, 0.2, 1); - } - - @keyframes slideLeft { - 0% { transform: translateX(calc(var(--translate-x, 0px) + 100px)); } - } - - @keyframes slideRight { - 0% { transform: translateX(calc(var(--translate-x, 0px) - 100px)); } + opacity: 0.55; } /* Track info */ @@ -403,46 +357,46 @@ } /* Responsive */ - @media (max-width: 900px) { + @media (max-width: 1100px) { .coverflow-item.left-1 { - transform: translateX(-140px) rotateY(45deg) scale(0.8); - --hover-x: -130px; + transform: translateX(-160px) rotateY(45deg) scale(0.78); + --hover-x: -150px; } .coverflow-item.left-2 { - transform: translateX(-230px) rotateY(55deg) scale(0.65); - --hover-x: -220px; + transform: translateX(-270px) rotateY(55deg) scale(0.6); + --hover-x: -260px; } .coverflow-item.right-1 { - transform: translateX(140px) rotateY(-45deg) scale(0.8); - --hover-x: 130px; + transform: translateX(160px) rotateY(-45deg) scale(0.78); + --hover-x: 150px; } .coverflow-item.right-2 { - transform: translateX(230px) rotateY(-55deg) scale(0.65); - --hover-x: 220px; + transform: translateX(270px) rotateY(-55deg) scale(0.6); + --hover-x: 260px; } } @media (max-width: 768px) { .coverflow-panel { - padding: 70px 16px 130px; + padding: 60px 16px 100px; gap: 16px; } .cover-wrapper { - width: min(30vh, 200px); - height: min(30vh, 200px); + width: min(32vh, 240px); + height: min(32vh, 240px); } .coverflow-item.center .cover-wrapper { - width: min(38vh, 260px); - height: min(38vh, 260px); + width: min(42vh, 320px); + height: min(42vh, 320px); } .coverflow-item.left-1 { - transform: translateX(-100px) rotateY(45deg) scale(0.75); + transform: translateX(-120px) rotateY(45deg) scale(0.72); } .coverflow-item.left-2 { @@ -450,7 +404,7 @@ } .coverflow-item.right-1 { - transform: translateX(100px) rotateY(-45deg) scale(0.75); + transform: translateX(120px) rotateY(-45deg) scale(0.72); } .coverflow-item.right-2 { @@ -460,13 +414,13 @@ @media (max-height: 600px) { .cover-wrapper { - width: min(28vh, 180px); - height: min(28vh, 180px); + width: min(30vh, 200px); + height: min(30vh, 200px); } .coverflow-item.center .cover-wrapper { - width: min(32vh, 220px); - height: min(32vh, 220px); + width: min(38vh, 280px); + height: min(38vh, 280px); } .track-info { diff --git a/src/lib/components/immersive/panels/EnergyBandsPanel.svelte b/src/lib/components/immersive/panels/EnergyBandsPanel.svelte index b735816b9..a6cae9245 100644 --- a/src/lib/components/immersive/panels/EnergyBandsPanel.svelte +++ b/src/lib/components/immersive/panels/EnergyBandsPanel.svelte @@ -1,5 +1,6 @@

- QBZ detected it's running inside a Flatpak sandbox. - There are some considerations to ensure the best experience. + {$t('updates.runningInsideSandbox', { values: { env: environment } })} {$t('updates.flatpak.intro')}

-

What you should know:

+

{$t('updates.flatpak.whatYouShouldKnow.heading')}

    -
  • Access to external music folders (NAS, network mounts) requires manual permission grants
  • -
  • Some audio features may behave differently due to sandbox restrictions
  • -
  • The Settings page includes Flatpak-specific commands you can copy and run
  • +
  • {$t('updates.flatpak.whatYouShouldKnow.externalMusicFolderAccess')}
  • +
  • {$t('updates.flatpak.whatYouShouldKnow.audioFeatures')}
  • +
  • {$t('updates.flatpak.whatYouShouldKnow.settingsPage')}

- Check the Flatpak Sandbox section in Settings for detailed instructions and copy-paste commands. + {$t('updates.flatpak.note')}

{#snippet footer()} {/snippet} diff --git a/src/lib/components/updates/SnapWelcomeModal.svelte b/src/lib/components/updates/SnapWelcomeModal.svelte index 9fc769228..8ed05a776 100644 --- a/src/lib/components/updates/SnapWelcomeModal.svelte +++ b/src/lib/components/updates/SnapWelcomeModal.svelte @@ -1,6 +1,7 @@

- QBZ detected it's running inside a Snap sandbox. - Some audio plugs need to be connected manually for the best experience. + {$t('updates.runningInsideSandbox', { values: { env: environment } })} {$t('updates.snap.intro')}

-

Required plug connections:

+

{$t('updates.snap.requiredPlugConnections')}

@@ -75,19 +77,19 @@ sudo snap connect qbz-player:mpris`;
-

Optional (for external drives / NAS):

+

{$t('updates.snap.optional')}

@@ -95,14 +97,14 @@ sudo snap connect qbz-player:mpris`;

- Run these commands in a terminal, then restart QBZ. This only needs to be done once. + {$t('updates.snap.note')}

{#snippet footer()} {/snippet} diff --git a/src/lib/components/updates/UpdateAvailableModal.svelte b/src/lib/components/updates/UpdateAvailableModal.svelte index 1b0a330e2..a0c57b6d0 100644 --- a/src/lib/components/updates/UpdateAvailableModal.svelte +++ b/src/lib/components/updates/UpdateAvailableModal.svelte @@ -1,5 +1,6 @@ - +
-

A new version of QBZ has been released

+

{$t('updates.newVersionReleased')}

v{currentVersion} @@ -27,14 +28,14 @@
{#snippet footer()} {/snippet}
diff --git a/src/lib/components/updates/UpdateCheckResultModal.svelte b/src/lib/components/updates/UpdateCheckResultModal.svelte index c4056dfa3..a5f016020 100644 --- a/src/lib/components/updates/UpdateCheckResultModal.svelte +++ b/src/lib/components/updates/UpdateCheckResultModal.svelte @@ -1,6 +1,7 @@ - +
{#if status === 'update_available'} -

New version available: v{newVersion}

+

{$t('updates.newVersionAvailable', { values: { version: newVersion } })}

{:else} -

No updates found.

+

{$t('updates.noUpdatesFound')}

{/if}
{#snippet footer()} {/snippet} diff --git a/src/lib/components/updates/UpdateReminderModal.svelte b/src/lib/components/updates/UpdateReminderModal.svelte index fb4cce291..35a8b3be0 100644 --- a/src/lib/components/updates/UpdateReminderModal.svelte +++ b/src/lib/components/updates/UpdateReminderModal.svelte @@ -1,5 +1,6 @@ - +
-

Can be re-enabled from Settings

+

{$t('updates.updateReminder.hint')}

{#snippet footer()} {/snippet}
diff --git a/src/lib/components/updates/WhatsNewModal.svelte b/src/lib/components/updates/WhatsNewModal.svelte index 7b2ec303d..4d69642e2 100644 --- a/src/lib/components/updates/WhatsNewModal.svelte +++ b/src/lib/components/updates/WhatsNewModal.svelte @@ -1,6 +1,7 @@
{#if release} @@ -98,15 +95,15 @@ {@html releaseHtml}
{:else} -

Release notes are not available for this version.

+

{$t('updates.whatsNew.releaseNotesNotAvailableForVersion')}

{/if} {:else} -

Release notes are not available.

+

{$t('updates.whatsNew.releaseNotesNotAvailable')}

{/if} {#snippet footer()} {/snippet}
diff --git a/src/lib/components/views/AlbumDetailView.svelte b/src/lib/components/views/AlbumDetailView.svelte index 0d7600990..5594d7a47 100644 --- a/src/lib/components/views/AlbumDetailView.svelte +++ b/src/lib/components/views/AlbumDetailView.svelte @@ -3,14 +3,14 @@ import { invoke, convertFileSrc } from '@tauri-apps/api/core'; import { open, save } from '@tauri-apps/plugin-dialog'; import { openUrl } from '@tauri-apps/plugin-opener'; - import { t } from 'svelte-i18n'; + import { t, locale } from 'svelte-i18n'; import { showToast } from '$lib/stores/toastStore'; import { hasCustomAlbumCover, setCustomAlbumCover, removeCustomAlbumCover as removeCustomCoverFromStore } from '$lib/stores/customAlbumCoverStore'; - import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, Loader2, CheckSquare, BookOpen } from 'lucide-svelte'; + import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen } from 'lucide-svelte'; import { cachedSrc } from '$lib/actions/cachedImage'; import AlbumCard from '../AlbumCard.svelte'; import TrackRow from '../TrackRow.svelte'; @@ -319,7 +319,7 @@ if (album.releaseDate) { const date = new Date(album.releaseDate); if (!isNaN(date.getTime())) { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString($locale ? $locale : 'en-us', { year: 'numeric', month: 'long', day: 'numeric' @@ -479,7 +479,7 @@ @@ -518,21 +518,21 @@ • {album.genre}
{album.quality}
-
{album.trackCount} tracks • {album.duration}
+
{album.trackCount} {$t('album.tracks')} • {album.duration}
@@ -541,7 +541,7 @@ class:is-active={isFavorite} onclick={toggleFavorite} disabled={isFavoriteLoading} - title={isFavorite ? 'Remove from favorites' : 'Add to favorites'} + title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')} > {#if radioLoading} - + {:else} {/if} @@ -567,7 +567,7 @@
@@ -615,9 +615,9 @@
#
-
Title
-
Duration
-
Quality
+
{$t('tracklist.title')}
+
{$t('tracklist.duration')}
+
{$t('tracklist.quality')}
@@ -697,14 +697,14 @@ {#if filteredArtistAlbums.length > 0 && !isVariousArtists}
-

By the same artist

+

{$t('album.sameArtist')}

{#if hasMoreThanVisible} diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index a23b41efc..ff4a61d50 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -3,9 +3,9 @@ import { open, save } from '@tauri-apps/plugin-dialog'; import { openUrl } from '@tauri-apps/plugin-opener'; import { setCustomImage, removeCustomImage as removeCustomImageFromStore } from '$lib/stores/customArtistImageStore'; - import { t } from 'svelte-i18n'; + import { t, locale } from 'svelte-i18n'; import { cachedSrc } from '$lib/actions/cachedImage'; - import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc, Settings, CheckSquare, PanelRightClose, ThumbsDown } from 'lucide-svelte'; + import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, Ellipsis, Info, Disc, Settings, SquareCheckBig, PanelRightClose, ThumbsDown } from 'lucide-svelte'; import { isBlacklisted, isEnabled as isFilteringEnabled, @@ -930,6 +930,18 @@ return begin; } + function formatMbDate_v2(dateToFormat: string): string { + const date = new Date(dateToFormat); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString($locale ? $locale : 'en-us', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + return dateToFormat; + }; + // Load MusicBrainz relationships for artist enrichment async function loadMusicBrainzRelationships() { // First, resolve the artist to get MBID @@ -1407,15 +1419,15 @@ let hasOthers = $derived(artist.others.length > 0); let hasPlaylists = $derived(artist.playlists.length > 0); let jumpSections = $derived.by(() => [ - { id: 'about', label: 'About', el: aboutSection, visible: true }, - { id: 'popular', label: 'Popular Tracks', el: topTracksSection, visible: hasTopTracks }, - { id: 'discography', label: 'Discography', el: discographySection, visible: true }, - { id: 'eps', label: 'EPs & Singles', el: epsSinglesSection, visible: hasEpsSingles }, - { id: 'live', label: 'Live Albums', el: liveAlbumsSection, visible: hasLiveAlbums }, - { id: 'compilations', label: 'Compilations', el: compilationsSection, visible: hasCompilations }, - { id: 'others', label: 'Others', el: othersSection, visible: hasOthers }, - { id: 'playlists', label: 'Playlists', el: playlistsSection, visible: hasPlaylists }, - { id: 'tributes', label: 'Tributes', el: tributesSection, visible: hasTributes }, + { id: 'about', label: $t('artist.about'), el: aboutSection, visible: true }, + { id: 'popular', label: $t('artist.popularTracks'), el: topTracksSection, visible: hasTopTracks }, + { id: 'discography', label: $t('artist.discography'), el: discographySection, visible: true }, + { id: 'eps', label: $t('artist.eps'), el: epsSinglesSection, visible: hasEpsSingles }, + { id: 'live', label: $t('artist.liveAlbums'), el: liveAlbumsSection, visible: hasLiveAlbums }, + { id: 'compilations', label: $t('artist.compilations'), el: compilationsSection, visible: hasCompilations }, + { id: 'others', label: $t('artist.others'), el: othersSection, visible: hasOthers }, + { id: 'playlists', label: $t('artist.playlists'), el: playlistsSection, visible: hasPlaylists }, + { id: 'tributes', label: $t('artist.tributes'), el: tributesSection, visible: hasTributes }, ].filter(section => section.visible)); let showJumpNav = $derived(jumpSections.length > 1); @@ -1633,7 +1645,7 @@ @@ -1679,15 +1691,15 @@ {/if} {#if artist.biography?.source} -
Source: {artist.biography.source}
+
{$t('library.source')}: {artist.biography.source}
{/if}
{/if} @@ -1790,7 +1802,7 @@ class:is-favorite={isFavorite} onclick={toggleFavorite} disabled={isFavoriteLoading} - title={isFavorite ? 'Remove from favorites' : 'Add to favorites'} + title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')} > {#if isFavorite} @@ -1805,17 +1817,17 @@ class:glow={radioJustCreated} onclick={() => { radioDropdownOpen = !radioDropdownOpen; }} disabled={isRadioLoading} - title="Start Artist Radio" + title={$t('actions.radio.startArtistRadio')} > {#if radioDropdownOpen && !isRadioLoading} @@ -1831,7 +1843,7 @@ class="network-btn" class:active={showNetworkSidebar} onclick={handleToggleNetworkSidebar} - title="Artist Network" + title={$t('actions.artistNetwork')} > Network @@ -1847,7 +1859,7 @@ class:active={showHideDropdown} class:is-hidden={artistIsBlacklisted} onclick={() => showHideDropdown = !showHideDropdown} - title={artistIsBlacklisted ? 'Artist is hidden' : 'Hide artist options'} + title={artistIsBlacklisted ? $t('actions.hide.hiddenArtist') : $t('actions.hide.hideArtistOptions')} > @@ -1860,21 +1872,21 @@ >
{#if artistIsBlacklisted} - Show this artist + {$t('actions.hide.showArtist')} {:else} - Hide this artist + {$t('actions.hide.hideArtist')} {/if}

{#if artistIsBlacklisted} - Show this artist in searches, playlists, discover, etc. + {$t('artist.artistIsBlacklisted')} {:else} - Don't show this artist in searches, playlists, discover, etc. + {$t('artist.artistIsNotBlacklisted')} {/if}

- Blacklist can be managed from settings. + {$t('artist.manageBlacklistFromSettings')}

@@ -1891,9 +1903,9 @@ {#if contentFilteringEnabled && artistIsBlacklisted}
- This artist is hidden. Their music won't appear in search, radio, or suggestions. + {$t('artist.blacklistBanner')}
{/if} @@ -1901,7 +1913,7 @@ {#if showJumpNav}
-
Jump to
+
{$t('artist.jumpTo')}
{:else} - {/if} @@ -1981,11 +1993,11 @@
-

Popular Tracks

+

{$t('artist.popularTracks')}

{#if topTracks.length > 0}
-
{#if showTracksContextMenu}
showTracksContextMenu = false} role="presentation">
{/if} @@ -2027,7 +2039,7 @@
{#if tracksLoading} -
Loading tracks...
+
{$t('toast.loadingTracks')}
{:else}
{#each visibleTracks as track, index} @@ -2164,7 +2176,7 @@
{#if canLoadMoreTracks} {/if}
-

Discography

+

{$t('artist.discography')}

{#if artist.albums.length > 0} {artist.albums.length} {/if} @@ -2192,11 +2204,11 @@
{/if} @@ -2244,7 +2256,7 @@
{#if artist.albums.length === 0} -
No albums found
+
{$t('library.noAlbumsFound')}
{:else}
{#each filteredAlbums as album} @@ -2276,7 +2288,7 @@ {#if artist.releaseHasMore?.album && onLoadMoreReleases}
{/if} @@ -2290,28 +2302,28 @@
-

EPs & Singles

+

{$t('artist.epsSingles')}

{artist.epsSingles.length}
{#if showEpsSinglesSortMenu}
- - - - - + + + + +
{/if}
@@ -2346,7 +2358,7 @@ {#if artist.releaseHasMore?.ep && onLoadMoreReleases}
{/if} @@ -2359,28 +2371,28 @@
-

Live Albums

+

{$t('artist.liveAlbums')}

{artist.liveAlbums.length}
{#if showLiveAlbumsSortMenu}
- - - - - + + + + +
{/if}
@@ -2415,7 +2427,7 @@ {#if artist.releaseHasMore?.live && onLoadMoreReleases}
{/if} @@ -2428,28 +2440,28 @@
-

Compilations

+

{$t('artist.compilations')}

{artist.compilations.length}
{#if showCompilationsSortMenu}
- - - - - + + + + +
{/if}
@@ -2490,28 +2502,28 @@
-

Others

+

{$t('artist.others')}

{artist.others.length}
{#if showOthersSortMenu}
- - - - - + + + + +
{/if}
@@ -2546,7 +2558,7 @@ {#if (artist.releaseHasMore?.compilation || artist.releaseHasMore?.other) && onLoadMoreReleases}
{/if} @@ -2559,7 +2571,7 @@
-

Playlists

+

{$t('artist.playlists')}

{artist.playlists.length}
@@ -2585,7 +2597,7 @@
{playlist.title}
{#if playlist.trackCount} - {playlist.trackCount} tracks + {playlist.trackCount} {$t('playlist.tracks')} {:else} Playlist {/if} @@ -2606,7 +2618,7 @@
-

Tributes & Covers

+

{$t('artist.tributes')}

{artist.tributes.length} {#if showTributesSortMenu}
- - - - - + + + + +
{/if}
@@ -2678,7 +2690,7 @@ {#if canLoadMoreTributes}
{/if} @@ -2713,7 +2725,7 @@ {mbMetadata.artist_type === 'person' ? $t('artist.born') : $t('artist.founded')} - {formatMbDate(mbMetadata.life_span)} + {formatMbDate_v2(mbMetadata.life_span.begin)}
{/if} {#if mbMetadata.location} @@ -2757,13 +2769,21 @@ {/if}
{/if} + {#if mbMetadata.life_span?.end} +
+ + {mbMetadata.artist_type === 'person' ? $t('artist.died') : $t('artist.disbanded')} + + {formatMbDate_v2(mbMetadata.life_span.end)} +
+ {/if} {/if} {/if}
{:else}