chore(qconnect): checkpoint current poc state

cx
This commit is contained in:
vicrodh committed 2026-03-13 17:28:05 -06:00
1 parent 6d20360dc3
commit de29f1e2e0
92 files changed
+3886 -2433

No files matched your search

+124 -46
View File
@@ -8,7 +8,9 @@
//! Uses CPAL's ALSA host with specific device selection. //! Uses CPAL's ALSA host with specific device selection.
//! Device enumeration reads directly from /proc/asound (no alsa-utils dependency). //! Device enumeration reads directly from /proc/asound (no alsa-utils dependency).
use super::backend::{AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult}; use super::backend::{
AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult,
};
use rodio::{ use rodio::{
cpal::{ cpal::{
traits::{DeviceTrait, HostTrait}, traits::{DeviceTrait, HostTrait},
@@ -225,7 +227,11 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
if !device_num.is_empty() { if !device_num.is_empty() {
devices.push(ProcPcmInfo { devices.push(ProcPcmInfo {
device_num, device_num,
name: if pcm_name.is_empty() { "Unknown".to_string() } else { pcm_name }, name: if pcm_name.is_empty() {
"Unknown".to_string()
} else {
pcm_name
},
}); });
} }
} }
@@ -235,7 +241,9 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
// Sort by device number // Sort by device number
devices.sort_by(|a, b| { devices.sort_by(|a, b| {
a.device_num.parse::<u32>().unwrap_or(0) a.device_num
.parse::<u32>()
.unwrap_or(0)
.cmp(&b.device_num.parse::<u32>().unwrap_or(0)) .cmp(&b.device_num.parse::<u32>().unwrap_or(0))
}); });
@@ -257,7 +265,8 @@ fn build_card_info_map() -> HashMap<String, (String, String)> {
/// Find card number by short name (e.g., "C20" -> "0") /// Find card number by short name (e.g., "C20" -> "0")
fn find_card_number_by_name(short_name: &str) -> Option<String> { fn find_card_number_by_name(short_name: &str) -> Option<String> {
let cards = read_proc_asound_cards(); let cards = read_proc_asound_cards();
cards.iter() cards
.iter()
.find(|c| c.short_name == short_name) .find(|c| c.short_name == short_name)
.map(|c| c.number.clone()) .map(|c| c.number.clone())
} }
@@ -271,10 +280,15 @@ fn extract_card_name_from_device(device_id: &str) -> Option<String> {
Some(after_card.split(',').next()?.to_string()) Some(after_card.split(',').next()?.to_string())
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") { } else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
// hw:0,0 -> card number 0 -> look up short name // hw:0,0 -> card number 0 -> look up short name
let prefix = if device_id.starts_with("hw:") { "hw:" } else { "plughw:" }; let prefix = if device_id.starts_with("hw:") {
"hw:"
} else {
"plughw:"
};
let card_num = device_id.strip_prefix(prefix)?.split(',').next()?; let card_num = device_id.strip_prefix(prefix)?.split(',').next()?;
let cards = read_proc_asound_cards(); let cards = read_proc_asound_cards();
cards.iter() cards
.iter()
.find(|c| c.number == card_num) .find(|c| c.number == card_num)
.map(|c| c.short_name.clone()) .map(|c| c.short_name.clone())
} else { } else {
@@ -337,7 +351,10 @@ impl AlsaBackend {
// Check if ALSA is available // Check if ALSA is available
// cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!) // cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!)
if !available_hosts.iter().any(|h| h.name().eq_ignore_ascii_case("alsa")) { if !available_hosts
.iter()
.any(|h| h.name().eq_ignore_ascii_case("alsa"))
{
return Err("ALSA host not available on this system".to_string()); return Err("ALSA host not available on this system".to_string());
} }
@@ -346,8 +363,9 @@ impl AlsaBackend {
available_hosts available_hosts
.into_iter() .into_iter()
.find(|h| h.name().eq_ignore_ascii_case("alsa")) .find(|h| h.name().eq_ignore_ascii_case("alsa"))
.ok_or("ALSA host not found".to_string())? .ok_or("ALSA host not found".to_string())?,
).map_err(|e| format!("Failed to create ALSA host: {}", e))?; )
.map_err(|e| format!("Failed to create ALSA host: {}", e))?;
log::info!("[ALSA Backend] Initialized successfully"); log::info!("[ALSA Backend] Initialized successfully");
@@ -373,24 +391,32 @@ impl AlsaBackend {
for card in &cards { for card in &cards {
log::debug!( log::debug!(
"[ALSA Backend] Card {}: {} = {} ({} PCM devices)", "[ALSA Backend] Card {}: {} = {} ({} PCM devices)",
card.number, card.short_name, card.long_name, card.pcm_playback_devices.len() card.number,
card.short_name,
card.long_name,
card.pcm_playback_devices.len()
); );
} }
// Build CPAL device map for sample rate enrichment (OPTIONAL - may be incomplete) // Build CPAL device map for sample rate enrichment (OPTIONAL - may be incomplete)
let cpal_devices = self.build_cpal_device_map(); let cpal_devices = self.build_cpal_device_map();
log::debug!("[ALSA Backend] CPAL found {} devices for enrichment", cpal_devices.len()); log::debug!(
"[ALSA Backend] CPAL found {} devices for enrichment",
cpal_devices.len()
);
// Add system default device // Add system default device
let default_sample_rates = cpal_devices.get("default") let default_sample_rates = cpal_devices
.get("default")
.and_then(|d| get_supported_sample_rates(d)); .and_then(|d| get_supported_sample_rates(d));
let default_max_rate = default_sample_rates.as_ref() let default_max_rate = default_sample_rates
.as_ref()
.and_then(|rates| rates.iter().max().copied()); .and_then(|rates| rates.iter().max().copied());
devices.push(AudioDevice { devices.push(AudioDevice {
id: "default".to_string(), id: "default".to_string(),
name: "default".to_string(), name: "default".to_string(),
description: None, // Frontend shows "System Default" description: None, // Frontend shows "System Default"
is_default: true, is_default: true,
max_sample_rate: default_max_rate.or(Some(384000)), max_sample_rate: default_max_rate.or(Some(384000)),
supported_sample_rates: default_sample_rates, supported_sample_rates: default_sample_rates,
@@ -402,7 +428,8 @@ impl AlsaBackend {
for card in &cards { for card in &cards {
// Add sysdefault:CARD=name (card default with software mixing) // Add sysdefault:CARD=name (card default with software mixing)
let sysdefault_id = format!("sysdefault:CARD={}", card.short_name); let sysdefault_id = format!("sysdefault:CARD={}", card.short_name);
let sysdefault_rates = cpal_devices.get(&sysdefault_id) let sysdefault_rates = cpal_devices
.get(&sysdefault_id)
.and_then(|d| get_supported_sample_rates(d)); .and_then(|d| get_supported_sample_rates(d));
devices.push(AudioDevice { devices.push(AudioDevice {
@@ -410,12 +437,13 @@ impl AlsaBackend {
name: sysdefault_id.clone(), name: sysdefault_id.clone(),
description: Some(format!("{}, {}", card.long_name, sysdefault_id)), description: Some(format!("{}, {}", card.long_name, sysdefault_id)),
is_default: false, is_default: false,
max_sample_rate: sysdefault_rates.as_ref() max_sample_rate: sysdefault_rates
.as_ref()
.and_then(|r| r.iter().max().copied()) .and_then(|r| r.iter().max().copied())
.or(Some(192000)), .or(Some(192000)),
supported_sample_rates: sysdefault_rates, supported_sample_rates: sysdefault_rates,
device_bus: None, device_bus: None,
is_hardware: false, // sysdefault uses dmix is_hardware: false, // sysdefault uses dmix
}); });
// Add PCM-specific devices (front:, iec958:, hdmi:) // Add PCM-specific devices (front:, iec958:, hdmi:)
@@ -424,14 +452,18 @@ impl AlsaBackend {
let device_prefix = if pcm.name.to_lowercase().contains("hdmi") { let device_prefix = if pcm.name.to_lowercase().contains("hdmi") {
"hdmi" "hdmi"
} else if pcm.name.to_lowercase().contains("iec958") } else if pcm.name.to_lowercase().contains("iec958")
|| pcm.name.to_lowercase().contains("spdif") || pcm.name.to_lowercase().contains("spdif")
|| pcm.name.to_lowercase().contains("s/pdif") { || pcm.name.to_lowercase().contains("s/pdif")
{
"iec958" "iec958"
} else { } else {
"front" // Default to front: for analog/USB audio "front" // Default to front: for analog/USB audio
}; };
let device_id = format!("{}:CARD={},DEV={}", device_prefix, card.short_name, pcm.device_num); let device_id = format!(
"{}:CARD={},DEV={}",
device_prefix, card.short_name, pcm.device_num
);
// Skip if already added (shouldn't happen, but be safe) // Skip if already added (shouldn't happen, but be safe)
if devices.iter().any(|d| d.id == device_id) { if devices.iter().any(|d| d.id == device_id) {
@@ -439,11 +471,13 @@ impl AlsaBackend {
} }
// Try to get sample rates from CPAL (may fail if device is busy) // Try to get sample rates from CPAL (may fail if device is busy)
let sample_rates = cpal_devices.get(&device_id) let sample_rates = cpal_devices
.get(&device_id)
.and_then(|d| get_supported_sample_rates(d)); .and_then(|d| get_supported_sample_rates(d));
let max_rate = sample_rates.as_ref() let max_rate = sample_rates
.as_ref()
.and_then(|r| r.iter().max().copied()) .and_then(|r| r.iter().max().copied())
.or(Some(384000)); // Assume high capability if CPAL unavailable .or(Some(384000)); // Assume high capability if CPAL unavailable
devices.push(AudioDevice { devices.push(AudioDevice {
id: device_id.clone(), id: device_id.clone(),
@@ -504,7 +538,10 @@ impl AlsaBackend {
// Only use direct ALSA for hw:/plughw:/front: devices // Only use direct ALSA for hw:/plughw:/front: devices
if !super::AlsaDirectStream::is_hw_device(device_id) { if !super::AlsaDirectStream::is_hw_device(device_id) {
log::info!("[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL", device_id); log::info!(
"[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL",
device_id
);
return None; return None;
} }
@@ -538,7 +575,9 @@ impl AlsaBackend {
} }
log::info!( log::info!(
"[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})", "[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})",
config.sample_rate, card_name, hw_rates config.sample_rate,
card_name,
hw_rates
); );
} }
} }
@@ -583,23 +622,38 @@ impl AlsaBackend {
log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release..."); log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release...");
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
match super::AlsaDirectStream::new(&hw_device, config.sample_rate, config.channels) { match super::AlsaDirectStream::new(
&hw_device,
config.sample_rate,
config.channels,
) {
Ok(stream) => { Ok(stream) => {
log::info!("[ALSA Backend] ✓ Direct hw stream created after PipeWire suspend"); log::info!("[ALSA Backend] ✓ Direct hw stream created after PipeWire suspend");
return Some(Ok((stream, super::backend::BitPerfectMode::DirectHardware))); return Some(Ok((
stream,
super::backend::BitPerfectMode::DirectHardware,
)));
} }
Err(e2) => { Err(e2) => {
log::warn!("[ALSA Backend] Retry after suspend also failed: {}", e2); log::warn!(
"[ALSA Backend] Retry after suspend also failed: {}",
e2
);
} }
} }
} else { } else {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr); log::warn!(
"[ALSA Backend] Failed to suspend PipeWire sink: {}",
stderr
);
} }
} }
// If retry failed or suspend failed, fall through to error // If retry failed or suspend failed, fall through to error
log::error!("[ALSA Backend] Cannot acquire device even after PipeWire suspend"); log::error!(
"[ALSA Backend] Cannot acquire device even after PipeWire suspend"
);
return Some(Err(format!( return Some(Err(format!(
"ALSA Direct failed: {}. Device may be in use or inaccessible.", "ALSA Direct failed: {}. Device may be in use or inaccessible.",
error error
@@ -629,7 +683,9 @@ impl AlsaBackend {
))); )));
} }
log::info!("[ALSA Backend] Format unsupported on hw, trying plughw fallback..."); log::info!(
"[ALSA Backend] Format unsupported on hw, trying plughw fallback..."
);
} }
} }
} }
@@ -644,7 +700,9 @@ impl AlsaBackend {
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) { match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
Ok(stream) => { Ok(stream) => {
log::info!("[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)"); log::info!(
"[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)"
);
Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback))) Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
} }
Err(e) => { Err(e) => {
@@ -657,16 +715,28 @@ impl AlsaBackend {
.output() .output()
{ {
if output.status.success() { if output.status.success() {
log::info!("[ALSA Backend] PipeWire sink suspended, retrying plughw..."); log::info!(
"[ALSA Backend] PipeWire sink suspended, retrying plughw..."
);
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) { match super::AlsaDirectStream::new(
&plughw_device,
config.sample_rate,
config.channels,
) {
Ok(stream) => { Ok(stream) => {
log::info!("[ALSA Backend] ✓ plughw stream created after PipeWire suspend"); log::info!("[ALSA Backend] ✓ plughw stream created after PipeWire suspend");
return Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback))); return Some(Ok((
stream,
super::backend::BitPerfectMode::PluginFallback,
)));
} }
Err(e2) => { Err(e2) => {
log::error!("[ALSA Backend] plughw retry after suspend also failed: {}", e2); log::error!(
"[ALSA Backend] plughw retry after suspend also failed: {}",
e2
);
} }
} }
} }
@@ -758,7 +828,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
let stripped = device_id.strip_prefix("front:CARD=")?; let stripped = device_id.strip_prefix("front:CARD=")?;
let parts: Vec<&str> = stripped.split(',').collect(); let parts: Vec<&str> = stripped.split(',').collect();
let card_name = parts.first()?; let card_name = parts.first()?;
let dev_part = parts.get(1).and_then(|s| s.strip_prefix("DEV=")).unwrap_or("0"); let dev_part = parts
.get(1)
.and_then(|s| s.strip_prefix("DEV="))
.unwrap_or("0");
// Find current card number for this name using /proc/asound // Find current card number for this name using /proc/asound
if let Some(card_num) = find_card_number_by_name(card_name) { if let Some(card_num) = find_card_number_by_name(card_name) {
@@ -767,7 +840,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
return Some(hw_id); return Some(hw_id);
} }
log::warn!("[ALSA] Card '{}' not found in current enumeration", card_name); log::warn!(
"[ALSA] Card '{}' not found in current enumeration",
card_name
);
None None
} }
@@ -796,10 +872,7 @@ impl AudioBackend for AlsaBackend {
self.enumerate_with_proc_descriptions() self.enumerate_with_proc_descriptions()
} }
fn create_output_stream( fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
log::info!( log::info!(
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}", "[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
config.sample_rate, config.sample_rate,
@@ -898,7 +971,9 @@ impl AudioBackend for AlsaBackend {
// In exclusive mode, PipeWire may have re-acquired the device after the // In exclusive mode, PipeWire may have re-acquired the device after the
// previous ALSA Direct stream released it. Suspend PipeWire before opening. // previous ALSA Direct stream released it. Suspend PipeWire before opening.
if config.exclusive_mode { if config.exclusive_mode {
log::info!("[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream"); log::info!(
"[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream"
);
if let Ok(output) = std::process::Command::new("pactl") if let Ok(output) = std::process::Command::new("pactl")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"]) .args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output() .output()
@@ -941,12 +1016,15 @@ impl AudioBackend for AlsaBackend {
if effective_rate != config.sample_rate { if effective_rate != config.sample_rate {
log::info!( log::info!(
"[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})", "[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})",
effective_rate, config.sample_rate, config.exclusive_mode effective_rate,
config.sample_rate,
config.exclusive_mode
); );
} else { } else {
log::info!( log::info!(
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})", "[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
config.sample_rate, config.exclusive_mode config.sample_rate,
config.exclusive_mode
); );
} }
+107 -58
View File
@@ -38,8 +38,8 @@ impl AlsaDirectStream {
// Set hardware parameters and auto-detect best format // Set hardware parameters and auto-detect best format
let selected_format = { let selected_format = {
let hwp = HwParams::any(&pcm) let hwp =
.map_err(|e| format!("Failed to get hardware params: {}", e))?; HwParams::any(&pcm).map_err(|e| format!("Failed to get hardware params: {}", e))?;
// Set access type (interleaved) // Set access type (interleaved)
hwp.set_access(Access::RWInterleaved) hwp.set_access(Access::RWInterleaved)
@@ -49,10 +49,10 @@ impl AlsaDirectStream {
// S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip) // S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip)
// Then descending bit-depth for quality // Then descending bit-depth for quality
let format_priority = [ let format_priority = [
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs) (Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
(Format::S32LE, "S32LE"), // 32-bit (Format::S32LE, "S32LE"), // 32-bit
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container (Format::S24LE, "S24LE"), // 24-bit in 32-bit container
(Format::S16LE, "S16LE"), // 16-bit (Format::S16LE, "S16LE"), // 16-bit
(Format::FloatLE, "Float32LE"), // Float (compatibility) (Format::FloatLE, "Float32LE"), // Float (compatibility)
]; ];
@@ -65,8 +65,10 @@ impl AlsaDirectStream {
} }
} }
let format = selected_format let format = selected_format.ok_or_else(|| {
.ok_or_else(|| "No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)".to_string())?; "No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)"
.to_string()
})?;
// Set channels // Set channels
hwp.set_channels(channels as u32) hwp.set_channels(channels as u32)
@@ -99,8 +101,13 @@ impl AlsaDirectStream {
pcm.hw_params(&hwp) pcm.hw_params(&hwp)
.map_err(|e| format!("Failed to apply hardware params: {}", e))?; .map_err(|e| format!("Failed to apply hardware params: {}", e))?;
log::info!("[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}", log::info!(
sample_rate, channels, buffer_size, format); "[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
sample_rate,
channels,
buffer_size,
format
);
format format
}; };
@@ -128,18 +135,21 @@ impl AlsaDirectStream {
match self.format { match self.format {
Format::FloatLE => { Format::FloatLE => {
// Convert i16 to f32 // Convert i16 to f32
let samples_f32: Vec<f32> = samples_i16 let samples_f32: Vec<f32> =
.iter() samples_i16.iter().map(|&s| s as f32 / 32768.0).collect();
.map(|&s| s as f32 / 32768.0)
.collect();
let io = pcm.io_f32() let io = pcm
.io_f32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_f32) { match io.writei(&samples_f32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -155,18 +165,20 @@ impl AlsaDirectStream {
} }
Format::S32LE => { Format::S32LE => {
// Convert i16 to i32 (bit-perfect: shift left 16 bits) // Convert i16 to i32 (bit-perfect: shift left 16 bits)
let samples_i32: Vec<i32> = samples_i16 let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let io = pcm.io_i32() let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) { match io.writei(&samples_i32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -182,13 +194,18 @@ impl AlsaDirectStream {
} }
Format::S16LE => { Format::S16LE => {
// Direct write (no conversion needed) // Direct write (no conversion needed)
let io = pcm.io_i16() let io = pcm
.io_i16()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(samples_i16) { match io.writei(samples_i16) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -212,8 +229,8 @@ impl AlsaDirectStream {
// Convert i16 to i24 (lossless: zeros in lower 8 bits) // Convert i16 to i24 (lossless: zeros in lower 8 bits)
let s24 = (sample as i32) << 8; let s24 = (sample as i32) << 8;
// Pack as 3 bytes in little-endian order // Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended) bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
} }
@@ -223,7 +240,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) { match io.writei(&bytes) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -240,18 +261,20 @@ impl AlsaDirectStream {
Format::S24LE => { Format::S24LE => {
// S24LE: 24-bit in 32-bit container (padded) // S24LE: 24-bit in 32-bit container (padded)
// Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source) // Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source)
let samples_i32: Vec<i32> = samples_i16 let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let io = pcm.io_i32() let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) { match io.writei(&samples_i32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -265,9 +288,7 @@ impl AlsaDirectStream {
} }
} }
} }
_ => { _ => Err(format!("Unsupported format: {:?}", self.format)),
Err(format!("Unsupported format: {:?}", self.format))
}
} }
} }
@@ -283,13 +304,18 @@ impl AlsaDirectStream {
match self.format { match self.format {
Format::FloatLE => { Format::FloatLE => {
// Direct write - no conversion needed // Direct write - no conversion needed
let io = pcm.io_f32() let io = pcm
.io_f32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(samples_f32) { match io.writei(samples_f32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -310,13 +336,18 @@ impl AlsaDirectStream {
.map(|&s| (s * 2_147_483_647.0) as i32) .map(|&s| (s * 2_147_483_647.0) as i32)
.collect(); .collect();
let io = pcm.io_i32() let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) { match io.writei(&samples_i32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -341,13 +372,18 @@ impl AlsaDirectStream {
}) })
.collect(); .collect();
let io = pcm.io_i32() let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) { match io.writei(&samples_i32) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -370,8 +406,8 @@ impl AlsaDirectStream {
let scaled = sample * 8_388_607.0; let scaled = sample * 8_388_607.0;
let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32; let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32;
// Pack as 3 bytes in little-endian order // Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended) bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
} }
@@ -380,7 +416,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) { match io.writei(&bytes) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -396,18 +436,21 @@ impl AlsaDirectStream {
} }
Format::S16LE => { Format::S16LE => {
// f32 -> i16 // f32 -> i16
let samples_i16: Vec<i16> = samples_f32 let samples_i16: Vec<i16> =
.iter() samples_f32.iter().map(|&s| (s * 32_767.0) as i16).collect();
.map(|&s| (s * 32_767.0) as i16)
.collect();
let io = pcm.io_i16() let io = pcm
.io_i16()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?; .map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i16) { match io.writei(&samples_i16) {
Ok(written) => { Ok(written) => {
if written != frames { if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames); log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
} }
Ok(()) Ok(())
} }
@@ -421,9 +464,7 @@ impl AlsaDirectStream {
} }
} }
} }
_ => { _ => Err(format!("Unsupported format: {:?}", self.format)),
Err(format!("Unsupported format: {:?}", self.format))
}
} }
} }
@@ -471,8 +512,8 @@ impl AlsaDirectStream {
/// NOTE: Failure doesn't break playback, just means volume can't be controlled. /// NOTE: Failure doesn't break playback, just means volume can't be controlled.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> { pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
use alsa::mixer::{Mixer, SelemId};
use alsa::mixer::SelemChannelId::*; use alsa::mixer::SelemChannelId::*;
use alsa::mixer::{Mixer, SelemId};
// Open mixer for device // Open mixer for device
let mixer = Mixer::new(&self.device_id, false) let mixer = Mixer::new(&self.device_id, false)
@@ -491,8 +532,13 @@ impl AlsaDirectStream {
let (min, max) = selem.get_playback_volume_range(); let (min, max) = selem.get_playback_volume_range();
let target = min + ((max - min) as f32 * volume) as i64; let target = min + ((max - min) as f32 * volume) as i64;
log::info!("[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})", log::info!(
name, volume * 100.0, target, max); "[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
name,
volume * 100.0,
target,
max
);
// Set volume on all channels // Set volume on all channels
for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] { for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] {
@@ -504,7 +550,10 @@ impl AlsaDirectStream {
} }
} }
Err(format!("No volume control found for {}. DAC may not support hardware mixer.", self.device_id)) Err(format!(
"No volume control found for {}. DAC may not support hardware mixer.",
self.device_id
))
} }
/// Check if device is a bit-perfect hardware device /// Check if device is a bit-perfect hardware device
+1 -4
View File
@@ -217,10 +217,7 @@ pub trait AudioBackend: Send + Sync {
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>; fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>;
/// Create an output stream for the given configuration /// Create an output stream for the given configuration
fn create_output_stream( fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink>;
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink>;
/// Check if this backend is available on the current system /// Check if this backend is available on the current system
fn is_available(&self) -> bool; fn is_available(&self) -> bool;
+5 -1
View File
@@ -78,7 +78,11 @@ impl AudioDiagnostic {
let sample_rate = self.sample_rate.load(Ordering::SeqCst); let sample_rate = self.sample_rate.load(Ordering::SeqCst);
let channels = self.channels.load(Ordering::SeqCst); let channels = self.channels.load(Ordering::SeqCst);
let trailing_zeros = if or_mask == 0 { 32 } else { or_mask.trailing_zeros() }; let trailing_zeros = if or_mask == 0 {
32
} else {
or_mask.trailing_zeros()
};
let effective_bits = 32 - trailing_zeros; let effective_bits = 32 - trailing_zeros;
let frames = if channels > 0 { let frames = if channels > 0 {
+23 -27
View File
@@ -26,41 +26,37 @@
//! └───────────────┘ //! └───────────────┘
//! ``` //! ```
pub mod backend;
pub mod pipewire_backend;
pub mod alsa_backend; pub mod alsa_backend;
pub mod pulse_backend;
pub mod alsa_direct; pub mod alsa_direct;
pub mod diagnostic; pub mod analysis;
pub mod loudness;
pub mod dynamic_amplify;
pub mod analyzer_tap; pub mod analyzer_tap;
pub mod loudness_cache; pub mod backend;
pub mod diagnostic;
pub mod dynamic_amplify;
pub mod loudness;
pub mod loudness_analyzer; pub mod loudness_analyzer;
pub mod loudness_cache;
pub mod pipewire_backend;
pub mod pulse_backend;
pub mod settings; pub mod settings;
pub mod visualizer; pub mod visualizer;
pub mod analysis;
// Re-export commonly used types // Re-export commonly used types
pub use backend::{ pub use alsa_backend::{
AlsaPlugin, device_supports_sample_rate, get_device_supported_rates, normalize_device_id_to_stable,
AudioBackend, resolve_stable_to_current_hw,
AudioBackendType,
AudioDevice,
BackendConfig,
BackendManager,
BackendResult,
AlsaDirectError,
BitPerfectMode,
}; };
pub use alsa_direct::AlsaDirectStream; pub use alsa_direct::AlsaDirectStream;
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw, device_supports_sample_rate, get_device_supported_rates};
pub use diagnostic::{AudioDiagnostic, DiagnosticSource, BitDepthResult};
pub use loudness::{ReplayGainData, extract_replaygain, calculate_gain_factor, db_to_linear};
pub use dynamic_amplify::DynamicAmplify;
pub use analyzer_tap::{AnalyzerTap, AnalyzerMessage};
pub use loudness_cache::LoudnessCache;
pub use loudness_analyzer::LoudnessAnalyzer;
pub use settings::AudioSettings;
pub use visualizer::{VisualizerTap, TappedSource, RingBuffer};
pub use analysis::SpectralAnalyzer; pub use analysis::SpectralAnalyzer;
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap};
pub use backend::{
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
BackendManager, BackendResult, BitPerfectMode,
};
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
pub use dynamic_amplify::DynamicAmplify;
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
pub use loudness_analyzer::LoudnessAnalyzer;
pub use loudness_cache::LoudnessCache;
pub use settings::AudioSettings;
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
+58 -17
View File
@@ -4,13 +4,13 @@
//! for volume normalization. When normalization is disabled, this module is //! for volume normalization. When normalization is disabled, this module is
//! not invoked and the audio pipeline remains bit-perfect. //! not invoked and the audio pipeline remains bit-perfect.
use symphonia::core::formats::FormatReader; use std::io::{Cursor, Read, Seek, SeekFrom};
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use symphonia::core::formats::FormatOptions; use symphonia::core::formats::FormatOptions;
use symphonia::core::formats::FormatReader;
use symphonia::core::io::{MediaSource, MediaSourceStream}; use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use symphonia::core::probe::Hint; use symphonia::core::probe::Hint;
use symphonia::default::get_probe; use symphonia::default::get_probe;
use std::io::{Cursor, Read, Seek, SeekFrom};
/// Extracted loudness data for a track /// Extracted loudness data for a track
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -28,7 +28,9 @@ struct CursorMediaSource {
impl CursorMediaSource { impl CursorMediaSource {
fn new(data: Vec<u8>) -> Self { fn new(data: Vec<u8>) -> Self {
Self { inner: Cursor::new(data) } Self {
inner: Cursor::new(data),
}
} }
} }
@@ -45,7 +47,9 @@ impl Seek for CursorMediaSource {
} }
impl MediaSource for CursorMediaSource { impl MediaSource for CursorMediaSource {
fn is_seekable(&self) -> bool { true } fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<u64> { fn byte_len(&self) -> Option<u64> {
Some(self.inner.get_ref().len() as u64) Some(self.inner.get_ref().len() as u64)
} }
@@ -122,7 +126,11 @@ pub fn extract_replaygain_from_reader(format: &mut dyn FormatReader) -> Option<R
} }
gain_db.map(|db| { gain_db.map(|db| {
log::info!("Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}", db, peak); log::info!(
"Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}",
db,
peak
);
ReplayGainData { gain_db: db, peak } ReplayGainData { gain_db: db, peak }
}) })
} }
@@ -174,7 +182,11 @@ fn extract_from_tags(tags: &[Tag], gain_db: &mut Option<f32>, peak: &mut Option<
fn parse_gain_value(value: &Value) -> Option<f32> { fn parse_gain_value(value: &Value) -> Option<f32> {
let s = value_to_string(value)?; let s = value_to_string(value)?;
// Strip " dB" suffix if present, then parse // Strip " dB" suffix if present, then parse
let trimmed = s.trim().trim_end_matches(" dB").trim_end_matches(" db").trim_end_matches("dB"); let trimmed = s
.trim()
.trim_end_matches(" dB")
.trim_end_matches(" db")
.trim_end_matches("dB");
trimmed.parse::<f32>().ok() trimmed.parse::<f32>().ok()
} }
@@ -240,7 +252,9 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
if gain > max_safe_gain { if gain > max_safe_gain {
log::debug!( log::debug!(
"Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})", "Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})",
gain, max_safe_gain, peak gain,
max_safe_gain,
peak
); );
gain = max_safe_gain; gain = max_safe_gain;
} }
@@ -258,7 +272,10 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
log::debug!( log::debug!(
"Loudness: gain_db={:.2}, target={:.1} LUFS, adjusted={:.2} dB, factor={:.4}", "Loudness: gain_db={:.2}, target={:.1} LUFS, adjusted={:.2} dB, factor={:.4}",
rg.gain_db, target_lufs, adjusted_gain_db, gain rg.gain_db,
target_lufs,
adjusted_gain_db,
gain
); );
gain gain
@@ -283,7 +300,10 @@ mod tests {
#[test] #[test]
fn test_calculate_gain_factor_at_reference() { fn test_calculate_gain_factor_at_reference() {
// At -18 LUFS target (ReplayGain reference), gain_db should pass through directly // At -18 LUFS target (ReplayGain reference), gain_db should pass through directly
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.9) }; let rg = ReplayGainData {
gain_db: -3.0,
peak: Some(0.9),
};
let factor = calculate_gain_factor(&rg, -18.0); let factor = calculate_gain_factor(&rg, -18.0);
// -3 dB → ~0.708 // -3 dB → ~0.708
assert!((factor - 0.708).abs() < 0.01); assert!((factor - 0.708).abs() < 0.01);
@@ -292,7 +312,10 @@ mod tests {
#[test] #[test]
fn test_calculate_gain_factor_with_target_adjustment() { fn test_calculate_gain_factor_with_target_adjustment() {
// At -14 LUFS target, we add +4 dB to the RG gain // At -14 LUFS target, we add +4 dB to the RG gain
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.5) }; let rg = ReplayGainData {
gain_db: -3.0,
peak: Some(0.5),
};
let factor = calculate_gain_factor(&rg, -14.0); let factor = calculate_gain_factor(&rg, -14.0);
// -3 + 4 = +1 dB → ~1.122 // -3 + 4 = +1 dB → ~1.122
assert!((factor - 1.122).abs() < 0.01); assert!((factor - 1.122).abs() < 0.01);
@@ -301,7 +324,10 @@ mod tests {
#[test] #[test]
fn test_clipping_prevention_with_peak() { fn test_clipping_prevention_with_peak() {
// High positive gain but peak close to 1.0 — should be capped // High positive gain but peak close to 1.0 — should be capped
let rg = ReplayGainData { gain_db: 10.0, peak: Some(0.95) }; let rg = ReplayGainData {
gain_db: 10.0,
peak: Some(0.95),
};
let factor = calculate_gain_factor(&rg, -18.0); let factor = calculate_gain_factor(&rg, -18.0);
// max_safe_gain = 1/0.95 ≈ 1.053, which is less than db_to_linear(10) ≈ 3.162 // max_safe_gain = 1/0.95 ≈ 1.053, which is less than db_to_linear(10) ≈ 3.162
assert!((factor - (1.0 / 0.95)).abs() < 0.01); assert!((factor - (1.0 / 0.95)).abs() < 0.01);
@@ -310,7 +336,10 @@ mod tests {
#[test] #[test]
fn test_clipping_prevention_without_peak() { fn test_clipping_prevention_without_peak() {
// High gain without peak data — capped at +6 dB // High gain without peak data — capped at +6 dB
let rg = ReplayGainData { gain_db: 12.0, peak: None }; let rg = ReplayGainData {
gain_db: 12.0,
peak: None,
};
let factor = calculate_gain_factor(&rg, -18.0); let factor = calculate_gain_factor(&rg, -18.0);
assert!((factor - db_to_linear(6.0)).abs() < 0.01); assert!((factor - db_to_linear(6.0)).abs() < 0.01);
} }
@@ -318,18 +347,30 @@ mod tests {
#[test] #[test]
fn test_parse_gain_value_formats() { fn test_parse_gain_value_formats() {
// Standard format: "-6.54 dB" // Standard format: "-6.54 dB"
assert!((parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs() < 0.001); assert!(
(parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs()
< 0.001
);
// Without dB suffix // Without dB suffix
assert!((parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs() < 0.001); assert!(
(parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs()
< 0.001
);
// Positive // Positive
assert!((parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs() < 0.001); assert!(
(parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs()
< 0.001
);
// Float value // Float value
assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001); assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001);
} }
#[test] #[test]
fn test_parse_peak_value() { fn test_parse_peak_value() {
assert!((parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs() < 0.0001); assert!(
(parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs()
< 0.0001
);
assert!((parse_peak_value(&Value::Float(0.95)).unwrap() - 0.95).abs() < 0.001); assert!((parse_peak_value(&Value::Float(0.95)).unwrap() - 0.95).abs() < 0.001);
} }
} }
+24 -7
View File
@@ -16,13 +16,12 @@ use std::thread;
use ebur128::{EbuR128, Mode}; use ebur128::{EbuR128, Mode};
use super::analyzer_tap::AnalyzerMessage; use super::analyzer_tap::AnalyzerMessage;
use super::loudness_cache::LoudnessCache;
use super::loudness::db_to_linear; use super::loudness::db_to_linear;
use super::loudness_cache::LoudnessCache;
/// Maximum gain boost in dB (conservative clipping prevention) /// Maximum gain boost in dB (conservative clipping prevention)
const MAX_GAIN_DB: f32 = 6.0; const MAX_GAIN_DB: f32 = 6.0;
pub struct LoudnessAnalyzer; pub struct LoudnessAnalyzer;
impl LoudnessAnalyzer { impl LoudnessAnalyzer {
@@ -56,10 +55,19 @@ impl LoudnessAnalyzer {
}; };
match msg { match msg {
AnalyzerMessage::NewTrack { track_id, sample_rate, channels, target_lufs, gain_atomic } => { AnalyzerMessage::NewTrack {
track_id,
sample_rate,
channels,
target_lufs,
gain_atomic,
} => {
log::info!( log::info!(
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)", "[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
track_id, sample_rate, channels, target_lufs track_id,
sample_rate,
channels,
target_lufs
); );
// Check cache first // Check cache first
@@ -74,7 +82,8 @@ impl LoudnessAnalyzer {
gain_atomic.store(gain.to_bits(), Ordering::Relaxed); gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
// Create state marked as cached — still accept samples for refinement // Create state marked as cached — still accept samples for refinement
let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs); let mut s =
AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
s.gain_atomic = Some(gain_atomic); s.gain_atomic = Some(gain_atomic);
s.initial_done = true; s.initial_done = true;
state = Some(s); state = Some(s);
@@ -197,7 +206,11 @@ impl AnalyzerState {
// -inf means silence — don't adjust // -inf means silence — don't adjust
if loudness.is_infinite() || loudness.is_nan() { if loudness.is_infinite() || loudness.is_nan() {
log::debug!("[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping", self.track_id, loudness); log::debug!(
"[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping",
self.track_id,
loudness
);
return; return;
} }
@@ -205,7 +218,11 @@ impl AnalyzerState {
let adjustment_db = self.target_lufs - measured_lufs; let adjustment_db = self.target_lufs - measured_lufs;
let gain = compute_gain_capped(adjustment_db); let gain = compute_gain_capped(adjustment_db);
let phase = if self.initial_done { "refine" } else { "initial" }; let phase = if self.initial_done {
"refine"
} else {
"initial"
};
log::info!( log::info!(
"[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}", "[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}",
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
+68 -21
View File
@@ -157,7 +157,9 @@ impl PipeWireBackend {
.ok() .ok()
.and_then(|o| { .and_then(|o| {
if o.status.success() { if o.status.success() {
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else { } else {
None None
} }
@@ -207,7 +209,8 @@ impl PipeWireBackend {
} else if line.starts_with("Name:") { } else if line.starts_with("Name:") {
current_name = Some(line.trim_start_matches("Name:").trim().to_string()); current_name = Some(line.trim_start_matches("Name:").trim().to_string());
} else if line.starts_with("Description:") { } else if line.starts_with("Description:") {
current_description = Some(line.trim_start_matches("Description:").trim().to_string()); current_description =
Some(line.trim_start_matches("Description:").trim().to_string());
} else if line.starts_with("Flags:") { } else if line.starts_with("Flags:") {
// Check for HARDWARE flag // Check for HARDWARE flag
current_is_hardware = line.contains("HARDWARE"); current_is_hardware = line.contains("HARDWARE");
@@ -223,7 +226,10 @@ impl PipeWireBackend {
} }
} else if line.starts_with("device.bus = ") { } else if line.starts_with("device.bus = ") {
// Parse device.bus property (e.g., "usb", "pci", "bluetooth") // Parse device.bus property (e.g., "usb", "pci", "bluetooth")
let bus = line.trim_start_matches("device.bus = ").trim_matches('"').to_string(); let bus = line
.trim_start_matches("device.bus = ")
.trim_matches('"')
.to_string();
current_device_bus = Some(bus); current_device_bus = Some(bus);
} }
} }
@@ -243,10 +249,19 @@ impl PipeWireBackend {
}); });
} }
log::info!("[PipeWire Backend] Enumerated {} devices via pactl", devices.len()); log::info!(
"[PipeWire Backend] Enumerated {} devices via pactl",
devices.len()
);
for (idx, dev) in devices.iter().enumerate() { for (idx, dev) in devices.iter().enumerate() {
log::info!(" [{}] {} (id: {}, bus: {:?}, hw: {})", log::info!(
idx, dev.name, dev.id, dev.device_bus, dev.is_hardware); " [{}] {} (id: {}, bus: {:?}, hw: {})",
idx,
dev.name,
dev.id,
dev.device_bus,
dev.is_hardware
);
} }
Ok(devices) Ok(devices)
@@ -262,10 +277,7 @@ impl AudioBackend for PipeWireBackend {
self.enumerate_pipewire_sinks() self.enumerate_pipewire_sinks()
} }
fn create_output_stream( fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
let target_sink = config.device_id.clone(); let target_sink = config.device_id.clone();
// Temporarily set default sink to target (if specified) // Temporarily set default sink to target (if specified)
@@ -287,7 +299,10 @@ impl AudioBackend for PipeWireBackend {
log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr); log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr);
} }
Err(e) => { Err(e) => {
log::warn!("[PipeWire Backend] Error executing pactl set-default-sink: {}", e); log::warn!(
"[PipeWire Backend] Error executing pactl set-default-sink: {}",
e
);
} }
} }
@@ -307,7 +322,9 @@ impl AudioBackend for PipeWireBackend {
.ok() .ok()
.and_then(|o| { .and_then(|o| {
if o.status.success() { if o.status.success() {
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) String::from_utf8(o.stdout)
.ok()
.map(|s| s.trim().to_string())
} else { } else {
None None
} }
@@ -319,7 +336,8 @@ impl AudioBackend for PipeWireBackend {
Some(rates) if rates.contains(&config.sample_rate) => { Some(rates) if rates.contains(&config.sample_rate) => {
log::info!( log::info!(
"[PipeWire Backend] DAC supports {}Hz (available: {:?})", "[PipeWire Backend] DAC supports {}Hz (available: {:?})",
config.sample_rate, rates config.sample_rate,
rates
); );
config.sample_rate config.sample_rate
} }
@@ -344,14 +362,26 @@ impl AudioBackend for PipeWireBackend {
}; };
// Force PipeWire to use the effective sample rate (for bit-perfect playback) // Force PipeWire to use the effective sample rate (for bit-perfect playback)
log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", effective_rate); log::info!(
"[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata",
effective_rate
);
let metadata_result = Command::new("pw-metadata") let metadata_result = Command::new("pw-metadata")
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()]) .args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output(); .output();
match metadata_result { match metadata_result {
Ok(output) if output.status.success() => { Ok(output) if output.status.success() => {
log::info!("[PipeWire Backend] Sample rate forced to {}Hz", effective_rate); log::info!(
"[PipeWire Backend] Sample rate forced to {}Hz",
effective_rate
);
} }
Ok(output) => { Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
@@ -486,7 +516,7 @@ impl AudioBackend for PipeWireBackend {
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the // MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
// ONLY buffer between the mixer and audio hardware. // ONLY buffer between the mixer and audio hardware.
let cpal_buffer_size = if config.exclusive_mode { let cpal_buffer_size = if config.exclusive_mode {
BufferSize::Fixed(512) // Low latency for exclusive mode BufferSize::Fixed(512) // Low latency for exclusive mode
} else { } else {
// ~100ms buffer, matching old vendored cpal period size. // ~100ms buffer, matching old vendored cpal period size.
// Prevents underruns at high sample rates (192kHz = 19200 frames). // Prevents underruns at high sample rates (192kHz = 19200 frames).
@@ -500,9 +530,17 @@ impl AudioBackend for PipeWireBackend {
.with_supported_config(&supported_config) .with_supported_config(&supported_config)
.with_buffer_size(cpal_buffer_size) .with_buffer_size(cpal_buffer_size)
.open_stream() .open_stream()
.map_err(|e| format!("Failed to create output stream at {}Hz: {}", effective_rate, e))?; .map_err(|e| {
format!(
"Failed to create output stream at {}Hz: {}",
effective_rate, e
)
})?;
log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", effective_rate); log::info!(
"[PipeWire Backend] Output stream created successfully at {}Hz",
effective_rate
);
// Re-apply clock.force-rate AFTER stream creation. // Re-apply clock.force-rate AFTER stream creation.
// When resuming after PipeWire dropped the stream during pause, // When resuming after PipeWire dropped the stream during pause,
@@ -512,9 +550,18 @@ impl AudioBackend for PipeWireBackend {
// the graph at the correct rate. // the graph at the correct rate.
if effective_rate != 44100 && effective_rate != 48000 { if effective_rate != 44100 && effective_rate != 48000 {
let _ = Command::new("pw-metadata") let _ = Command::new("pw-metadata")
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()]) .args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output(); .output();
log::info!("[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation", effective_rate); log::info!(
"[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation",
effective_rate
);
} }
Ok(mixer_sink) Ok(mixer_sink)
+1 -4
View File
@@ -30,10 +30,7 @@ impl AudioBackend for PulseBackend {
self.inner.enumerate_devices() self.inner.enumerate_devices()
} }
fn create_output_stream( fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
// Delegate to PipeWire backend (same mechanism) // Delegate to PipeWire backend (same mechanism)
self.inner.create_output_stream(config) self.inner.create_output_stream(config)
} }
+2 -5
View File
@@ -402,11 +402,8 @@ impl AudioSettingsStore {
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?; .map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
match json { match json {
Some(s) if !s.is_empty() => { Some(s) if !s.is_empty() => serde_json::from_str(&s)
serde_json::from_str(&s).map_err(|e| { .map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
format!("Failed to parse device sample rate limits: {}", e)
})
}
_ => Ok(HashMap::new()), _ => Ok(HashMap::new()),
} }
} }
+1 -1
View File
@@ -16,8 +16,8 @@ mod tapped_source;
pub use ring_buffer::RingBuffer; pub use ring_buffer::RingBuffer;
pub use tapped_source::TappedSource; pub use tapped_source::TappedSource;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
/// Number of frequency bins to send to frontend /// Number of frequency bins to send to frontend
pub const NUM_BARS: usize = 16; pub const NUM_BARS: usize = 16;
@@ -9,8 +9,8 @@
//! - No allocations //! - No allocations
//! - Minimal atomic operations //! - Minimal atomic operations
use std::sync::atomic::{AtomicUsize, Ordering};
use std::cell::UnsafeCell; use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A lock-free single-producer single-consumer ring buffer /// A lock-free single-producer single-consumer ring buffer
/// ///
@@ -4,9 +4,9 @@
//! without affecting audio playback. The tap is completely transparent to the //! without affecting audio playback. The tap is completely transparent to the
//! audio pipeline. //! audio pipeline.
use rodio::Source;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use rodio::Source;
use super::RingBuffer; use super::RingBuffer;
+2 -1
View File
@@ -146,7 +146,8 @@ impl AudioCache {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
// Evict old entries to make room // Evict old entries to make room
while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty() { while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty()
{
let oldest_id = state.access_order.remove(0); let oldest_id = state.access_order.remove(0);
if let Some(track) = state.tracks.remove(&oldest_id) { if let Some(track) = state.tracks.remove(&oldest_id) {
state.current_size = state.current_size.saturating_sub(track.size_bytes); state.current_size = state.current_size.saturating_sub(track.size_bytes);
+1 -1
View File
@@ -58,5 +58,5 @@
mod audio_cache; mod audio_cache;
mod playback_cache; mod playback_cache;
pub use audio_cache::{AudioCache, CachedTrack, CacheStats}; pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
pub use playback_cache::{PlaybackCache, PlaybackCacheStats}; pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
+12 -5
View File
@@ -94,9 +94,8 @@ impl PlaybackCache {
if let Some(id_str) = filename.strip_suffix(".audio") { if let Some(id_str) = filename.strip_suffix(".audio") {
if let Ok(track_id) = id_str.parse::<u64>() { if let Ok(track_id) = id_str.parse::<u64>() {
let size = metadata.len(); let size = metadata.len();
let last_accessed = metadata let last_accessed =
.accessed() metadata.accessed().unwrap_or_else(|_| SystemTime::now());
.unwrap_or_else(|_| SystemTime::now());
state.entries.insert( state.entries.insert(
track_id, track_id,
@@ -171,7 +170,11 @@ impl PlaybackCache {
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to open playback cache file for track {}: {}", track_id, e); log::warn!(
"Failed to open playback cache file for track {}: {}",
track_id,
e
);
None None
} }
} }
@@ -232,7 +235,11 @@ impl PlaybackCache {
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to create playback cache file for track {}: {}", track_id, e); log::warn!(
"Failed to create playback cache file for track {}: {}",
track_id,
e
);
} }
} }
} }
+11 -15
View File
@@ -2,8 +2,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent}; use mdns_sd::{ServiceDaemon, ServiceEvent};
@@ -59,12 +59,12 @@ impl AirPlayDiscovery {
let mdns = ServiceDaemon::new() let mdns = ServiceDaemon::new()
.map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?; .map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?;
let raop_rx = mdns let raop_rx = mdns.browse(SERVICE_RAOP).map_err(|e| {
.browse(SERVICE_RAOP) AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e))
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e)))?; })?;
let airplay_rx = mdns let airplay_rx = mdns.browse(SERVICE_AIRPLAY).map_err(|e| {
.browse(SERVICE_AIRPLAY) AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e))
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e)))?; })?;
self.running.store(true, Ordering::SeqCst); self.running.store(true, Ordering::SeqCst);
@@ -76,12 +76,8 @@ impl AirPlayDiscovery {
state.clone(), state.clone(),
running.clone(), running.clone(),
)); ));
self.handles.push(spawn_receiver( self.handles
"airplay", .push(spawn_receiver("airplay", airplay_rx, state, running));
airplay_rx,
state,
running,
));
self.daemon = Some(mdns); self.daemon = Some(mdns);
Ok(()) Ok(())
@@ -150,8 +146,8 @@ fn spawn_receiver(
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
let requires_password = parse_password_required(&info); let requires_password = parse_password_required(&info);
let ip = pick_ip(info.get_addresses()) let ip =
.unwrap_or_else(|| "127.0.0.1".to_string()); pick_ip(info.get_addresses()).unwrap_or_else(|| "127.0.0.1".to_string());
let device = DiscoveredAirPlayDevice { let device = DiscoveredAirPlayDevice {
id: id.clone(), id: id.clone(),
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device; pub mod device;
pub mod discovery; pub mod discovery;
pub use crate::AirPlayError;
pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus}; pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus};
pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice}; pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice};
pub use crate::AirPlayError;
+14 -7
View File
@@ -2,9 +2,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use rust_cast::CastDevice; use rust_cast::channels::media::{
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason}; IdleReason, Image, Media, Metadata, MusicTrackMediaMetadata, PlayerState, StreamType,
};
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus}; use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
use rust_cast::CastDevice;
use crate::CastError; use crate::CastError;
@@ -90,7 +92,10 @@ impl CastDeviceConnection {
/// Disconnect from device /// Disconnect from device
pub fn disconnect(&mut self) -> Result<(), CastError> { pub fn disconnect(&mut self) -> Result<(), CastError> {
if let Some(session) = &self.session { if let Some(session) = &self.session {
let _ = self.device.connection.disconnect(session.transport_id.as_str()); let _ = self
.device
.connection
.disconnect(session.transport_id.as_str());
} }
let _ = self.device.connection.disconnect(DEFAULT_RECEIVER_ID); let _ = self.device.connection.disconnect(DEFAULT_RECEIVER_ID);
self.session = None; self.session = None;
@@ -151,7 +156,11 @@ impl CastDeviceConnection {
let status = self let status = self
.device .device
.media .media
.load(session.transport_id.as_str(), session.session_id.as_str(), &media) .load(
session.transport_id.as_str(),
session.session_id.as_str(),
&media,
)
.map_err(|e| CastError::Media(e.to_string()))?; .map_err(|e| CastError::Media(e.to_string()))?;
if let Some(entry) = status.entries.first() { if let Some(entry) = status.entries.first() {
@@ -245,9 +254,7 @@ impl CastDeviceConnection {
IdleReason::Error => "ERROR".to_string(), IdleReason::Error => "ERROR".to_string(),
}); });
let duration = entry.media.as_ref() let duration = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64;
.and_then(|m| m.duration)
.unwrap_or(0.0) as f64;
let position = entry.current_time.unwrap_or(0.0) as f64; let position = entry.current_time.unwrap_or(0.0) as f64;
+1 -1
View File
@@ -2,8 +2,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent}; use mdns_sd::{ServiceDaemon, ServiceEvent};
+1 -1
View File
@@ -8,8 +8,8 @@ use std::thread::{self, JoinHandle};
use std::time::Duration; use std::time::Duration;
use crate::chromecast::device::CastDeviceConnection; use crate::chromecast::device::CastDeviceConnection;
use crate::chromecast::{CastPositionInfo, CastStatus, MediaMetadata};
use crate::CastError; use crate::CastError;
use crate::chromecast::{CastStatus, MediaMetadata, CastPositionInfo};
/// Commands sent to the Chromecast thread /// Commands sent to the Chromecast thread
pub enum CastCommand { pub enum CastCommand {
+76 -34
View File
@@ -1,9 +1,9 @@
//! DLNA device connection and playback via AVTransport SOAP //! DLNA device connection and playback via AVTransport SOAP
use serde::{Deserialize, Serialize};
use rupnp::{Device, Service};
use rupnp::http::Uri; use rupnp::http::Uri;
use rupnp::ssdp::URN; use rupnp::ssdp::URN;
use rupnp::{Device, Service};
use serde::{Deserialize, Serialize};
use crate::dlna::DiscoveredDlnaDevice; use crate::dlna::DiscoveredDlnaDevice;
use crate::DlnaError; use crate::DlnaError;
@@ -56,13 +56,11 @@ impl DlnaConnection {
.parse() .parse()
.map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?; .map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?;
let parsed_device = Device::from_url(device_url.clone()) let parsed_device = Device::from_url(device_url.clone()).await.map_err(|e| {
.await DlnaError::Connection(format!("Failed to load device description: {}", e))
.map_err(|e| DlnaError::Connection(format!("Failed to load device description: {}", e)))?; })?;
let av_transport_service = parsed_device let av_transport_service = parsed_device.find_service(&av_transport_urn()).cloned();
.find_service(&av_transport_urn())
.cloned();
let rendering_control_service = parsed_device let rendering_control_service = parsed_device
.find_service(&rendering_control_urn()) .find_service(&rendering_control_urn())
.cloned(); .cloned();
@@ -110,12 +108,19 @@ impl DlnaConnection {
} }
/// Set the media URI and start playback /// Set the media URI and start playback
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata, content_type: &str) -> Result<(), DlnaError> { pub async fn load_media(
&mut self,
uri: &str,
metadata: &DlnaMetadata,
content_type: &str,
) -> Result<(), DlnaError> {
if !self.connected { if !self.connected {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Build DIDL-Lite metadata with actual content type // Build DIDL-Lite metadata with actual content type
@@ -133,7 +138,7 @@ impl DlnaConnection {
let response = tokio::time::timeout( let response = tokio::time::timeout(
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "SetAVTransportURI", &payload) av_service.action(&self.device_url, "SetAVTransportURI", &payload),
) )
.await .await
.map_err(|_| { .map_err(|_| {
@@ -158,12 +163,18 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let response = tokio::time::timeout( let response = tokio::time::timeout(
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>") av_service.action(
&self.device_url,
"Play",
"<InstanceID>0</InstanceID><Speed>1</Speed>",
),
) )
.await .await
.map_err(|_| { .map_err(|_| {
@@ -187,7 +198,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
av_service av_service
@@ -206,7 +219,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
av_service av_service
@@ -231,7 +246,9 @@ impl DlnaConnection {
let seconds = position_secs % 60; let seconds = position_secs % 60;
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
let payload = format!( let payload = format!(
@@ -254,8 +271,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let rc_service = self.rendering_control_service.as_ref() let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
.ok_or_else(|| DlnaError::Playback("Device has no RenderingControl service".to_string()))?; DlnaError::Playback("Device has no RenderingControl service".to_string())
})?;
// DLNA volume is typically 0-100 // DLNA volume is typically 0-100
let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100); let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100);
@@ -280,13 +298,19 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected); return Err(DlnaError::NotConnected);
} }
let av_service = self.av_transport_service.as_ref() let av_service = self
.av_transport_service
.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?; .ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
// Get position info // Get position info
let position_response = tokio::time::timeout( let position_response = tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetPositionInfo", "<InstanceID>0</InstanceID>") av_service.action(
&self.device_url,
"GetPositionInfo",
"<InstanceID>0</InstanceID>",
),
) )
.await .await
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))? .map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
@@ -295,26 +319,33 @@ impl DlnaConnection {
// Get transport state // Get transport state
let transport_response = tokio::time::timeout( let transport_response = tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
av_service.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>") av_service.action(
&self.device_url,
"GetTransportInfo",
"<InstanceID>0</InstanceID>",
),
) )
.await .await
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))? .map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?; .map_err(|e| DlnaError::Playback(e.to_string()))?;
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS" // Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
let rel_time = position_response.get("RelTime") let rel_time = position_response
.get("RelTime")
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or("0:00:00"); .unwrap_or("0:00:00");
let position_secs = parse_time_string(rel_time); let position_secs = parse_time_string(rel_time);
// Parse TrackDuration - format: "HH:MM:SS" // Parse TrackDuration - format: "HH:MM:SS"
let track_duration = position_response.get("TrackDuration") let track_duration = position_response
.get("TrackDuration")
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or("0:00:00"); .unwrap_or("0:00:00");
let duration_secs = parse_time_string(track_duration); let duration_secs = parse_time_string(track_duration);
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.) // Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
let transport_state = transport_response.get("CurrentTransportState") let transport_state = transport_response
.get("CurrentTransportState")
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string()); .unwrap_or_else(|| "UNKNOWN".to_string());
@@ -324,7 +355,6 @@ impl DlnaConnection {
transport_state, transport_state,
}) })
} }
} }
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds /// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
@@ -336,7 +366,9 @@ fn parse_time_string(time: &str) -> u64 {
let hours: u64 = parts[0].parse().unwrap_or(0); let hours: u64 = parts[0].parse().unwrap_or(0);
let minutes: u64 = parts[1].parse().unwrap_or(0); let minutes: u64 = parts[1].parse().unwrap_or(0);
let seconds: u64 = parts[2].split('.').next() let seconds: u64 = parts[2]
.split('.')
.next()
.and_then(|s| s.parse().ok()) .and_then(|s| s.parse().ok())
.unwrap_or(0); .unwrap_or(0);
@@ -345,15 +377,25 @@ fn parse_time_string(time: &str) -> u64 {
/// Build DIDL-Lite metadata for a track /// Build DIDL-Lite metadata for a track
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String { fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
let duration = metadata.duration_secs.map(|d| { let duration = metadata
let hours = d / 3600; .duration_secs
let minutes = (d % 3600) / 60; .map(|d| {
let seconds = d % 60; let hours = d / 3600;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds) let minutes = (d % 3600) / 60;
}).unwrap_or_else(|| "00:00:00".to_string()); let seconds = d % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
})
.unwrap_or_else(|| "00:00:00".to_string());
let artwork = metadata.artwork_url.as_ref() let artwork = metadata
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url))) .artwork_url
.as_ref()
.map(|url| {
format!(
r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#,
xml_escape(url)
)
})
.unwrap_or_default(); .unwrap_or_default();
// Use actual content type for protocolInfo - critical for DLNA compatibility // Use actual content type for protocolInfo - critical for DLNA compatibility
+5 -7
View File
@@ -1,8 +1,8 @@
//! DLNA/UPnP device discovery via SSDP //! DLNA/UPnP device discovery via SSDP
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_util::TryStreamExt; use futures_util::TryStreamExt;
@@ -62,7 +62,9 @@ impl DlnaDiscovery {
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
while running.load(Ordering::SeqCst) { while running.load(Ordering::SeqCst) {
let target = SearchTarget::URN(media_renderer_urn()); let target = SearchTarget::URN(media_renderer_urn());
let discover = rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None).await; let discover =
rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None)
.await;
if let Ok(stream) = discover { if let Ok(stream) = discover {
let mut stream = std::pin::pin!(stream); let mut stream = std::pin::pin!(stream);
@@ -72,11 +74,7 @@ impl DlnaDiscovery {
let manufacturer = device.manufacturer().to_string(); let manufacturer = device.manufacturer().to_string();
let model = device.model_name().to_string(); let model = device.model_name().to_string();
let url = device.url().to_string(); let url = device.url().to_string();
let ip = device let ip = device.url().host().unwrap_or("unknown").to_string();
.url()
.host()
.unwrap_or("unknown")
.to_string();
let mut has_av_transport = false; let mut has_av_transport = false;
let mut has_rendering_control = false; let mut has_rendering_control = false;
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device; pub mod device;
pub mod discovery; pub mod discovery;
pub use crate::DlnaError;
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus}; pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery}; pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
pub use crate::DlnaError;
+8 -24
View File
@@ -15,48 +15,32 @@
//! - **MediaServer**: Local HTTP server for streaming audio to cast devices. //! - **MediaServer**: Local HTTP server for streaming audio to cast devices.
//! Supports byte-range requests for seeking. //! Supports byte-range requests for seeking.
pub mod airplay;
pub mod chromecast;
pub mod dlna;
pub mod errors; pub mod errors;
pub mod media_server; pub mod media_server;
pub mod chromecast;
pub mod airplay;
pub mod dlna;
// Re-export error types at root // Re-export error types at root
pub use errors::{CastError, AirPlayError, DlnaError}; pub use errors::{AirPlayError, CastError, DlnaError};
// Re-export media server // Re-export media server
pub use media_server::MediaServer; pub use media_server::MediaServer;
// Re-export Chromecast types // Re-export Chromecast types
pub use chromecast::{ pub use chromecast::{
CastApplication, CastApplication, CastCommand, CastDeviceConnection, CastPositionInfo, CastStatus,
CastCommand, ChromecastHandle, DeviceDiscovery, DiscoveredDevice, MediaMetadata,
CastDeviceConnection,
CastPositionInfo,
CastStatus,
ChromecastHandle,
DeviceDiscovery,
DiscoveredDevice,
MediaMetadata,
}; };
// Re-export AirPlay types // Re-export AirPlay types
pub use airplay::{ pub use airplay::{
AirPlayConnection, AirPlayConnection, AirPlayDiscovery, AirPlayMetadata, AirPlayStatus, DiscoveredAirPlayDevice,
AirPlayDiscovery,
AirPlayMetadata,
AirPlayStatus,
DiscoveredAirPlayDevice,
}; };
// Re-export DLNA types // Re-export DLNA types
pub use dlna::{ pub use dlna::{
DiscoveredDlnaDevice, DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
DlnaConnection,
DlnaDiscovery,
DlnaMetadata,
DlnaPositionInfo,
DlnaStatus,
}; };
/// Cast device type alias for backwards compatibility /// Cast device type alias for backwards compatibility
+38 -11
View File
@@ -60,7 +60,12 @@ impl MediaServer {
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
let base_url = format_base_url(base_ip, port); let base_url = format_base_url(base_ip, port);
log::info!("MediaServer: Started on {}:{} (base_url: {})", base_ip, port, base_url); log::info!(
"MediaServer: Started on {}:{} (base_url: {})",
base_ip,
port,
base_url
);
let entries = Arc::new(Mutex::new(HashMap::new())); let entries = Arc::new(Mutex::new(HashMap::new()));
let shutdown = Arc::new(AtomicBool::new(false)); let shutdown = Arc::new(AtomicBool::new(false));
@@ -70,11 +75,19 @@ impl MediaServer {
let port_for_log = port; let port_for_log = port;
let handle = thread::spawn(move || { let handle = thread::spawn(move || {
log::info!("MediaServer: Thread started, listening on port {}", port_for_log); log::info!(
"MediaServer: Thread started, listening on port {}",
port_for_log
);
while !shutdown_clone.load(Ordering::SeqCst) { while !shutdown_clone.load(Ordering::SeqCst) {
match server.recv_timeout(Duration::from_millis(250)) { match server.recv_timeout(Duration::from_millis(250)) {
Ok(Some(request)) => { Ok(Some(request)) => {
let response = handle_request(request.method(), request.url(), &request, &entries_clone); let response = handle_request(
request.method(),
request.url(),
&request,
&entries_clone,
);
let _ = request.respond(response); let _ = request.respond(response);
} }
Ok(None) => {} Ok(None) => {}
@@ -134,10 +147,7 @@ impl MediaServer {
))); )));
} }
let size = path let size = path.metadata().map_err(CastError::Io)?.len();
.metadata()
.map_err(CastError::Io)?
.len();
let content_type = content_type_for_path(path); let content_type = content_type_for_path(path);
let entry = MediaEntry { let entry = MediaEntry {
@@ -194,7 +204,12 @@ fn handle_request(
request: &tiny_http::Request, request: &tiny_http::Request,
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>, entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
) -> Response<std::io::Cursor<Vec<u8>>> { ) -> Response<std::io::Cursor<Vec<u8>>> {
log::info!("MediaServer: {} request from {:?} for {}", method, request.remote_addr(), url); log::info!(
"MediaServer: {} request from {:?} for {}",
method,
request.remote_addr(),
url
);
if method != &Method::Get { if method != &Method::Get {
log::warn!("MediaServer: Rejected non-GET request: {}", method); log::warn!("MediaServer: Rejected non-GET request: {}", method);
@@ -204,14 +219,22 @@ fn handle_request(
let id = match parse_audio_id(url) { let id = match parse_audio_id(url) {
Some(id) => id, Some(id) => id,
None => { None => {
log::warn!("MediaServer: 404 - Could not parse audio ID from URL: {}", url); log::warn!(
"MediaServer: 404 - Could not parse audio ID from URL: {}",
url
);
return Response::from_data(Vec::new()).with_status_code(StatusCode(404)); return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
} }
}; };
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) { let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
Some(entry) => { Some(entry) => {
log::info!("MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes", id, entry.content_type, entry.size); log::info!(
"MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes",
id,
entry.content_type,
entry.size
);
entry entry
} }
None => { None => {
@@ -345,7 +368,11 @@ fn format_base_url(ip: IpAddr, port: u16) -> String {
} }
fn content_type_for_path(path: &Path) -> String { fn content_type_for_path(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()).map(|s| s.to_lowercase()) { match path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
{
Some(ext) if ext == "flac" => "audio/flac".to_string(), Some(ext) if ext == "flac" => "audio/flac".to_string(),
Some(ext) if ext == "wav" => "audio/wav".to_string(), Some(ext) if ext == "wav" => "audio/wav".to_string(),
Some(ext) if ext == "m4a" => "audio/mp4".to_string(), Some(ext) if ext == "m4a" => "audio/mp4".to_string(),
+22 -30
View File
@@ -8,11 +8,11 @@ use tokio::sync::RwLock;
use qbz_models::{ use qbz_models::{
Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse, DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData,
LabelPageData, PageArtistResponse, Playlist, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode,
PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, SearchResultsPage, StreamUrl, Track, UserSession,
}; };
use qbz_player::{Player, PlaybackState, QueueManager}; use qbz_player::{PlaybackState, Player, QueueManager};
use qbz_qobuz::QobuzClient; use qbz_qobuz::QobuzClient;
use crate::error::CoreError; use crate::error::CoreError;
@@ -63,9 +63,10 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?; let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
// Extract bundle tokens (required before any API calls) // Extract bundle tokens (required before any API calls)
client.init().await.map_err(|e| { client
CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)) .init()
})?; .await
.map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?;
*self.client.write().await = Some(client); *self.client.write().await = Some(client);
@@ -115,10 +116,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client.set_session(session.clone()).await; client.set_session(session.clone()).await;
self.emit(CoreEvent::LoggedIn { self.emit(CoreEvent::LoggedIn { session }).await;
session,
})
.await;
Ok(()) Ok(())
} }
@@ -398,23 +396,17 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// Pause playback /// Pause playback
pub fn pause(&self) -> Result<(), CoreError> { pub fn pause(&self) -> Result<(), CoreError> {
self.player self.player.pause().map_err(|e| CoreError::Playback(e))
.pause()
.map_err(|e| CoreError::Playback(e))
} }
/// Resume playback /// Resume playback
pub fn resume(&self) -> Result<(), CoreError> { pub fn resume(&self) -> Result<(), CoreError> {
self.player self.player.resume().map_err(|e| CoreError::Playback(e))
.resume()
.map_err(|e| CoreError::Playback(e))
} }
/// Stop playback /// Stop playback
pub fn stop(&self) -> Result<(), CoreError> { pub fn stop(&self) -> Result<(), CoreError> {
self.player self.player.stop().map_err(|e| CoreError::Playback(e))
.stop()
.map_err(|e| CoreError::Playback(e))
} }
/// Seek to position in seconds /// Seek to position in seconds
@@ -503,7 +495,10 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client.get_playlist(playlist_id).await.map_err(CoreError::Api) client
.get_playlist(playlist_id)
.await
.map_err(CoreError::Api)
} }
/// Add tracks to playlist /// Add tracks to playlist
@@ -612,14 +607,14 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client client.get_genres(parent_id).await.map_err(CoreError::Api)
.get_genres(parent_id)
.await
.map_err(CoreError::Api)
} }
/// Get discover index /// Get discover index
pub async fn get_discover_index(&self, genre_ids: Option<Vec<u64>>) -> Result<DiscoverResponse, CoreError> { pub async fn get_discover_index(
&self,
genre_ids: Option<Vec<u64>>,
) -> Result<DiscoverResponse, CoreError> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
@@ -651,10 +646,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await; let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?; let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client client.get_playlist_tags().await.map_err(CoreError::Api)
.get_playlist_tags()
.await
.map_err(CoreError::Api)
} }
/// Get discover albums from a specific browse endpoint /// Get discover albums from a specific browse endpoint
+1 -1
View File
@@ -56,7 +56,7 @@ pub mod core;
pub mod error; pub mod error;
// Re-exports from qbz-models for convenience // Re-exports from qbz-models for convenience
pub use qbz_models::{CoreEvent, FrontendAdapter, NoOpAdapter, LoggingAdapter}; pub use qbz_models::{CoreEvent, FrontendAdapter, LoggingAdapter, NoOpAdapter};
// Re-exports from this crate // Re-exports from this crate
pub use core::QbzCore; pub use core::QbzCore;
+13 -14
View File
@@ -3,8 +3,11 @@
use reqwest::Client; use reqwest::Client;
use serde_json::json; use serde_json::json;
use super::models::{
AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession,
LastFmSimilarArtist,
};
use crate::error::{IntegrationError, IntegrationResult}; use crate::error::{IntegrationError, IntegrationResult};
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
/// Cloudflare Workers proxy URL - handles API credentials and signature generation /// Cloudflare Workers proxy URL - handles API credentials and signature generation
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm"; const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
@@ -78,25 +81,18 @@ impl LastFmClient {
pub async fn get_token(&self) -> IntegrationResult<(String, String)> { pub async fn get_token(&self) -> IntegrationResult<(String, String)> {
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL); let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
let response = self let response = self.client.post(&url).json(&json!({})).send().await?;
.client
.post(&url)
.json(&json!({}))
.send()
.await?;
let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?; let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?;
match data { match data {
LastFmResponse::Success(r) => { LastFmResponse::Success(r) => {
let auth_url = r.auth_url.unwrap_or_else(|| { let auth_url = r
format!("https://www.last.fm/api/auth/?token={}", r.token) .auth_url
}); .unwrap_or_else(|| format!("https://www.last.fm/api/auth/?token={}", r.token));
Ok((r.token, auth_url)) Ok((r.token, auth_url))
} }
LastFmResponse::Error { error, message } => { LastFmResponse::Error { error, message } => Err(IntegrationError::api(error, message)),
Err(IntegrationError::api(error, message))
}
} }
} }
@@ -171,7 +167,10 @@ impl LastFmClient {
Ok(()) Ok(())
} else { } else {
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!("Scrobble failed: {}", text))) Err(IntegrationError::internal(format!(
"Scrobble failed: {}",
text
)))
} }
} }
@@ -142,7 +142,8 @@ impl ListenBrainzCache {
isrc: Option<&str>, isrc: Option<&str>,
duration_ms: Option<u64>, duration_ms: Option<u64>,
) -> Result<i64, String> { ) -> Result<i64, String> {
let artist_mbids_json = artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default()); let artist_mbids_json =
artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
self.conn self.conn
.execute( .execute(
@@ -183,7 +184,8 @@ impl ListenBrainzCache {
let listens = stmt let listens = stmt
.query_map([limit], |row| { .query_map([limit], |row| {
let artist_mbids_json: Option<String> = row.get(7)?; let artist_mbids_json: Option<String> = row.get(7)?;
let artist_mbids = artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok()); let artist_mbids =
artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok());
Ok(QueuedListen { Ok(QueuedListen {
id: row.get(0)?, id: row.get(0)?,
@@ -241,7 +243,8 @@ impl ListenBrainzCache {
.iter() .iter()
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>) .map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
.collect(); .collect();
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
self.conn self.conn
.execute(&sql, params_refs.as_slice()) .execute(&sql, params_refs.as_slice())
.map_err(|e| format!("Failed to batch mark sent: {}", e))?; .map_err(|e| format!("Failed to batch mark sent: {}", e))?;
@@ -252,7 +255,11 @@ impl ListenBrainzCache {
pub fn get_queue_count(&self) -> Result<u32, String> { pub fn get_queue_count(&self) -> Result<u32, String> {
let count: i64 = self let count: i64 = self
.conn .conn
.query_row("SELECT COUNT(*) FROM listen_queue WHERE sent = 0", [], |row| row.get(0)) .query_row(
"SELECT COUNT(*) FROM listen_queue WHERE sent = 0",
[],
|row| row.get(0),
)
.unwrap_or(0); .unwrap_or(0);
Ok(count as u32) Ok(count as u32)
} }
@@ -6,8 +6,8 @@ use reqwest::Client;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*; use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// ListenBrainz API base URL /// ListenBrainz API base URL
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1"; const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
@@ -113,9 +113,9 @@ impl ListenBrainzClient {
return Err(IntegrationError::AuthFailed(validation.message)); return Err(IntegrationError::AuthFailed(validation.message));
} }
let user_name = validation let user_name = validation.user_name.ok_or_else(|| {
.user_name IntegrationError::AuthFailed("Token valid but no username returned".into())
.ok_or_else(|| IntegrationError::AuthFailed("Token valid but no username returned".into()))?; })?;
// Store validated token and username // Store validated token and username
{ {
@@ -174,7 +174,10 @@ impl ListenBrainzClient {
))); )));
} }
response.json::<TokenValidationResponse>().await.map_err(Into::into) response
.json::<TokenValidationResponse>()
.await
.map_err(Into::into)
} }
/// Submit "now playing" notification /// Submit "now playing" notification
@@ -261,7 +264,11 @@ impl ListenBrainzClient {
} }
/// Internal: Submit listens to API /// Internal: Submit listens to API
async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> IntegrationResult<()> { async fn submit_listens(
&self,
token: &str,
payload: &SubmitListensPayload,
) -> IntegrationResult<()> {
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL); let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
let response = self let response = self
@@ -45,6 +45,6 @@ pub mod cache;
pub use client::{ListenBrainzClient, ListenBrainzConfig}; pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{ pub use models::{
ListenType, TrackMetadata, AdditionalInfo, Listen, SubmitListensPayload, AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
ListenBrainzStatus, UserInfo, QueuedListen, TrackMetadata, UserInfo,
}; };
@@ -8,8 +8,8 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use super::models::{ use super::models::{
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse, ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence,
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType, ResolvedArtist, ResolvedTrack,
}; };
/// TTL for recording cache (30 days) /// TTL for recording cache (30 days)
@@ -245,7 +245,10 @@ impl MusicBrainzCache {
// ============ Artist Cache (JSON-serialized) ============ // ============ Artist Cache (JSON-serialized) ============
/// Get cached artist by name (JSON-serialized) /// Get cached artist by name (JSON-serialized)
pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(&self, name: &str) -> Result<Option<T>, String> { pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(
&self,
name: &str,
) -> Result<Option<T>, String> {
let normalized = Self::normalize_name(name); let normalized = Self::normalize_name(name);
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS; let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
let result: Option<String> = self let result: Option<String> = self
@@ -268,7 +271,11 @@ impl MusicBrainzCache {
} }
/// Cache an artist (JSON-serialized) /// Cache an artist (JSON-serialized)
pub fn set_artist_by_name<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<(), String> { pub fn set_artist_by_name<T: serde::Serialize>(
&self,
name: &str,
data: &T,
) -> Result<(), String> {
let normalized = Self::normalize_name(name); let normalized = Self::normalize_name(name);
let fetched_at = Self::current_timestamp(); let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data) let json = serde_json::to_string(data)
@@ -285,7 +292,10 @@ impl MusicBrainzCache {
// ============ Release Cache ============ // ============ Release Cache ============
/// Get cached release by barcode /// Get cached release by barcode
pub fn get_release<T: serde::de::DeserializeOwned>(&self, barcode: &str) -> Result<Option<T>, String> { pub fn get_release<T: serde::de::DeserializeOwned>(
&self,
barcode: &str,
) -> Result<Option<T>, String> {
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS; let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
let result: Option<String> = self let result: Option<String> = self
.conn .conn
@@ -345,7 +355,11 @@ impl MusicBrainzCache {
} }
/// Cache artist relationships /// Cache artist relationships
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> { pub fn set_artist_relations(
&self,
mbid: &str,
data: &ArtistRelationships,
) -> Result<(), String> {
let fetched_at = Self::current_timestamp(); let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data) let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize relations: {}", e))?; .map_err(|e| format!("Failed to serialize relations: {}", e))?;
@@ -399,7 +413,10 @@ impl MusicBrainzCache {
// ============ Scene Discovery Cache ============ // ============ Scene Discovery Cache ============
/// Get cached scene discovery results /// Get cached scene discovery results
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> { pub fn get_scene_cache(
&self,
cache_key: &str,
) -> Result<Option<LocationDiscoveryResponse>, String> {
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS; let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
let result: Option<String> = self let result: Option<String> = self
.conn .conn
@@ -421,10 +438,14 @@ impl MusicBrainzCache {
} }
/// Cache scene discovery results /// Cache scene discovery results
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> { pub fn set_scene_cache(
&self,
cache_key: &str,
data: &LocationDiscoveryResponse,
) -> Result<(), String> {
let fetched_at = Self::current_timestamp(); let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data) let json =
.map_err(|e| format!("Failed to serialize scene: {}", e))?; serde_json::to_string(data).map_err(|e| format!("Failed to serialize scene: {}", e))?;
self.conn self.conn
.execute( .execute(
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)", "INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
@@ -621,7 +642,10 @@ impl MusicBrainzCache {
} }
if total_deleted > 0 { if total_deleted > 0 {
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted); log::info!(
"MusicBrainz cache cleanup: removed {} expired entries",
total_deleted
);
} }
Ok(total_deleted) Ok(total_deleted)
} }
@@ -650,17 +674,23 @@ impl MusicBrainzCache {
/// Get cache statistics /// Get cache statistics
pub fn get_stats(&self) -> Result<CacheStats, String> { pub fn get_stats(&self) -> Result<CacheStats, String> {
let recordings: i64 = self.conn let recordings: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
.unwrap_or(0); .unwrap_or(0);
let artists: i64 = self.conn let artists: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
.unwrap_or(0); .unwrap_or(0);
let releases: i64 = self.conn let releases: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
.unwrap_or(0); .unwrap_or(0);
let relations: i64 = self.conn let relations: i64 = self
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0)) .conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| {
row.get(0)
})
.unwrap_or(0); .unwrap_or(0);
Ok(CacheStats { Ok(CacheStats {
@@ -674,12 +704,14 @@ impl MusicBrainzCache {
/// TTL-based cleanup (V2 style) /// TTL-based cleanup (V2 style)
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> { pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400); let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
let tracks_deleted = self.conn let tracks_deleted =
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff]) self.conn
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64; .execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
let artists_deleted = self.conn .map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff]) let artists_deleted =
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64; self.conn
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
Ok((tracks_deleted, artists_deleted)) Ok((tracks_deleted, artists_deleted))
} }
+105 -26
View File
@@ -8,8 +8,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*; use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// Proxy URL for MusicBrainz requests /// Proxy URL for MusicBrainz requests
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz"; const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
@@ -144,7 +144,10 @@ impl MusicBrainzClient {
} }
/// Search recordings by ISRC /// Search recordings by ISRC
pub async fn search_recording_by_isrc(&self, isrc: &str) -> IntegrationResult<RecordingSearchResponse> { pub async fn search_recording_by_isrc(
&self,
isrc: &str,
) -> IntegrationResult<RecordingSearchResponse> {
if !self.is_enabled().await { if !self.is_enabled().await {
return Err(IntegrationError::ServiceUnavailable( return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(), "MusicBrainz integration is disabled".into(),
@@ -174,7 +177,11 @@ impl MusicBrainzClient {
} }
/// Search artists by name /// Search artists by name
pub async fn search_artist(&self, name: &str, limit: u32) -> IntegrationResult<ArtistSearchResponse> { pub async fn search_artist(
&self,
name: &str,
limit: u32,
) -> IntegrationResult<ArtistSearchResponse> {
if !self.is_enabled().await { if !self.is_enabled().await {
return Err(IntegrationError::ServiceUnavailable( return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(), "MusicBrainz integration is disabled".into(),
@@ -220,7 +227,11 @@ impl MusicBrainzClient {
if let Some(isrc) = isrc { if let Some(isrc) = isrc {
let response = self.search_recording_by_isrc(isrc).await?; let response = self.search_recording_by_isrc(isrc).await?;
if let Some(recording) = response.recordings.first() { if let Some(recording) = response.recordings.first() {
let confidence = if recording.isrcs.as_ref().map_or(false, |isrcs| isrcs.contains(&isrc.to_string())) { let confidence = if recording
.isrcs
.as_ref()
.map_or(false, |isrcs| isrcs.contains(&isrc.to_string()))
{
MatchConfidence::Exact MatchConfidence::Exact
} else { } else {
MatchConfidence::from_score(recording.score) MatchConfidence::from_score(recording.score)
@@ -275,7 +286,11 @@ impl MusicBrainzClient {
// ============ Extended API Methods ============ // ============ Extended API Methods ============
/// Search recordings by title and artist /// Search recordings by title and artist
pub async fn search_recording(&self, title: &str, artist: &str) -> IntegrationResult<RecordingSearchResponse> { pub async fn search_recording(
&self,
title: &str,
artist: &str,
) -> IntegrationResult<RecordingSearchResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -285,7 +300,11 @@ impl MusicBrainzClient {
Self::escape_query(title), Self::escape_query(title),
Self::escape_query(artist) Self::escape_query(artist)
); );
let url = format!("{}/recording?query={}&fmt=json&limit=5", base, urlencoding::encode(&query)); let url = format!(
"{}/recording?query={}&fmt=json&limit=5",
base,
urlencoding::encode(&query)
);
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
self.check_response(&response).await; self.check_response(&response).await;
@@ -294,7 +313,10 @@ impl MusicBrainzClient {
} }
/// Get artist details with relationships and tags /// Get artist details with relationships and tags
pub async fn get_artist_with_relations(&self, mbid: &str) -> IntegrationResult<ArtistFullResponse> { pub async fn get_artist_with_relations(
&self,
mbid: &str,
) -> IntegrationResult<ArtistFullResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -330,11 +352,18 @@ impl MusicBrainzClient {
.filter(|tag| tag.count.unwrap_or(0) > 0) .filter(|tag| tag.count.unwrap_or(0) > 0)
.collect(); .collect();
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0))); tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect()) Ok(tags
.into_iter()
.map(|tag| tag.name.to_lowercase())
.collect())
} }
/// Search artists by tag (genre) /// Search artists by tag (genre)
pub async fn search_artists_by_tag(&self, tag: &str, limit: usize) -> IntegrationResult<ArtistSearchResponse> { pub async fn search_artists_by_tag(
&self,
tag: &str,
limit: usize,
) -> IntegrationResult<ArtistSearchResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -343,7 +372,9 @@ impl MusicBrainzClient {
let query = format!("tag:\"{}\"", Self::escape_query(tag)); let query = format!("tag:\"{}\"", Self::escape_query(tag));
let url = format!( let url = format!(
"{}/artist?query={}&fmt=json&limit={}", "{}/artist?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit base,
urlencoding::encode(&query),
limit
); );
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
@@ -373,7 +404,10 @@ impl MusicBrainzClient {
); );
let url = format!( let url = format!(
"{}/artist?query={}&fmt=json&limit={}&offset={}", "{}/artist?query={}&fmt=json&limit={}&offset={}",
base, urlencoding::encode(&query), limit, offset base,
urlencoding::encode(&query),
limit,
offset
); );
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
@@ -382,12 +416,18 @@ impl MusicBrainzClient {
} }
/// Search releases by barcode (UPC/EAN) /// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode(&self, barcode: &str) -> IntegrationResult<ReleaseSearchResponse> { pub async fn search_release_by_barcode(
&self,
barcode: &str,
) -> IntegrationResult<ReleaseSearchResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
let base = self.base_url().await; let base = self.base_url().await;
let url = format!("{}/release?query=barcode:{}&fmt=json&limit=5", base, barcode); let url = format!(
"{}/release?query=barcode:{}&fmt=json&limit=5",
base, barcode
);
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?; let response = self.handle_response_status(response).await?;
@@ -395,7 +435,11 @@ impl MusicBrainzClient {
} }
/// Search releases by title and artist /// Search releases by title and artist
pub async fn search_release(&self, title: &str, artist: &str) -> IntegrationResult<ReleaseSearchResponse> { pub async fn search_release(
&self,
title: &str,
artist: &str,
) -> IntegrationResult<ReleaseSearchResponse> {
self.search_releases_extended(title, artist, None, 5).await self.search_releases_extended(title, artist, None, 5).await
} }
@@ -428,7 +472,9 @@ impl MusicBrainzClient {
let limit = limit.min(25).max(1); let limit = limit.min(25).max(1);
let url = format!( let url = format!(
"{}/release?query={}&fmt=json&limit={}", "{}/release?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit base,
urlencoding::encode(&query),
limit
); );
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
@@ -437,7 +483,10 @@ impl MusicBrainzClient {
} }
/// Get full release details including tracks /// Get full release details including tracks
pub async fn get_release_with_tracks(&self, release_id: &str) -> IntegrationResult<ReleaseFullResponse> { pub async fn get_release_with_tracks(
&self,
release_id: &str,
) -> IntegrationResult<ReleaseFullResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -475,7 +524,11 @@ impl MusicBrainzClient {
} }
/// Search for an area by name /// Search for an area by name
pub async fn search_area(&self, name: &str, area_type: Option<&str>) -> IntegrationResult<AreaSearchResponse> { pub async fn search_area(
&self,
name: &str,
area_type: Option<&str>,
) -> IntegrationResult<AreaSearchResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -490,7 +543,11 @@ impl MusicBrainzClient {
format!("area:\"{}\"", Self::escape_query(name)) format!("area:\"{}\"", Self::escape_query(name))
}; };
let url = format!("{}/area?query={}&fmt=json&limit=5", base, urlencoding::encode(&query)); let url = format!(
"{}/area?query={}&fmt=json&limit=5",
base,
urlencoding::encode(&query)
);
let response = self.client.get(&url).send().await?; let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?; let response = self.handle_response_status(response).await?;
@@ -498,7 +555,10 @@ impl MusicBrainzClient {
} }
/// Look up an area and its parent relationships /// Look up an area and its parent relationships
pub async fn get_area_with_relations(&self, area_id: &str) -> IntegrationResult<AreaDetailResponse> { pub async fn get_area_with_relations(
&self,
area_id: &str,
) -> IntegrationResult<AreaDetailResponse> {
self.check_enabled().await?; self.check_enabled().await?;
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
@@ -511,7 +571,10 @@ impl MusicBrainzClient {
} }
/// Resolve a city area to its parent subdivision (state/region) /// Resolve a city area to its parent subdivision (state/region)
pub async fn resolve_parent_subdivision(&self, area_id: &str) -> IntegrationResult<Option<(String, String)>> { pub async fn resolve_parent_subdivision(
&self,
area_id: &str,
) -> IntegrationResult<Option<(String, String)>> {
let mut current_id = area_id.to_string(); let mut current_id = area_id.to_string();
let mut path: Vec<String> = Vec::new(); let mut path: Vec<String> = Vec::new();
let max_hops = 5; let max_hops = 5;
@@ -539,7 +602,10 @@ impl MusicBrainzClient {
} }
let has_country_parent = parents.iter().any(|p| { let has_country_parent = parents.iter().any(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("country")).unwrap_or(false) p.area_type
.as_deref()
.map(|t| t.eq_ignore_ascii_case("country"))
.unwrap_or(false)
}); });
if has_country_parent { if has_country_parent {
@@ -559,7 +625,10 @@ impl MusicBrainzClient {
let next = parents let next = parents
.iter() .iter()
.find(|p| { .find(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("subdivision")).unwrap_or(false) p.area_type
.as_deref()
.map(|t| t.eq_ignore_ascii_case("subdivision"))
.unwrap_or(false)
}) })
.or_else(|| { .or_else(|| {
parents.iter().find(|p| { parents.iter().find(|p| {
@@ -570,8 +639,12 @@ impl MusicBrainzClient {
.or_else(|| parents.first()); .or_else(|| parents.first());
match next { match next {
Some(parent) => { current_id = parent.id.clone(); } Some(parent) => {
None => { return Ok(None); } current_id = parent.id.clone();
}
None => {
return Ok(None);
}
} }
} }
@@ -594,7 +667,10 @@ impl MusicBrainzClient {
// Placeholder for response logging/metrics // Placeholder for response logging/metrics
} }
async fn handle_response_status(&self, response: reqwest::Response) -> IntegrationResult<reqwest::Response> { async fn handle_response_status(
&self,
response: reqwest::Response,
) -> IntegrationResult<reqwest::Response> {
if response.status().is_success() { if response.status().is_success() {
return Ok(response); return Ok(response);
} }
@@ -603,7 +679,10 @@ impl MusicBrainzClient {
return Err(IntegrationError::RateLimited(60)); return Err(IntegrationError::RateLimited(60));
} }
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!("MusicBrainz API error {}: {}", status, text))) Err(IntegrationError::internal(format!(
"MusicBrainz API error {}: {}",
status, text
)))
} }
/// Escape special characters in Lucene queries /// Escape special characters in Lucene queries
+54 -51
View File
@@ -991,15 +991,21 @@ impl LibraryDatabase {
} }
// Detect if this file is a Qobuz purchased download // Detect if this file is a Qobuz purchased download
let is_purchase: bool = self.conn let is_purchase: bool = self
.conn
.query_row( .query_row(
"SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1", "SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1",
params![track.file_path], params![track.file_path],
|row| row.get::<_, i64>(0), |row| row.get::<_, i64>(0),
) )
.unwrap_or(0) > 0; .unwrap_or(0)
> 0;
let source = if is_purchase { "qobuz_purchase" } else { "user" }; let source = if is_purchase {
"qobuz_purchase"
} else {
"user"
};
self.conn self.conn
.execute( .execute(
@@ -1047,7 +1053,10 @@ impl LibraryDatabase {
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> { pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare(&format!("SELECT {} FROM local_tracks WHERE id = ?", Self::TRACK_COLUMNS)) .prepare(&format!(
"SELECT {} FROM local_tracks WHERE id = ?",
Self::TRACK_COLUMNS
))
.map_err(|e| LibraryError::Database(e.to_string()))?; .map_err(|e| LibraryError::Database(e.to_string()))?;
stmt.query_row(params![id], |row| Self::row_to_track(row)) stmt.query_row(params![id], |row| Self::row_to_track(row))
@@ -1059,7 +1068,10 @@ impl LibraryDatabase {
pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> { pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare(&format!("SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL", Self::TRACK_COLUMNS)) .prepare(&format!(
"SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL",
Self::TRACK_COLUMNS
))
.map_err(|e| LibraryError::Database(e.to_string()))?; .map_err(|e| LibraryError::Database(e.to_string()))?;
stmt.query_row(params![path], |row| Self::row_to_track(row)) stmt.query_row(params![path], |row| Self::row_to_track(row))
@@ -1692,7 +1704,10 @@ impl LibraryDatabase {
"SELECT {} FROM local_tracks \ "SELECT {} FROM local_tracks \
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \ WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
{} {} {}", {} {} {}",
Self::TRACK_COLUMNS, source_filter, network_filter, limit_clause Self::TRACK_COLUMNS,
source_filter,
network_filter,
limit_clause
); );
let mut stmt = self let mut stmt = self
@@ -1754,8 +1769,7 @@ impl LibraryDatabase {
/// Convert a database row to LocalTrack /// Convert a database row to LocalTrack
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices) /// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
const TRACK_COLUMNS: &'static str = const TRACK_COLUMNS: &'static str = "id, file_path, title, artist, album, album_artist, \
"id, file_path, title, artist, album, album_artist, \
track_number, disc_number, year, genre, duration_secs, format, \ track_number, disc_number, year, genre, duration_secs, format, \
bit_depth, sample_rate, channels, file_size_bytes, \ bit_depth, sample_rate, channels, file_size_bytes, \
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \ cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
@@ -1764,33 +1778,33 @@ impl LibraryDatabase {
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> { fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
Ok(LocalTrack { Ok(LocalTrack {
id: row.get(0)?, // id id: row.get(0)?, // id
file_path: row.get(1)?, // file_path file_path: row.get(1)?, // file_path
title: row.get(2)?, // title title: row.get(2)?, // title
artist: row.get(3)?, // artist artist: row.get(3)?, // artist
album: row.get(4)?, // album album: row.get(4)?, // album
album_artist: row.get(5)?, // album_artist album_artist: row.get(5)?, // album_artist
track_number: row.get(6)?, // track_number track_number: row.get(6)?, // track_number
disc_number: row.get(7)?, // disc_number disc_number: row.get(7)?, // disc_number
year: row.get(8)?, // year year: row.get(8)?, // year
genre: row.get(9)?, // genre genre: row.get(9)?, // genre
duration_secs: row.get(10)?, // duration_secs duration_secs: row.get(10)?, // duration_secs
format: Self::parse_format(&row.get::<_, String>(11)?), // format format: Self::parse_format(&row.get::<_, String>(11)?), // format
bit_depth: row.get(12)?, // bit_depth bit_depth: row.get(12)?, // bit_depth
sample_rate: row.get::<_, f64>(13)?, // sample_rate sample_rate: row.get::<_, f64>(13)?, // sample_rate
channels: row.get(14)?, // channels channels: row.get(14)?, // channels
file_size_bytes: row.get(15)?, // file_size_bytes file_size_bytes: row.get(15)?, // file_size_bytes
cue_file_path: row.get(16)?, // cue_file_path cue_file_path: row.get(16)?, // cue_file_path
cue_start_secs: row.get(17)?, // cue_start_secs cue_start_secs: row.get(17)?, // cue_start_secs
cue_end_secs: row.get(18)?, // cue_end_secs cue_end_secs: row.get(18)?, // cue_end_secs
artwork_path: row.get(19)?, // artwork_path artwork_path: row.get(19)?, // artwork_path
last_modified: row.get(20)?, // last_modified last_modified: row.get(20)?, // last_modified
indexed_at: row.get(21)?, // indexed_at indexed_at: row.get(21)?, // indexed_at
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title
source: row.get(24).ok().flatten(), // source source: row.get(24).ok().flatten(), // source
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
catalog_number: row.get(26).ok().flatten(), // catalog_number catalog_number: row.get(26).ok().flatten(), // catalog_number
}) })
} }
@@ -3652,15 +3666,10 @@ impl LibraryDatabase {
} }
/// Get custom album cover path for a single album /// Get custom album cover path for a single album
pub fn get_custom_album_cover( pub fn get_custom_album_cover(&self, album_id: &str) -> Result<Option<String>, LibraryError> {
&self,
album_id: &str,
) -> Result<Option<String>, LibraryError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare( .prepare("SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1")
"SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1",
)
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?; .map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
let result = stmt let result = stmt
@@ -3674,10 +3683,7 @@ impl LibraryDatabase {
} }
/// Remove a custom album cover /// Remove a custom album cover
pub fn remove_custom_album_cover( pub fn remove_custom_album_cover(&self, album_id: &str) -> Result<(), LibraryError> {
&self,
album_id: &str,
) -> Result<(), LibraryError> {
self.conn self.conn
.execute( .execute(
"DELETE FROM custom_album_covers WHERE album_id = ?1", "DELETE FROM custom_album_covers WHERE album_id = ?1",
@@ -3971,14 +3977,10 @@ impl LibraryDatabase {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases") .prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases")
.map_err(|e| { .map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
LibraryError::Database(format!("Failed to prepare statement: {}", e))
})?;
let rows: Vec<(i64, i64, String)> = stmt let rows: Vec<(i64, i64, String)> = stmt
.query_map([], |row| { .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.map_err(|e| { .map_err(|e| {
LibraryError::Database(format!("Failed to query downloaded purchases: {}", e)) LibraryError::Database(format!("Failed to query downloaded purchases: {}", e))
})? })?
@@ -3998,7 +4000,10 @@ impl LibraryDatabase {
// Remove stale entries where the file no longer exists // Remove stale entries where the file no longer exists
if !stale.is_empty() { if !stale.is_empty() {
log::info!("Removing {} stale downloaded_purchases entries (files deleted)", stale.len()); log::info!(
"Removing {} stale downloaded_purchases entries (files deleted)",
stale.len()
);
for (track_id, format_id) in &stale { for (track_id, format_id) in &stale {
let _ = self.conn.execute( let _ = self.conn.execute(
"DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2", "DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2",
@@ -4017,9 +4022,7 @@ impl LibraryDatabase {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT track_id, format_id FROM downloaded_purchases") .prepare("SELECT track_id, format_id FROM downloaded_purchases")
.map_err(|e| { .map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
LibraryError::Database(format!("Failed to prepare statement: {}", e))
})?;
let rows = stmt let rows = stmt
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))) .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
+13 -16
View File
@@ -28,31 +28,28 @@
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap(); //! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
//! ``` //! ```
mod errors;
mod models;
mod scanner;
mod thumbnails;
mod metadata;
mod cue_parser; mod cue_parser;
mod database; mod database;
mod errors;
mod metadata;
mod models;
mod scanner;
mod tag_sidecar; mod tag_sidecar;
mod thumbnails;
// Re-exports // Re-exports
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTime, CueTrack};
pub use database::{
AlbumTrackUpdate, LibraryDatabase, LibraryFolder, LibraryStats, LocalContentStatus,
PlaylistFolder, PlaylistSettings, PlaylistStats, TrackMetadataUpdateFull,
};
pub use errors::LibraryError; pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*; pub use models::*;
pub use scanner::{LibraryScanner, ScanResult}; pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{ pub use thumbnails::{
generate_thumbnail, generate_thumbnail_from_bytes, clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
get_thumbnail_path, get_thumbnails_dir, thumbnail_exists, get_or_generate_thumbnail, get_thumbnail_path, get_thumbnails_dir, thumbnail_exists,
get_or_generate_thumbnail, clear_thumbnails, get_cache_size,
};
pub use metadata::MetadataExtractor;
pub use cue_parser::{CueParser, CueSheet, CueTrack, CueTime, cue_to_tracks};
pub use database::{
LibraryDatabase, LibraryFolder, LibraryStats,
AlbumTrackUpdate, TrackMetadataUpdateFull,
PlaylistFolder, PlaylistSettings, PlaylistStats,
LocalContentStatus,
}; };
// Re-export database module for backwards compatibility // Re-export database module for backwards compatibility
+85 -41
View File
@@ -5,8 +5,8 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes}; use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
/// Metadata extractor using lofty /// Metadata extractor using lofty
pub struct MetadataExtractor; pub struct MetadataExtractor;
@@ -108,10 +108,18 @@ impl MetadataExtractor {
// Must contain at least one disc-related token // Must contain at least one disc-related token
let has_disc_token = tokens.iter().any(|token| { let has_disc_token = tokens.iter().any(|token| {
*token == "disc" || *token == "disk" || *token == "cd" *token == "disc"
|| (token.starts_with("disc") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty()) || *token == "disk"
|| (token.starts_with("disk") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty()) || *token == "cd"
|| (token.starts_with("cd") && token[2..].chars().all(|c| c.is_ascii_digit()) && !token[2..].is_empty()) || (token.starts_with("disc")
&& token[4..].chars().all(|c| c.is_ascii_digit())
&& !token[4..].is_empty())
|| (token.starts_with("disk")
&& token[4..].chars().all(|c| c.is_ascii_digit())
&& !token[4..].is_empty())
|| (token.starts_with("cd")
&& token[2..].chars().all(|c| c.is_ascii_digit())
&& !token[2..].is_empty())
}); });
if !has_disc_token { if !has_disc_token {
@@ -215,7 +223,10 @@ impl MetadataExtractor {
// Only when leading number is 1-2 digits (disc number) followed by dash+digits // Only when leading number is 1-2 digits (disc number) followed by dash+digits
if digit_end <= 2 && rest.starts_with('-') { if digit_end <= 2 && rest.starts_with('-') {
let after_dash = &rest[1..]; let after_dash = &rest[1..];
let track_digits: String = after_dash.chars().take_while(|c| c.is_ascii_digit()).collect(); let track_digits: String = after_dash
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !track_digits.is_empty() { if !track_digits.is_empty() {
if let Ok(n) = track_digits.parse::<u32>() { if let Ok(n) = track_digits.parse::<u32>() {
if n > 0 && n < 10000 { if n > 0 && n < 10000 {
@@ -280,8 +291,18 @@ impl MetadataExtractor {
if let Some(word) = first_word { if let Some(word) = first_word {
if matches!( if matches!(
word, word,
"flac" | "mp3" | "aac" | "alac" | "wav" | "aiff" | "ogg" "flac"
| "dsd" | "opus" | "wma" | "ape" | "pcm" | "mp3"
| "aac"
| "alac"
| "wav"
| "aiff"
| "ogg"
| "dsd"
| "opus"
| "wma"
| "ape"
| "pcm"
) { ) {
return true; return true;
} }
@@ -380,7 +401,9 @@ impl MetadataExtractor {
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?; .map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
// Get the primary tag (prefer ID3v2/Vorbis/APE) // Get the primary tag (prefer ID3v2/Vorbis/APE)
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag()); let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag());
// Get audio properties // Get audio properties
let properties = tagged_file.properties(); let properties = tagged_file.properties();
@@ -424,10 +447,7 @@ impl MetadataExtractor {
LocalTrack { LocalTrack {
id: 0, id: 0,
file_path: file_path.to_string_lossy().to_string(), file_path: file_path.to_string_lossy().to_string(),
title: tag title: tag.title().map(|s| s.to_string()).unwrap_or(filename),
.title()
.map(|s| s.to_string())
.unwrap_or(filename),
artist: Self::normalize_field(tag.artist().as_deref()) artist: Self::normalize_field(tag.artist().as_deref())
.or_else(|| fallback_artist.clone()) .or_else(|| fallback_artist.clone())
.unwrap_or_else(|| "Unknown Artist".to_string()), .unwrap_or_else(|| "Unknown Artist".to_string()),
@@ -435,7 +455,9 @@ impl MetadataExtractor {
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_key,
album_group_title, album_group_title,
track_number: tag.track().map(|t| t as u32) track_number: tag
.track()
.map(|t| t as u32)
.or_else(|| Self::infer_track_number_from_filename(file_path)), .or_else(|| Self::infer_track_number_from_filename(file_path)),
disc_number: tag disc_number: tag
.disk() .disk()
@@ -443,7 +465,9 @@ impl MetadataExtractor {
.or(inferred_disc), .or(inferred_disc),
year: tag.year().map(|y| y as u32), year: tag.year().map(|y| y as u32),
genre: tag.genre().map(|s| s.to_string()), genre: tag.genre().map(|s| s.to_string()),
catalog_number: tag.get_string(&ItemKey::CatalogNumber).map(|s| s.to_string()), catalog_number: tag
.get_string(&ItemKey::CatalogNumber)
.map(|s| s.to_string()),
duration_secs, duration_secs,
format, format,
bit_depth, bit_depth,
@@ -474,8 +498,7 @@ impl MetadataExtractor {
id: 0, id: 0,
file_path: file_path.to_string_lossy().to_string(), file_path: file_path.to_string_lossy().to_string(),
title: filename, title: filename,
artist: fallback_artist artist: fallback_artist.unwrap_or_else(|| "Unknown Artist".to_string()),
.unwrap_or_else(|| "Unknown Artist".to_string()),
album: album_title, album: album_title,
album_artist: None, album_artist: None,
album_group_key, album_group_key,
@@ -546,7 +569,9 @@ impl MetadataExtractor {
/// Extract and save artwork as thumbnail to cache directory /// Extract and save artwork as thumbnail to cache directory
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> { pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
let tagged_file = Probe::open(file_path).ok()?.read().ok()?; let tagged_file = Probe::open(file_path).ok()?.read().ok()?;
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag())?; let tag = tagged_file
.primary_tag()
.or_else(|| tagged_file.first_tag())?;
let picture = tag.pictures().first()?; let picture = tag.pictures().first()?;
@@ -645,11 +670,8 @@ impl MetadataExtractor {
} }
}; };
let mut score = Self::artwork_score( let mut score =
&file_key, Self::artwork_score(&file_key, album_key.as_deref(), folder_key.as_deref());
album_key.as_deref(),
folder_key.as_deref(),
);
if score == 0 { if score == 0 {
score = 5; score = 5;
} }
@@ -692,11 +714,7 @@ impl MetadataExtractor {
matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp") matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
} }
fn artwork_score( fn artwork_score(file_key: &str, album_key: Option<&str>, folder_key: Option<&str>) -> i32 {
file_key: &str,
album_key: Option<&str>,
folder_key: Option<&str>,
) -> i32 {
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"]; const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
let mut score = 0; let mut score = 0;
@@ -752,9 +770,15 @@ mod tests {
#[test] #[test]
fn test_is_encoding_folder() { fn test_is_encoding_folder() {
// QBZ-generated quality folder names // QBZ-generated quality folder names
assert!(MetadataExtractor::is_encoding_folder("FLAC 16-bit - 44.1 kHz")); assert!(MetadataExtractor::is_encoding_folder(
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 96 kHz")); "FLAC 16-bit - 44.1 kHz"
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 192 kHz")); ));
assert!(MetadataExtractor::is_encoding_folder(
"FLAC 24-bit - 96 kHz"
));
assert!(MetadataExtractor::is_encoding_folder(
"FLAC 24-bit - 192 kHz"
));
assert!(MetadataExtractor::is_encoding_folder("MP3 320 kbps")); assert!(MetadataExtractor::is_encoding_folder("MP3 320 kbps"));
// Common encoding folder names from other tools // Common encoding folder names from other tools
@@ -792,7 +816,8 @@ mod tests {
#[test] #[test]
fn test_album_root_dir_encoding_folder() { fn test_album_root_dir_encoding_folder() {
// artist/album/quality/track.flac -> album/ // artist/album/quality/track.flac -> album/
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/01 - Novocaine.flac"); let path =
Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/01 - Novocaine.flac");
let root = MetadataExtractor::album_root_dir(path).unwrap(); let root = MetadataExtractor::album_root_dir(path).unwrap();
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak")); assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
} }
@@ -800,7 +825,9 @@ mod tests {
#[test] #[test]
fn test_album_root_dir_encoding_and_disc() { fn test_album_root_dir_encoding_and_disc() {
// artist/album/quality/disc1/track.flac -> album/ // artist/album/quality/disc1/track.flac -> album/
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/Disc 1/01 - Novocaine.flac"); let path = Path::new(
"/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/Disc 1/01 - Novocaine.flac",
);
let root = MetadataExtractor::album_root_dir(path).unwrap(); let root = MetadataExtractor::album_root_dir(path).unwrap();
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak")); assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
} }
@@ -809,16 +836,22 @@ mod tests {
fn test_infer_track_number_from_filename() { fn test_infer_track_number_from_filename() {
// Common patterns: "01 - Title" // Common patterns: "01 - Title"
assert_eq!( assert_eq!(
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/01 - Novocaine.flac")), MetadataExtractor::infer_track_number_from_filename(Path::new(
"/music/01 - Novocaine.flac"
)),
Some(1) Some(1)
); );
assert_eq!( assert_eq!(
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/12 - Beautiful Freak.flac")), MetadataExtractor::infer_track_number_from_filename(Path::new(
"/music/12 - Beautiful Freak.flac"
)),
Some(12) Some(12)
); );
// "01. Title" // "01. Title"
assert_eq!( assert_eq!(
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/03. Song Name.flac")), MetadataExtractor::infer_track_number_from_filename(Path::new(
"/music/03. Song Name.flac"
)),
Some(3) Some(3)
); );
// "01_Title" // "01_Title"
@@ -848,7 +881,9 @@ mod tests {
); );
// Zero is not a valid track number // Zero is not a valid track number
assert_eq!( assert_eq!(
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/00 - Intro.flac")), MetadataExtractor::infer_track_number_from_filename(Path::new(
"/music/00 - Intro.flac"
)),
None None
); );
} }
@@ -873,11 +908,17 @@ mod tests {
#[test] #[test]
fn test_is_disc_folder_false_album_names() { fn test_is_disc_folder_false_album_names() {
// Album names containing disc/cd keywords — NOT disc folders (issue #147) // Album names containing disc/cd keywords — NOT disc folders (issue #147)
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics, Disc 1")); assert!(!MetadataExtractor::is_disc_folder(
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1")); "100 Popular Classics, Disc 1"
));
assert!(!MetadataExtractor::is_disc_folder(
"100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1"
));
assert!(!MetadataExtractor::is_disc_folder("Relaxation Disc1")); assert!(!MetadataExtractor::is_disc_folder("Relaxation Disc1"));
assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1")); assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1"));
assert!(!MetadataExtractor::is_disc_folder("Match of the Day - The Album CD1")); assert!(!MetadataExtractor::is_disc_folder(
"Match of the Day - The Album CD1"
));
assert!(!MetadataExtractor::is_disc_folder("20 Blues Greats")); assert!(!MetadataExtractor::is_disc_folder("20 Blues Greats"));
assert!(!MetadataExtractor::is_disc_folder("The Beatles")); assert!(!MetadataExtractor::is_disc_folder("The Beatles"));
assert!(!MetadataExtractor::is_disc_folder("Abbey Road")); assert!(!MetadataExtractor::is_disc_folder("Abbey Road"));
@@ -888,7 +929,10 @@ mod tests {
// Issue #147: album names containing "Disc N" should NOT be treated as disc folders // Issue #147: album names containing "Disc N" should NOT be treated as disc folders
let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac"); let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac");
let root = MetadataExtractor::album_root_dir(path).unwrap(); let root = MetadataExtractor::album_root_dir(path).unwrap();
assert_eq!(root, Path::new("/music/Various Artists/100 Popular Classics, Disc 1")); assert_eq!(
root,
Path::new("/music/Various Artists/100 Popular Classics, Disc 1")
);
let path = Path::new("/music/Various Artists/Relaxation Disc1/01 - Track.flac"); let path = Path::new("/music/Various Artists/Relaxation Disc1/01 - Track.flac");
let root = MetadataExtractor::album_root_dir(path).unwrap(); let root = MetadataExtractor::album_root_dir(path).unwrap();
+4 -4
View File
@@ -57,7 +57,7 @@ pub struct LocalTrack {
pub duration_secs: u64, pub duration_secs: u64,
pub format: AudioFormat, pub format: AudioFormat,
pub bit_depth: Option<u32>, pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 to support fractional rates (44.1kHz = 44100Hz) pub sample_rate: f64, // Changed from u32 to f64 to support fractional rates (44.1kHz = 44100Hz)
pub channels: u8, pub channels: u8,
pub file_size_bytes: u64, pub file_size_bytes: u64,
@@ -97,7 +97,7 @@ impl Default for LocalTrack {
duration_secs: 0, duration_secs: 0,
format: AudioFormat::Unknown, format: AudioFormat::Unknown,
bit_depth: None, bit_depth: None,
sample_rate: 44100.0, // Now f64 sample_rate: 44100.0, // Now f64
channels: 2, channels: 2,
file_size_bytes: 0, file_size_bytes: 0,
cue_file_path: None, cue_file_path: None,
@@ -138,7 +138,7 @@ pub struct LocalAlbum {
pub total_duration_secs: u64, pub total_duration_secs: u64,
pub format: AudioFormat, pub format: AudioFormat,
pub bit_depth: Option<u32>, pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
pub directory_path: String, pub directory_path: String,
/// Source of the album: "user" for local files, "qobuz_download" for offline cached /// Source of the album: "user" for local files, "qobuz_download" for offline cached
#[serde(default = "default_source")] #[serde(default = "default_source")]
@@ -201,7 +201,7 @@ pub struct ScanError {
pub struct AudioProperties { pub struct AudioProperties {
pub duration_secs: u64, pub duration_secs: u64,
pub bit_depth: Option<u32>, pub bit_depth: Option<u32>,
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
pub channels: u8, pub channels: u8,
} }
+16 -3
View File
@@ -73,7 +73,10 @@ pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, L
Ok(Some(sidecar)) Ok(Some(sidecar))
} }
pub fn write_album_sidecar(album_dir: &Path, sidecar: &AlbumTagSidecar) -> Result<(), LibraryError> { pub fn write_album_sidecar(
album_dir: &Path,
sidecar: &AlbumTagSidecar,
) -> Result<(), LibraryError> {
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?; fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
let target = sidecar_path(album_dir); let target = sidecar_path(album_dir);
@@ -96,12 +99,22 @@ pub fn delete_album_sidecar(album_dir: &Path) -> Result<(), LibraryError> {
} }
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) { pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
if let Some(title) = sidecar.album.album_title.as_ref().and_then(|s| normalize(s)) { if let Some(title) = sidecar
.album
.album_title
.as_ref()
.and_then(|s| normalize(s))
{
track.album = title.clone(); track.album = title.clone();
track.album_group_title = title.clone(); track.album_group_title = title.clone();
} }
if let Some(album_artist) = sidecar.album.album_artist.as_ref().and_then(|s| normalize(s)) { if let Some(album_artist) = sidecar
.album
.album_artist
.as_ref()
.and_then(|s| normalize(s))
{
track.album_artist = Some(album_artist.clone()); track.album_artist = Some(album_artist.clone());
} }
+6 -4
View File
@@ -20,8 +20,9 @@ pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> {
// Create directory if it doesn't exist // Create directory if it doesn't exist
if !thumbnails_dir.exists() { if !thumbnails_dir.exists() {
fs::create_dir_all(&thumbnails_dir) fs::create_dir_all(&thumbnails_dir).map_err(|e| {
.map_err(|e| LibraryError::Other(format!("Failed to create thumbnails directory: {}", e)))?; LibraryError::Other(format!("Failed to create thumbnails directory: {}", e))
})?;
} }
Ok(thumbnails_dir) Ok(thumbnails_dir)
@@ -135,8 +136,9 @@ pub fn clear_thumbnails() -> Result<(), LibraryError> {
if thumbnails_dir.exists() { if thumbnails_dir.exists() {
fs::remove_dir_all(&thumbnails_dir) fs::remove_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?; .map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?;
fs::create_dir_all(&thumbnails_dir) fs::create_dir_all(&thumbnails_dir).map_err(|e| {
.map_err(|e| LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e)))?; LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e))
})?;
} }
Ok(()) Ok(())
+22 -68
View File
@@ -20,9 +20,7 @@ pub enum CoreEvent {
}, },
/// Playback state changed (play/pause/stop) /// Playback state changed (play/pause/stop)
PlaybackStateChanged { PlaybackStateChanged { state: PlaybackState },
state: PlaybackState,
},
/// Playback position updated (periodic, e.g., every second) /// Playback position updated (periodic, e.g., every second)
PositionUpdated { PositionUpdated {
@@ -31,41 +29,27 @@ pub enum CoreEvent {
}, },
/// Track finished playing naturally /// Track finished playing naturally
TrackEnded { TrackEnded { track_id: u64 },
track_id: u64,
},
/// Volume changed /// Volume changed
VolumeChanged { VolumeChanged { volume: f32 },
volume: f32,
},
/// Full playback status update /// Full playback status update
PlaybackStatusUpdated { PlaybackStatusUpdated { status: PlaybackStatus },
status: PlaybackStatus,
},
// ============ Queue Events ============ // ============ Queue Events ============
/// Queue state changed (tracks added/removed/reordered) /// Queue state changed (tracks added/removed/reordered)
QueueUpdated { QueueUpdated { state: QueueState },
state: QueueState,
},
/// Shuffle mode changed /// Shuffle mode changed
ShuffleChanged { ShuffleChanged { enabled: bool },
enabled: bool,
},
/// Repeat mode changed /// Repeat mode changed
RepeatModeChanged { RepeatModeChanged { mode: crate::playback::RepeatMode },
mode: crate::playback::RepeatMode,
},
// ============ Authentication Events ============ // ============ Authentication Events ============
/// User logged in successfully /// User logged in successfully
LoggedIn { LoggedIn { session: UserSession },
session: UserSession,
},
/// User logged out /// User logged out
LoggedOut, LoggedOut,
@@ -81,41 +65,26 @@ pub enum CoreEvent {
}, },
/// Playlist created /// Playlist created
PlaylistCreated { PlaylistCreated { playlist: Playlist },
playlist: Playlist,
},
/// Playlist updated (tracks added/removed) /// Playlist updated (tracks added/removed)
PlaylistUpdated { PlaylistUpdated { playlist_id: u64 },
playlist_id: u64,
},
/// Playlist deleted /// Playlist deleted
PlaylistDeleted { PlaylistDeleted { playlist_id: u64 },
playlist_id: u64,
},
// ============ Loading/Progress Events ============ // ============ Loading/Progress Events ============
/// Loading started for an operation /// Loading started for an operation
LoadingStarted { LoadingStarted { operation: String },
operation: String,
},
/// Loading completed /// Loading completed
LoadingCompleted { LoadingCompleted { operation: String },
operation: String,
},
/// Download progress for offline content /// Download progress for offline content
DownloadProgress { DownloadProgress { track_id: u64, progress_percent: u8 },
track_id: u64,
progress_percent: u8,
},
/// Download completed /// Download completed
DownloadCompleted { DownloadCompleted { track_id: u64 },
track_id: u64,
},
// ============ Error Events ============ // ============ Error Events ============
/// An error occurred /// An error occurred
@@ -127,21 +96,14 @@ pub enum CoreEvent {
}, },
/// Playback error (track couldn't play) /// Playback error (track couldn't play)
PlaybackError { PlaybackError { track_id: u64, message: String },
track_id: u64,
message: String,
},
/// Network error /// Network error
NetworkError { NetworkError { message: String },
message: String,
},
// ============ Audio System Events ============ // ============ Audio System Events ============
/// Audio device changed /// Audio device changed
AudioDeviceChanged { AudioDeviceChanged { device_name: String },
device_name: String,
},
/// Audio backend changed /// Audio backend changed
AudioBackendChanged { AudioBackendChanged {
@@ -150,9 +112,7 @@ pub enum CoreEvent {
}, },
/// Audio system diagnostic info /// Audio system diagnostic info
AudioDiagnostic { AudioDiagnostic { message: String },
message: String,
},
// ============ Search Events ============ // ============ Search Events ============
/// Search results received /// Search results received
@@ -163,17 +123,11 @@ pub enum CoreEvent {
// ============ Navigation Hints ============ // ============ Navigation Hints ============
/// Suggest navigation to album /// Suggest navigation to album
NavigateToAlbum { NavigateToAlbum { album_id: String },
album_id: String,
},
/// Suggest navigation to artist /// Suggest navigation to artist
NavigateToArtist { NavigateToArtist { artist_id: u64 },
artist_id: u64,
},
/// Suggest navigation to playlist /// Suggest navigation to playlist
NavigateToPlaylist { NavigateToPlaylist { playlist_id: u64 },
playlist_id: u64,
},
} }
+66 -15
View File
@@ -41,21 +41,72 @@ pub use events::CoreEvent;
pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode}; pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode};
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter}; pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
pub use types::{ pub use types::{
Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo, Album,
GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail, AlbumSummary,
LabelExploreResponse, LabelPageContainer, LabelPageData, LabelPageGenericList, Playlist, Artist,
PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality, ArtistAlbums,
SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer, ArtistBiography,
UserSession,
// Discover types // Discover types
DiscoverAlbum, DiscoverAlbumDates, DiscoverAlbumImage, DiscoverArtist, DiscoverAudioInfo, DiscoverAlbum,
DiscoverContainer, DiscoverContainers, DiscoverData, DiscoverPlaylist, DiscoverPlaylistImage, DiscoverAlbumDates,
DiscoverPlaylistsResponse, DiscoverResponse, PlaylistTag, PlaylistTagsResponse, RawPlaylistTag, DiscoverAlbumImage,
DiscoverArtist,
DiscoverAudioInfo,
DiscoverContainer,
DiscoverContainers,
DiscoverData,
DiscoverPlaylist,
DiscoverPlaylistImage,
DiscoverPlaylistsResponse,
DiscoverResponse,
Favorites,
Genre,
GenreInfo,
GenreListContainer,
GenreListResponse,
ImageSet,
Label,
LabelDetail,
LabelExploreResponse,
LabelPageContainer,
LabelPageData,
LabelPageGenericList,
// Artist page types // Artist page types
PageArtistAward, PageArtistBiography, PageArtistImages, PageArtistName, PageArtistAward,
PageArtistPhysicalSupport, PageArtistPlaylist, PageArtistPlaylistImages, PageArtistBiography,
PageArtistPlaylistOwner, PageArtistPlaylists, PageArtistPortrait, PageArtistRelease, PageArtistImages,
PageArtistReleaseArtist, PageArtistReleaseContributor, PageArtistReleaseGroup, PageArtistName,
PageArtistResponse, PageArtistRights, PageArtistSimilar, PageArtistSimilarItem, PageArtistPhysicalSupport,
PageArtistTrack, PageArtistTrackAlbum, ReleasesGridResponse, PageArtistPlaylist,
PageArtistPlaylistImages,
PageArtistPlaylistOwner,
PageArtistPlaylists,
PageArtistPortrait,
PageArtistRelease,
PageArtistReleaseArtist,
PageArtistReleaseContributor,
PageArtistReleaseGroup,
PageArtistResponse,
PageArtistRights,
PageArtistSimilar,
PageArtistSimilarItem,
PageArtistTrack,
PageArtistTrackAlbum,
Playlist,
PlaylistDuplicateResult,
PlaylistGenre,
PlaylistOwner,
PlaylistTag,
PlaylistTagsResponse,
PlaylistWithTrackIds,
Quality,
RawPlaylistTag,
ReleasesGridResponse,
SearchResults,
SearchResultsPage,
StreamRestriction,
StreamUrl,
Track,
TracksContainer,
UserSession,
}; };
+4 -10
View File
@@ -31,18 +31,12 @@
//! let queue = QueueManager::new(); //! let queue = QueueManager::new();
//! ``` //! ```
pub mod queue;
pub mod player; pub mod player;
pub mod queue;
// Re-export main types // Re-export main types
pub use queue::QueueManager;
pub use player::{ pub use player::{
Player, BufferWriter, BufferedMediaSource, IncrementalStreamingSource, PlaybackEvent, PlaybackState,
SharedState, Player, SharedState, StreamingConfig,
PlaybackState,
PlaybackEvent,
BufferedMediaSource,
BufferWriter,
StreamingConfig,
IncrementalStreamingSource,
}; };
pub use queue::QueueManager;
File diff suppressed because it is too large. Load diff
+34 -19
View File
@@ -67,9 +67,7 @@ impl SourceQueue {
/// Unified playback engine /// Unified playback engine
pub enum PlaybackEngine { pub enum PlaybackEngine {
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL) /// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
Rodio { Rodio { sink: RodioPlayer },
sink: RodioPlayer,
},
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue /// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
AlsaDirect { AlsaDirect {
stream: Arc<AlsaDirectStream>, stream: Arc<AlsaDirectStream>,
@@ -115,8 +113,14 @@ impl PlaybackEngine {
thread::spawn(move || { thread::spawn(move || {
alsa_writer_thread( alsa_writer_thread(
stream_c, playing_c, stop_c, pos_c, dur_c, stream_c,
queue_c, transition_c, channels, playing_c,
stop_c,
pos_c,
dur_c,
queue_c,
transition_c,
channels,
); );
}) })
}; };
@@ -154,8 +158,7 @@ impl PlaybackEngine {
source_transition, source_transition,
.. ..
} => { } => {
let is_first = source_queue.is_empty() let is_first = source_queue.is_empty() && !is_playing.load(Ordering::SeqCst);
&& !is_playing.load(Ordering::SeqCst);
// Box the source iterator and push to queue // Box the source iterator and push to queue
let boxed: BoxedSampleIter = Box::new(source.into_iter()); let boxed: BoxedSampleIter = Box::new(source.into_iter());
@@ -241,7 +244,11 @@ impl PlaybackEngine {
pub fn set_volume(&self, volume: f32) { pub fn set_volume(&self, volume: f32) {
match self { match self {
Self::Rodio { sink } => sink.set_volume(volume), Self::Rodio { sink } => sink.set_volume(volume),
Self::AlsaDirect { stream, hardware_volume, .. } => { Self::AlsaDirect {
stream,
hardware_volume,
..
} => {
if *hardware_volume { if *hardware_volume {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@@ -250,7 +257,9 @@ impl PlaybackEngine {
} }
} }
} else { } else {
log::debug!("[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"); log::debug!(
"[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"
);
} }
} }
} }
@@ -264,9 +273,7 @@ impl PlaybackEngine {
is_playing, is_playing,
source_queue, source_queue,
.. ..
} => { } => !is_playing.load(Ordering::SeqCst) && source_queue.is_empty(),
!is_playing.load(Ordering::SeqCst) && source_queue.is_empty()
}
} }
} }
@@ -275,11 +282,11 @@ impl PlaybackEngine {
pub fn take_source_transition(&self) -> bool { pub fn take_source_transition(&self) -> bool {
match self { match self {
Self::Rodio { .. } => false, Self::Rodio { .. } => false,
Self::AlsaDirect { source_transition, .. } => { Self::AlsaDirect {
source_transition.compare_exchange( source_transition, ..
true, false, Ordering::SeqCst, Ordering::SeqCst } => source_transition
).is_ok() .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
} .is_ok(),
} }
} }
@@ -288,7 +295,11 @@ impl PlaybackEngine {
pub fn position_secs(&self) -> Option<u64> { pub fn position_secs(&self) -> Option<u64> {
match self { match self {
Self::Rodio { .. } => None, Self::Rodio { .. } => None,
Self::AlsaDirect { position_frames, stream, .. } => { Self::AlsaDirect {
position_frames,
stream,
..
} => {
let frames = position_frames.load(Ordering::SeqCst); let frames = position_frames.load(Ordering::SeqCst);
let sample_rate = stream.sample_rate() as u64; let sample_rate = stream.sample_rate() as u64;
Some(frames / sample_rate) Some(frames / sample_rate)
@@ -301,7 +312,11 @@ impl PlaybackEngine {
pub fn duration_secs(&self) -> Option<u64> { pub fn duration_secs(&self) -> Option<u64> {
match self { match self {
Self::Rodio { .. } => None, Self::Rodio { .. } => None,
Self::AlsaDirect { duration_frames, stream, .. } => { Self::AlsaDirect {
duration_frames,
stream,
..
} => {
let frames = duration_frames.load(Ordering::SeqCst); let frames = duration_frames.load(Ordering::SeqCst);
let sample_rate = stream.sample_rate() as u64; let sample_rate = stream.sample_rate() as u64;
Some(frames / sample_rate) Some(frames / sample_rate)
@@ -185,17 +185,17 @@ impl BufferedMediaSource {
/// Returns error if download fails before initial buffer is filled. /// Returns error if download fails before initial buffer is filled.
pub fn wait_for_initial_buffer(&self) -> IoResult<()> { pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
let (lock, cvar) = &*self.state; let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| { let mut state = lock
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock") .lock()
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
while state.data.len() < self.config.initial_buffer_bytes while state.data.len() < self.config.initial_buffer_bytes
&& !state.download_complete && !state.download_complete
&& state.download_error.is_none() && state.download_error.is_none()
{ {
state = cvar.wait(state).map_err(|_| { state = cvar
IoError::new(ErrorKind::Other, "Condition variable wait failed") .wait(state)
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
} }
if let Some(ref err) = state.download_error { if let Some(ref err) = state.download_error {
@@ -297,9 +297,9 @@ impl Read for BufferedMediaSource {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state; let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| { let mut state = lock
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock") .lock()
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize; let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
@@ -308,9 +308,9 @@ impl Read for BufferedMediaSource {
&& !state.download_complete && !state.download_complete
&& state.download_error.is_none() && state.download_error.is_none()
{ {
state = cvar.wait(state).map_err(|_| { state = cvar
IoError::new(ErrorKind::Other, "Condition variable wait failed") .wait(state)
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
} }
// Check for errors // Check for errors
@@ -327,7 +327,8 @@ impl Read for BufferedMediaSource {
let available = state.data.len() - read_pos; let available = state.data.len() - read_pos;
let to_read = buf.len().min(available); let to_read = buf.len().min(available);
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]); buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
self.read_pos.store((read_pos + to_read) as u64, Ordering::SeqCst); self.read_pos
.store((read_pos + to_read) as u64, Ordering::SeqCst);
Ok(to_read) Ok(to_read)
} }
@@ -338,9 +339,9 @@ impl Seek for BufferedMediaSource {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state; let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| { let mut state = lock
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock") .lock()
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64; let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
@@ -377,9 +378,9 @@ impl Seek for BufferedMediaSource {
&& !state.download_complete && !state.download_complete
&& state.download_error.is_none() && state.download_error.is_none()
{ {
state = cvar.wait(state).map_err(|_| { state = cvar
IoError::new(ErrorKind::Other, "Condition variable wait failed") .wait(state)
})?; .map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
} }
if let Some(ref err) = state.download_error { if let Some(ref err) = state.download_error {
@@ -515,9 +516,7 @@ impl IncrementalStreamingSource {
/// The BufferedMediaSource should already have its initial buffer filled. /// The BufferedMediaSource should already have its initial buffer filled.
/// ///
/// Returns the source along with detected sample_rate and channels. /// Returns the source along with detected sample_rate and channels.
pub fn new( pub fn new(buffered_source: Arc<BufferedMediaSource>) -> Result<Self, String> {
buffered_source: Arc<BufferedMediaSource>,
) -> Result<Self, String> {
// Create a reader from the buffered source // Create a reader from the buffered source
let reader = buffered_source.create_reader(); let reader = buffered_source.create_reader();
let media_source = Box::new(reader) as Box<dyn MediaSource>; let media_source = Box::new(reader) as Box<dyn MediaSource>;
@@ -548,10 +547,7 @@ impl IncrementalStreamingSource {
let sample_rate = codec_params let sample_rate = codec_params
.sample_rate .sample_rate
.ok_or_else(|| "No sample rate in codec params".to_string())?; .ok_or_else(|| "No sample rate in codec params".to_string())?;
let channels = codec_params let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
.channels
.map(|c| c.count() as u16)
.unwrap_or(2);
let decoder = get_codecs() let decoder = get_codecs()
.make(&codec_params, &DecoderOptions::default()) .make(&codec_params, &DecoderOptions::default())
@@ -603,7 +599,9 @@ impl IncrementalStreamingSource {
while self.sample_queue.len() < min_samples { while self.sample_queue.len() < min_samples {
let packet = match self.format.next_packet() { let packet = match self.format.next_packet() {
Ok(packet) => packet, Ok(packet) => packet,
Err(SymphoniaError::IoError(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => { Err(SymphoniaError::IoError(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock =>
{
// Not enough data buffered yet - wait briefly and retry // Not enough data buffered yet - wait briefly and retry
// This happens when playback catches up with download // This happens when playback catches up with download
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
@@ -636,7 +634,8 @@ impl IncrementalStreamingSource {
sample_buf.copy_interleaved_ref(audio_buf); sample_buf.copy_interleaved_ref(audio_buf);
// Add samples to queue // Add samples to queue
self.sample_queue.extend(sample_buf.samples().iter().copied()); self.sample_queue
.extend(sample_buf.samples().iter().copied());
self.packets_decoded += 1; self.packets_decoded += 1;
} }
Err(SymphoniaError::DecodeError(e)) => { Err(SymphoniaError::DecodeError(e)) => {
+12 -3
View File
@@ -271,7 +271,10 @@ impl QueueManager {
// In shuffle mode, DnD indices come from the visible upcoming list, // In shuffle mode, DnD indices come from the visible upcoming list,
// so they must be applied to shuffle_order positions (not absolute // so they must be applied to shuffle_order positions (not absolute
// track indices in state.tracks). // track indices in state.tracks).
let base_pos = state.current_index.map(|_| state.shuffle_position + 1).unwrap_or(0); let base_pos = state
.current_index
.map(|_| state.shuffle_position + 1)
.unwrap_or(0);
let from_pos = base_pos + from_index; let from_pos = base_pos + from_index;
let to_pos = base_pos + to_index; let to_pos = base_pos + to_index;
@@ -717,12 +720,18 @@ impl QueueManager {
/// Remove one absolute track index from shuffle order and rebase remaining indices. /// Remove one absolute track index from shuffle order and rebase remaining indices.
fn remove_index_from_shuffle_internal(state: &mut InternalState, removed_idx: usize) { fn remove_index_from_shuffle_internal(state: &mut InternalState, removed_idx: usize) {
if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == removed_idx) { if let Some(pos) = state
.shuffle_order
.iter()
.position(|&idx| idx == removed_idx)
{
state.shuffle_order.remove(pos); state.shuffle_order.remove(pos);
if pos < state.shuffle_position && state.shuffle_position > 0 { if pos < state.shuffle_position && state.shuffle_position > 0 {
state.shuffle_position -= 1; state.shuffle_position -= 1;
} else if pos == state.shuffle_position && state.shuffle_position >= state.shuffle_order.len() { } else if pos == state.shuffle_position
&& state.shuffle_position >= state.shuffle_order.len()
{
state.shuffle_position = state.shuffle_order.len().saturating_sub(1); state.shuffle_position = state.shuffle_order.len().saturating_sub(1);
} }
} }
+7 -5
View File
@@ -19,10 +19,7 @@ pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &s
/// Generate signature for track/getFileUrl endpoint /// Generate signature for track/getFileUrl endpoint
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String { pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
let params = format!( let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id);
"format_id{}intentstreamtrack_id{}",
format_id, track_id
);
generate_signature("trackgetFileUrl", &params, timestamp, secret) generate_signature("trackgetFileUrl", &params, timestamp, secret)
} }
@@ -97,7 +94,12 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
} }
// Try common timestamp fields (seconds). // Try common timestamp fields (seconds).
let ts_keys = ["end_date_ts", "expires_at_ts", "expiration_ts", "valid_until_ts"]; let ts_keys = [
"end_date_ts",
"expires_at_ts",
"expiration_ts",
"valid_until_ts",
];
for key in ts_keys { for key in ts_keys {
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) { if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
if ts > 0 { if ts > 0 {
+29 -23
View File
@@ -24,23 +24,13 @@ pub struct BundleTokens {
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle /// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> { pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
// Step 1: Get login page to find bundle URL // Step 1: Get login page to find bundle URL
let login_page = client let login_page = client.get(LOGIN_PAGE_URL).send().await?.text().await?;
.get(LOGIN_PAGE_URL)
.send()
.await?
.text()
.await?;
let bundle_url = extract_bundle_url(&login_page)?; let bundle_url = extract_bundle_url(&login_page)?;
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url); let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
// Step 2: Fetch the bundle // Step 2: Fetch the bundle
let bundle_content = client let bundle_content = client.get(&full_bundle_url).send().await?.text().await?;
.get(&full_bundle_url)
.send()
.await?
.text()
.await?;
// Step 3: Extract app_id // Step 3: Extract app_id
let app_id = extract_app_id(&bundle_content)?; let app_id = extract_app_id(&bundle_content)?;
@@ -62,13 +52,18 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
log::debug!("OAuth private_key not found in bundle (older bundle version)"); log::debug!("OAuth private_key not found in bundle (older bundle version)");
} }
Ok(BundleTokens { app_id, secrets, private_key }) Ok(BundleTokens {
app_id,
secrets,
private_key,
})
} }
fn extract_bundle_url(html: &str) -> Result<String> { fn extract_bundle_url(html: &str) -> Result<String> {
// Pattern: <script src="/resources/X.X.X-bXXX/bundle.js"></script> // Pattern: <script src="/resources/X.X.X-bXXX/bundle.js"></script>
let re = Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#) let re =
.expect("Invalid regex"); Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)
.expect("Invalid regex");
re.captures(html) re.captures(html)
.and_then(|caps| caps.get(1)) .and_then(|caps| caps.get(1))
@@ -78,8 +73,7 @@ fn extract_bundle_url(html: &str) -> Result<String> {
fn extract_app_id(bundle: &str) -> Result<String> { fn extract_app_id(bundle: &str) -> Result<String> {
// Pattern: production:{api:{appId:"XXXXXXXXX" // Pattern: production:{api:{appId:"XXXXXXXXX"
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#) let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#).expect("Invalid regex");
.expect("Invalid regex");
re.captures(bundle) re.captures(bundle)
.and_then(|caps| caps.name("app_id")) .and_then(|caps| caps.name("app_id"))
@@ -106,10 +100,16 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
} }
} }
log::debug!("Found {} seeds with timezones: {:?}", seeds.len(), timezones); log::debug!(
"Found {} seeds with timezones: {:?}",
seeds.len(),
timezones
);
if seeds.is_empty() { if seeds.is_empty() {
return Err(ApiError::BundleExtractionError("No seeds found".to_string())); return Err(ApiError::BundleExtractionError(
"No seeds found".to_string(),
));
} }
// Build dynamic regex with found timezones (capitalize first letter for matching) // Build dynamic regex with found timezones (capitalize first letter for matching)
@@ -149,7 +149,11 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
if let Some(seed) = seeds.get(&tz_lower) { if let Some(seed) = seeds.get(&tz_lower) {
// Concatenate seed + info + extras, remove last 44 chars, base64 decode // Concatenate seed + info + extras, remove last 44 chars, base64 decode
let combined = format!("{}{}{}", seed, info.as_str(), extras.as_str()); let combined = format!("{}{}{}", seed, info.as_str(), extras.as_str());
log::debug!("Combined length: {}, timezone: {}", combined.len(), tz_lower); log::debug!(
"Combined length: {}, timezone: {}",
combined.len(),
tz_lower
);
if combined.len() > 44 { if combined.len() > 44 {
let trimmed = &combined[..combined.len() - 44]; let trimmed = &combined[..combined.len() - 44];
@@ -159,7 +163,10 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
) { ) {
Ok(decoded) => { Ok(decoded) => {
if let Ok(secret) = String::from_utf8(decoded) { if let Ok(secret) = String::from_utf8(decoded) {
log::info!("Successfully extracted secret for timezone: {}", tz_lower); log::info!(
"Successfully extracted secret for timezone: {}",
tz_lower
);
secrets.push(secret); secrets.push(secret);
} }
} }
@@ -190,8 +197,7 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
fn extract_private_key(bundle: &str) -> Option<String> { fn extract_private_key(bundle: &str) -> Option<String> {
// Pattern: privateKey:"VALUE" (the static OAuth key used in /oauth/callback) // Pattern: privateKey:"VALUE" (the static OAuth key used in /oauth/callback)
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#) let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#).expect("Invalid regex");
.expect("Invalid regex");
re.captures(bundle) re.captures(bundle)
.and_then(|caps| caps.name("key")) .and_then(|caps| caps.name("key"))
+288 -119
View File
@@ -153,11 +153,14 @@ impl QobuzClient {
*self.session.write().await = Some(session.clone()); *self.session.write().await = Some(session.clone());
Ok(session) Ok(session)
} }
StatusCode::UNAUTHORIZED => { StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
Err(ApiError::AuthenticationError("Invalid credentials".to_string())) "Invalid credentials".to_string(),
} )),
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId), StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))), status => Err(ApiError::ApiResponse(format!(
"Unexpected status: {}",
status
))),
} }
} }
@@ -186,14 +189,12 @@ impl QobuzClient {
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{HeaderMap, HeaderValue};
let tokens = self.tokens.read().await; let tokens = self.tokens.read().await;
let tokens = tokens.as_ref().ok_or_else(|| { let tokens = tokens
ApiError::BundleExtractionError("Client not initialized".to_string()) .as_ref()
})?; .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
let app_id = tokens.app_id.clone(); let app_id = tokens.app_id.clone();
let private_key = tokens.private_key.clone().ok_or_else(|| { let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError( ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string())
"OAuth private key not available in bundle".to_string(),
)
})?; })?;
let _ = tokens; // drop read lock let _ = tokens; // drop read lock
@@ -210,7 +211,11 @@ impl QobuzClient {
.http .http
.get(&callback_url) .get(&callback_url)
.headers(headers) .headers(headers)
.query(&[("code", code), ("private_key", &private_key), ("app_id", &app_id)]) .query(&[
("code", code),
("private_key", &private_key),
("app_id", &app_id),
])
.send() .send()
.await?; .await?;
@@ -240,9 +245,8 @@ impl QobuzClient {
); );
auth_headers.insert( auth_headers.insert(
"X-User-Auth-Token", "X-User-Auth-Token",
HeaderValue::from_str(&token).map_err(|_| { HeaderValue::from_str(&token)
ApiError::AuthenticationError("Invalid OAuth token format".into()) .map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
})?,
); );
let login_response = self let login_response = self
@@ -460,7 +464,12 @@ impl QobuzClient {
} }
/// Get similar artists for an artist ID /// Get similar artists for an artist ID
pub async fn get_similar_artists(&self, artist_id: u64, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> { pub async fn get_similar_artists(
&self,
artist_id: u64,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Artist>> {
let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR); let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR);
let http_response = self let http_response = self
.http .http
@@ -473,7 +482,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_similar_artists({}) status={}", artist_id, http_response.status()); log::debug!(
"[API] get_similar_artists({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let artists = response let artists = response
@@ -484,7 +497,12 @@ impl QobuzClient {
} }
/// Get an artist's tracks (public endpoint via artist/get?extra=tracks) /// Get an artist's tracks (public endpoint via artist/get?extra=tracks)
pub async fn get_artist_tracks(&self, artist_id: u64, limit: u32, offset: u32) -> Result<TracksContainer> { pub async fn get_artist_tracks(
&self,
artist_id: u64,
limit: u32,
offset: u32,
) -> Result<TracksContainer> {
let url = endpoints::build_url(paths::ARTIST_GET); let url = endpoints::build_url(paths::ARTIST_GET);
let locale = self.locale().await; let locale = self.locale().await;
@@ -501,7 +519,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_artist_tracks({}) status={}", artist_id, http_response.status()); log::debug!(
"[API] get_artist_tracks({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let tracks = response let tracks = response
@@ -527,12 +549,21 @@ impl QobuzClient {
log::debug!("[API] get_album({}) status={}", album_id, status); log::debug!("[API] get_album({}) status={}", album_id, status);
if status == StatusCode::NOT_FOUND { if status == StatusCode::NOT_FOUND {
log::warn!("[API] get_album({}) returned 404 — album not found", album_id); log::warn!(
return Err(ApiError::ApiResponse(format!("Album {} not found (404)", album_id))); "[API] get_album({}) returned 404 — album not found",
album_id
);
return Err(ApiError::ApiResponse(format!(
"Album {} not found (404)",
album_id
)));
} }
if !status.is_success() { if !status.is_success() {
log::error!("[API] get_album({}) unexpected status={}", album_id, status); log::error!("[API] get_album({}) unexpected status={}", album_id, status);
return Err(ApiError::ApiResponse(format!("get_album({}) status {}", album_id, status))); return Err(ApiError::ApiResponse(format!(
"get_album({}) status {}",
album_id, status
)));
} }
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
@@ -565,7 +596,11 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::debug!("[API] get_featured_albums({}) status={}", featured_type, http_response.status()); log::debug!(
"[API] get_featured_albums({}) status={}",
featured_type,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let albums = response let albums = response
@@ -579,9 +614,7 @@ impl QobuzClient {
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> { pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
let url = endpoints::build_url(paths::GENRE_LIST); let url = endpoints::build_url(paths::GENRE_LIST);
// Force English for consistent genre names across all user regions // Force English for consistent genre names across all user regions
let mut query: Vec<(&str, String)> = vec![ let mut query: Vec<(&str, String)> = vec![("lang", "en".to_string())];
("lang", "en".to_string()),
];
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
query.push(("parent_id", pid.to_string())); query.push(("parent_id", pid.to_string()));
@@ -594,7 +627,11 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::debug!("[API] get_genres(parent={:?}) status={}", parent_id, http_response.status()); log::debug!(
"[API] get_genres(parent={:?}) status={}",
parent_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let genres = response let genres = response
@@ -616,7 +653,11 @@ impl QobuzClient {
// Add genre_ids as comma-separated list if provided // Add genre_ids as comma-separated list if provided
if let Some(gids) = genre_ids { if let Some(gids) = genre_ids {
if !gids.is_empty() { if !gids.is_empty() {
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let ids_str = gids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
query.push(("genre_ids", ids_str)); query.push(("genre_ids", ids_str));
} }
} }
@@ -633,7 +674,10 @@ impl QobuzClient {
// Debug: log the response structure // Debug: log the response structure
if let Some(obj) = response.as_object() { if let Some(obj) = response.as_object() {
log::info!("Discover API response keys: {:?}", obj.keys().collect::<Vec<_>>()); log::info!(
"Discover API response keys: {:?}",
obj.keys().collect::<Vec<_>>()
);
if let Some(err) = obj.get("message") { if let Some(err) = obj.get("message") {
log::error!("Discover API error: {:?}", err); log::error!("Discover API error: {:?}", err);
} }
@@ -658,7 +702,11 @@ impl QobuzClient {
if let Some(gids) = genre_ids { if let Some(gids) = genre_ids {
if !gids.is_empty() { if !gids.is_empty() {
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let ids_str = gids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
query.push(("genre_ids", ids_str)); query.push(("genre_ids", ids_str));
} }
} }
@@ -673,7 +721,11 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::info!("[API] get_discover_albums({}) status={}", endpoint, http_response.status()); log::info!(
"[API] get_discover_albums({}) status={}",
endpoint,
http_response.status()
);
let response: serde_json::Value = http_response.json().await?; let response: serde_json::Value = http_response.json().await?;
Ok(serde_json::from_value(response)?) Ok(serde_json::from_value(response)?)
@@ -699,7 +751,11 @@ impl QobuzClient {
// Add genre_ids as comma-separated list if provided // Add genre_ids as comma-separated list if provided
if let Some(gids) = genre_ids { if let Some(gids) = genre_ids {
if !gids.is_empty() { if !gids.is_empty() {
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let ids_str = gids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
query.push(("genre_ids", ids_str)); query.push(("genre_ids", ids_str));
} }
} }
@@ -712,7 +768,11 @@ impl QobuzClient {
let off = offset.unwrap_or(0); let off = offset.unwrap_or(0);
query.push(("offset", off.to_string())); query.push(("offset", off.to_string()));
log::debug!("[API] get_discover_playlists URL: {} query: {:?}", url, query); log::debug!(
"[API] get_discover_playlists URL: {} query: {:?}",
url,
query
);
// First get raw JSON to debug structure // First get raw JSON to debug structure
let raw_response: serde_json::Value = self let raw_response: serde_json::Value = self
@@ -725,17 +785,28 @@ impl QobuzClient {
.json() .json()
.await?; .await?;
log::debug!("[API] get_discover_playlists raw response keys: {:?}", raw_response.as_object().map(|o| o.keys().collect::<Vec<_>>())); log::debug!(
"[API] get_discover_playlists raw response keys: {:?}",
raw_response
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
);
// Try to parse as expected structure // Try to parse as expected structure
let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone()) let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone())
.map_err(|e| { .map_err(|e| {
log::error!("[API] Failed to parse discover playlists response: {}", e); log::error!("[API] Failed to parse discover playlists response: {}", e);
log::error!("[API] Raw response: {}", serde_json::to_string_pretty(&raw_response).unwrap_or_default()); log::error!(
"[API] Raw response: {}",
serde_json::to_string_pretty(&raw_response).unwrap_or_default()
);
e e
})?; })?;
log::debug!("[API] get_discover_playlists response: {} playlists", response.items.len()); log::debug!(
"[API] get_discover_playlists response: {} playlists",
response.items.len()
);
Ok(response) Ok(response)
} }
@@ -765,18 +836,22 @@ impl QobuzClient {
.filter(|tag| tag.is_discover.as_deref() == Some("true")) .filter(|tag| tag.is_discover.as_deref() == Some("true"))
.filter_map(|tag| { .filter_map(|tag| {
// Parse name_json to get localized name // Parse name_json to get localized name
let name_map: serde_json::Value = let name_map: serde_json::Value = serde_json::from_str(&tag.name_json).ok()?;
serde_json::from_str(&tag.name_json).ok()?;
let name = name_map let name = name_map
.get(lang) .get(lang)
.or_else(|| name_map.get("en")) .or_else(|| name_map.get("en"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string())?; .map(|s| s.to_string())?;
let id = tag.featured_tag_id let id = tag
.featured_tag_id
.as_ref() .as_ref()
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0); .unwrap_or(0);
Some(PlaylistTag { id, slug: tag.slug, name }) Some(PlaylistTag {
id,
slug: tag.slug,
name,
})
}) })
.collect(); .collect();
@@ -798,12 +873,18 @@ impl QobuzClient {
log::debug!("[API] get_track({}) status={}", track_id, status); log::debug!("[API] get_track({}) status={}", track_id, status);
if status == StatusCode::NOT_FOUND { if status == StatusCode::NOT_FOUND {
log::warn!("[API] get_track({}) returned 404 — track no longer available", track_id); log::warn!(
"[API] get_track({}) returned 404 — track no longer available",
track_id
);
return Err(ApiError::TrackUnavailable(track_id)); return Err(ApiError::TrackUnavailable(track_id));
} }
if !status.is_success() { if !status.is_success() {
log::error!("[API] get_track({}) unexpected status={}", track_id, status); log::error!("[API] get_track({}) unexpected status={}", track_id, status);
return Err(ApiError::ApiResponse(format!("get_track({}) status {}", track_id, status))); return Err(ApiError::ApiResponse(format!(
"get_track({}) status {}",
track_id, status
)));
} }
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
@@ -827,19 +908,20 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::debug!("[API] get_artist_basic({}) status={}", artist_id, http_response.status()); log::debug!(
"[API] get_artist_basic({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?) Ok(serde_json::from_value(response)?)
} }
/// Get artist by ID /// Get artist by ID
pub async fn get_artist( pub async fn get_artist(&self, artist_id: u64, with_albums: bool) -> Result<Artist> {
&self, self.get_artist_with_pagination(artist_id, with_albums, None, None)
artist_id: u64, .await
with_albums: bool,
) -> Result<Artist> {
self.get_artist_with_pagination(artist_id, with_albums, None, None).await
} }
/// Get artist detail by ID with albums, playlists, and appears-on tracks /// Get artist detail by ID with albums, playlists, and appears-on tracks
@@ -870,7 +952,11 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::debug!("[API] get_artist_detail({}) status={}", artist_id, http_response.status()); log::debug!(
"[API] get_artist_detail({}) status={}",
artist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?) Ok(serde_json::from_value(response)?)
@@ -884,7 +970,8 @@ impl QobuzClient {
limit: Option<u32>, limit: Option<u32>,
offset: Option<u32>, offset: Option<u32>,
) -> Result<Artist> { ) -> Result<Artist> {
self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None).await self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None)
.await
} }
/// Get artist by ID with album pagination and optional locale override /// Get artist by ID with album pagination and optional locale override
@@ -902,10 +989,7 @@ impl QobuzClient {
Some(l) => l.to_string(), Some(l) => l.to_string(),
None => self.locale().await, None => self.locale().await,
}; };
let mut query = vec![ let mut query = vec![("artist_id", artist_id.to_string()), ("lang", locale)];
("artist_id", artist_id.to_string()),
("lang", locale),
];
if with_albums { if with_albums {
query.push(("extra", "albums".to_string())); query.push(("extra", "albums".to_string()));
} }
@@ -923,7 +1007,12 @@ impl QobuzClient {
.query(&query) .query(&query)
.send() .send()
.await?; .await?;
log::debug!("[API] get_artist({}, albums={}) status={}", artist_id, with_albums, http_response.status()); log::debug!(
"[API] get_artist({}, albums={}) status={}",
artist_id,
with_albums,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?) Ok(serde_json::from_value(response)?)
@@ -944,7 +1033,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_playlist_track_ids({}) status={}", playlist_id, http_response.status()); log::debug!(
"[API] get_playlist_track_ids({}) status={}",
playlist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let result: PlaylistWithTrackIds = serde_json::from_value(response)?; let result: PlaylistWithTrackIds = serde_json::from_value(response)?;
log::info!( log::info!(
@@ -1025,7 +1118,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_playlist({}) status={}", playlist_id, http_response.status()); log::debug!(
"[API] get_playlist({}) status={}",
playlist_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
let mut playlist: Playlist = serde_json::from_value(response)?; let mut playlist: Playlist = serde_json::from_value(response)?;
@@ -1039,37 +1136,43 @@ impl QobuzClient {
let offsets: Vec<u32> = (fetched..total).step_by(PAGE_SIZE as usize).collect(); let offsets: Vec<u32> = (fetched..total).step_by(PAGE_SIZE as usize).collect();
log::debug!( log::debug!(
"[API] get_playlist({}) fetching {} remaining pages concurrently ({}/{})", "[API] get_playlist({}) fetching {} remaining pages concurrently ({}/{})",
playlist_id, offsets.len(), fetched, total playlist_id,
offsets.len(),
fetched,
total
); );
// Prepare headers once for all concurrent requests // Prepare headers once for all concurrent requests
let headers = self.api_headers().await?; let headers = self.api_headers().await?;
// Launch all page requests concurrently // Launch all page requests concurrently
let futures: Vec<_> = offsets.iter().map(|&offset| { let futures: Vec<_> = offsets
let http = &self.http; .iter()
let url = &url; .map(|&offset| {
let headers = headers.clone(); let http = &self.http;
let pid = playlist_id.to_string(); let url = &url;
let limit = PAGE_SIZE.to_string(); let headers = headers.clone();
let offset_str = offset.to_string(); let pid = playlist_id.to_string();
async move { let limit = PAGE_SIZE.to_string();
let resp = http let offset_str = offset.to_string();
.get(url) async move {
.headers(headers) let resp = http
.query(&[ .get(url)
("playlist_id", pid.as_str()), .headers(headers)
("limit", limit.as_str()), .query(&[
("offset", offset_str.as_str()), ("playlist_id", pid.as_str()),
("extra", "tracks"), ("limit", limit.as_str()),
]) ("offset", offset_str.as_str()),
.send() ("extra", "tracks"),
.await?; ])
let value: Value = resp.json().await?; .send()
let page: Playlist = serde_json::from_value(value)?; .await?;
Ok::<_, anyhow::Error>((offset, page)) let value: Value = resp.json().await?;
} let page: Playlist = serde_json::from_value(value)?;
}).collect(); Ok::<_, anyhow::Error>((offset, page))
}
})
.collect();
let results = futures_util::future::join_all(futures).await; let results = futures_util::future::join_all(futures).await;
@@ -1079,7 +1182,11 @@ impl QobuzClient {
match result { match result {
Ok(page) => pages.push(page), Ok(page) => pages.push(page),
Err(e) => { Err(e) => {
log::warn!("[API] get_playlist({}) page fetch failed: {}", playlist_id, e); log::warn!(
"[API] get_playlist({}) page fetch failed: {}",
playlist_id,
e
);
// Continue with what we have // Continue with what we have
} }
} }
@@ -1127,22 +1234,19 @@ impl QobuzClient {
} }
/// Get label explore (discover more labels) /// Get label explore (discover more labels)
pub async fn get_label_explore( pub async fn get_label_explore(&self, limit: u32, offset: u32) -> Result<LabelExploreResponse> {
&self,
limit: u32,
offset: u32,
) -> Result<LabelExploreResponse> {
let url = endpoints::build_url(paths::LABEL_EXPLORE); let url = endpoints::build_url(paths::LABEL_EXPLORE);
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset); log::debug!(
"[API] get_label_explore(limit={}, offset={})",
limit,
offset
);
let response: serde_json::Value = self let response: serde_json::Value = self
.http .http
.get(&url) .get(&url)
.headers(self.api_headers().await?) .headers(self.api_headers().await?)
.query(&[ .query(&[("limit", limit.to_string()), ("offset", offset.to_string())])
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.send() .send()
.await? .await?
.json() .json()
@@ -1169,7 +1273,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_label({}) status={}", label_id, http_response.status()); log::debug!(
"[API] get_label({}) status={}",
label_id,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
Ok(serde_json::from_value(response)?) Ok(serde_json::from_value(response)?)
@@ -1179,7 +1287,11 @@ impl QobuzClient {
/// Get stream URL for a track (requires auth + signature) /// Get stream URL for a track (requires auth + signature)
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> { pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
log::info!("Getting stream URL for track {} with quality {:?}", track_id, quality); log::info!(
"Getting stream URL for track {} with quality {:?}",
track_id,
quality
);
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL); let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
let timestamp = get_timestamp(); let timestamp = get_timestamp();
log::debug!("Getting secret for signing..."); log::debug!("Getting secret for signing...");
@@ -1206,7 +1318,10 @@ impl QobuzClient {
match response.status() { match response.status() {
StatusCode::OK => { StatusCode::OK => {
let json: Value = response.json().await?; let json: Value = response.json().await?;
log::debug!("Stream URL response JSON keys: {:?}", json.as_object().map(|o| o.keys().collect::<Vec<_>>())); log::debug!(
"Stream URL response JSON keys: {:?}",
json.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
// Check for restrictions // Check for restrictions
let restrictions: Vec<StreamRestriction> = json let restrictions: Vec<StreamRestriction> = json
@@ -1214,12 +1329,17 @@ impl QobuzClient {
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default(); .unwrap_or_default();
// Validate that we got an actual URL (track may be unavailable) // Validate that we got an actual URL (track may be unavailable)
let url = json["url"].as_str().unwrap_or("").to_string(); let url = json["url"].as_str().unwrap_or("").to_string();
if url.is_empty() { if url.is_empty() {
// Log the restriction codes for debugging // Log the restriction codes for debugging
let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect(); let restriction_codes: Vec<&str> =
log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes); restrictions.iter().map(|r| r.code.as_str()).collect();
log::warn!(
"Stream URL missing for track {} - restrictions: {:?}",
track_id,
restriction_codes
);
return Err(ApiError::TrackUnavailable(track_id)); return Err(ApiError::TrackUnavailable(track_id));
} }
@@ -1234,7 +1354,10 @@ impl QobuzClient {
}) })
} }
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret), StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))), status => Err(ApiError::ApiResponse(format!(
"Unexpected status: {}",
status
))),
} }
} }
@@ -1244,7 +1367,11 @@ impl QobuzClient {
track_id: u64, track_id: u64,
preferred: Quality, preferred: Quality,
) -> Result<StreamUrl> { ) -> Result<StreamUrl> {
log::info!("Getting stream URL with fallback for track {}, preferred quality: {:?}", track_id, preferred); log::info!(
"Getting stream URL with fallback for track {}, preferred quality: {:?}",
track_id,
preferred
);
let qualities = Quality::fallback_order(); let qualities = Quality::fallback_order();
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0); let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
@@ -1259,24 +1386,24 @@ impl QobuzClient {
quality.id() quality.id()
); );
return Ok(url); return Ok(url);
}, }
Ok(_) => { Ok(_) => {
log::info!("Quality {:?} has restrictions, trying next", quality); log::info!("Quality {:?} has restrictions, trying next", quality);
continue; continue;
}, }
Err(ApiError::InvalidAppSecret) => { Err(ApiError::InvalidAppSecret) => {
log::error!("Invalid app secret"); log::error!("Invalid app secret");
return Err(ApiError::InvalidAppSecret); return Err(ApiError::InvalidAppSecret);
}, }
Err(ApiError::TrackUnavailable(_)) => { Err(ApiError::TrackUnavailable(_)) => {
// Track is completely unavailable on Qobuz // Track is completely unavailable on Qobuz
track_unavailable = true; track_unavailable = true;
continue; continue;
}, }
Err(e) => { Err(e) => {
log::warn!("Quality {:?} failed: {}, trying next", quality, e); log::warn!("Quality {:?} failed: {}, trying next", quality, e);
continue; continue;
}, }
} }
} }
@@ -1310,7 +1437,11 @@ impl QobuzClient {
]) ])
.send() .send()
.await?; .await?;
log::debug!("[API] get_favorites({}) status={}", fav_type, http_response.status()); log::debug!(
"[API] get_favorites({}) status={}",
fav_type,
http_response.status()
);
let response: Value = http_response.json().await?; let response: Value = http_response.json().await?;
Ok(response) Ok(response)
@@ -1337,7 +1468,12 @@ impl QobuzClient {
} }
/// Search playlists /// Search playlists
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Playlist>> { pub async fn search_playlists(
&self,
query: &str,
limit: u32,
offset: u32,
) -> Result<SearchResultsPage<Playlist>> {
let url = endpoints::build_url(paths::PLAYLIST_SEARCH); let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
let http_response = self let http_response = self
.http .http
@@ -1361,7 +1497,12 @@ impl QobuzClient {
} }
/// Create a new playlist /// Create a new playlist
pub async fn create_playlist(&self, name: &str, description: Option<&str>, is_public: bool) -> Result<Playlist> { pub async fn create_playlist(
&self,
name: &str,
description: Option<&str>,
is_public: bool,
) -> Result<Playlist> {
let url = endpoints::build_url(paths::PLAYLIST_CREATE); let url = endpoints::build_url(paths::PLAYLIST_CREATE);
let mut params = vec![ let mut params = vec![
@@ -1402,7 +1543,11 @@ impl QobuzClient {
/// Add tracks to a playlist /// Add tracks to a playlist
pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> { pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> {
let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS); let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS);
let track_ids_str = track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let track_ids_str = track_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
self.http self.http
.get(&url) .get(&url)
@@ -1418,9 +1563,17 @@ impl QobuzClient {
} }
/// Remove tracks from a playlist /// Remove tracks from a playlist
pub async fn remove_tracks_from_playlist(&self, playlist_id: u64, playlist_track_ids: &[u64]) -> Result<()> { pub async fn remove_tracks_from_playlist(
&self,
playlist_id: u64,
playlist_track_ids: &[u64],
) -> Result<()> {
let url = endpoints::build_url(paths::PLAYLIST_DELETE_TRACKS); let url = endpoints::build_url(paths::PLAYLIST_DELETE_TRACKS);
let track_ids_str = playlist_track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let track_ids_str = playlist_track_ids
.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(",");
self.http self.http
.get(&url) .get(&url)
@@ -1436,7 +1589,13 @@ impl QobuzClient {
} }
/// Update playlist metadata /// Update playlist metadata
pub async fn update_playlist(&self, playlist_id: u64, name: Option<&str>, description: Option<&str>, is_public: Option<bool>) -> Result<Playlist> { pub async fn update_playlist(
&self,
playlist_id: u64,
name: Option<&str>,
description: Option<&str>,
is_public: Option<bool>,
) -> Result<Playlist> {
let url = endpoints::build_url(paths::PLAYLIST_UPDATE); let url = endpoints::build_url(paths::PLAYLIST_UPDATE);
let mut params = vec![("playlist_id", playlist_id.to_string())]; let mut params = vec![("playlist_id", playlist_id.to_string())];
@@ -1479,7 +1638,10 @@ impl QobuzClient {
if response.status().is_success() { if response.status().is_success() {
Ok(()) Ok(())
} else { } else {
Err(ApiError::ApiResponse(format!("Failed to add favorite: {}", response.status()))) Err(ApiError::ApiResponse(format!(
"Failed to add favorite: {}",
response.status()
)))
} }
} }
@@ -1499,7 +1661,10 @@ impl QobuzClient {
if response.status().is_success() { if response.status().is_success() {
Ok(()) Ok(())
} else { } else {
Err(ApiError::ApiResponse(format!("Failed to remove favorite: {}", response.status()))) Err(ApiError::ApiResponse(format!(
"Failed to remove favorite: {}",
response.status()
)))
} }
} }
@@ -1512,9 +1677,7 @@ impl QobuzClient {
sort: Option<&str>, sort: Option<&str>,
) -> Result<PageArtistResponse> { ) -> Result<PageArtistResponse> {
let url = endpoints::build_url(paths::ARTIST_PAGE); let url = endpoints::build_url(paths::ARTIST_PAGE);
let mut query = vec![ let mut query = vec![("artist_id", artist_id.to_string())];
("artist_id", artist_id.to_string()),
];
if let Some(s) = sort { if let Some(s) = sort {
query.push(("sort", s.to_string())); query.push(("sort", s.to_string()));
} }
@@ -1553,7 +1716,13 @@ impl QobuzClient {
query.push(("sort", s.to_string())); query.push(("sort", s.to_string()));
} }
log::debug!("[API] get_releases_grid({}) type={} limit={} offset={}", artist_id, release_type, limit, offset); log::debug!(
"[API] get_releases_grid({}) type={} limit={} offset={}",
artist_id,
release_type,
limit,
offset
);
let response: serde_json::Value = self let response: serde_json::Value = self
.http .http
.get(&url) .get(&url)
+2 -2
View File
@@ -39,7 +39,7 @@ pub mod link_resolver;
pub mod performers; pub mod performers;
// Re-export main types // Re-export main types
pub use bundle::BundleTokens;
pub use client::QobuzClient; pub use client::QobuzClient;
pub use error::{ApiError, Result}; pub use error::{ApiError, Result};
pub use bundle::BundleTokens; pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
pub use link_resolver::{resolve_link, ResolvedLink, LinkResolverError};
+3 -1
View File
@@ -135,7 +135,9 @@ fn build_resolved_link(entity_type: &str, raw_id: &str) -> Result<ResolvedLink,
.map_err(|_| LinkResolverError::InvalidId(raw_id.to_string()))?; .map_err(|_| LinkResolverError::InvalidId(raw_id.to_string()))?;
Ok(ResolvedLink::OpenPlaylist(id)) Ok(ResolvedLink::OpenPlaylist(id))
} }
_ => Err(LinkResolverError::UnknownEntityType(entity_type.to_string())), _ => Err(LinkResolverError::UnknownEntityType(
entity_type.to_string(),
)),
} }
} }
+143 -139
View File
@@ -1,139 +1,143 @@
//! Performers string parsing utilities //! Performers string parsing utilities
//! //!
//! Qobuz provides performer credits as a formatted string like: //! Qobuz provides performer credits as a formatted string like:
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass" //! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
//! //!
//! This module parses that string into structured data. //! This module parses that string into structured data.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A performer with their name and roles /// A performer with their name and roles
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Performer { pub struct Performer {
pub name: String, pub name: String,
pub roles: Vec<String>, pub roles: Vec<String>,
} }
impl Performer { impl Performer {
pub fn new(name: String, roles: Vec<String>) -> Self { pub fn new(name: String, roles: Vec<String>) -> Self {
Self { name, roles } Self { name, roles }
} }
} }
/// Parse a Qobuz performers string into structured data /// Parse a Qobuz performers string into structured data
/// ///
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2" /// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use crate::api::performers::parse_performers; /// use crate::api::performers::parse_performers;
/// ///
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano"); /// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
/// assert_eq!(performers.len(), 2); /// assert_eq!(performers.len(), 2);
/// assert_eq!(performers[0].name, "John Coltrane"); /// assert_eq!(performers[0].name, "John Coltrane");
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]); /// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
/// ``` /// ```
pub fn parse_performers(performers_str: &str) -> Vec<Performer> { pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
if performers_str.is_empty() { if performers_str.is_empty() {
return Vec::new(); return Vec::new();
} }
performers_str performers_str
.split(" - ") .split(" - ")
.filter_map(|segment| { .filter_map(|segment| {
let segment = segment.trim(); let segment = segment.trim();
if segment.is_empty() { if segment.is_empty() {
return None; return None;
} }
let parts: Vec<&str> = segment.split(", ").collect(); let parts: Vec<&str> = segment.split(", ").collect();
if parts.is_empty() { if parts.is_empty() {
return None; return None;
} }
let name = parts[0].trim().to_string(); let name = parts[0].trim().to_string();
if name.is_empty() { if name.is_empty() {
return None; return None;
} }
let roles: Vec<String> = parts[1..] let roles: Vec<String> = parts[1..]
.iter() .iter()
.map(|r| r.trim().to_string()) .map(|r| r.trim().to_string())
.filter(|r| !r.is_empty()) .filter(|r| !r.is_empty())
.collect(); .collect();
Some(Performer::new(name, roles)) Some(Performer::new(name, roles))
}) })
.collect() .collect()
} }
/// Group performers by their roles /// Group performers by their roles
/// ///
/// Returns a map where keys are role names and values are lists of performer names /// Returns a map where keys are role names and values are lists of performer names
pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> { pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> {
let mut grouped: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new(); let mut grouped: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for performer in performers {
for role in &performer.roles { for performer in performers {
grouped for role in &performer.roles {
.entry(role.clone()) grouped
.or_default() .entry(role.clone())
.push(performer.name.clone()); .or_default()
} .push(performer.name.clone());
} }
}
grouped
} grouped
}
#[cfg(test)]
mod tests { #[cfg(test)]
use super::*; mod tests {
use super::*;
#[test]
fn test_parse_single_performer() { #[test]
let result = parse_performers("John Coltrane, Saxophone"); fn test_parse_single_performer() {
assert_eq!(result.len(), 1); let result = parse_performers("John Coltrane, Saxophone");
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 1);
assert_eq!(result[0].roles, vec!["Saxophone"]); assert_eq!(result[0].name, "John Coltrane");
} assert_eq!(result[0].roles, vec!["Saxophone"]);
}
#[test]
fn test_parse_multiple_performers() { #[test]
let result = parse_performers( fn test_parse_multiple_performers() {
"John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums", let result = parse_performers(
); "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums",
assert_eq!(result.len(), 3); );
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 3);
assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]); assert_eq!(result[0].name, "John Coltrane");
assert_eq!(result[1].name, "McCoy Tyner"); assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]);
assert_eq!(result[1].roles, vec!["Piano"]); assert_eq!(result[1].name, "McCoy Tyner");
assert_eq!(result[2].name, "Elvin Jones"); assert_eq!(result[1].roles, vec!["Piano"]);
assert_eq!(result[2].roles, vec!["Drums"]); assert_eq!(result[2].name, "Elvin Jones");
} assert_eq!(result[2].roles, vec!["Drums"]);
}
#[test]
fn test_parse_empty_string() { #[test]
let result = parse_performers(""); fn test_parse_empty_string() {
assert!(result.is_empty()); let result = parse_performers("");
} assert!(result.is_empty());
}
#[test]
fn test_parse_performer_no_roles() { #[test]
let result = parse_performers("John Coltrane"); fn test_parse_performer_no_roles() {
assert_eq!(result.len(), 1); let result = parse_performers("John Coltrane");
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 1);
assert!(result[0].roles.is_empty()); assert_eq!(result[0].name, "John Coltrane");
} assert!(result[0].roles.is_empty());
}
#[test]
fn test_group_by_role() { #[test]
let performers = vec![ fn test_group_by_role() {
Performer::new("John".to_string(), vec!["Saxophone".to_string()]), let performers = vec![
Performer::new("Jane".to_string(), vec!["Saxophone".to_string(), "Vocals".to_string()]), Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
]; Performer::new(
let grouped = group_by_role(&performers); "Jane".to_string(),
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2); vec!["Saxophone".to_string(), "Vocals".to_string()],
assert_eq!(grouped.get("Vocals").unwrap().len(), 1); ),
} ];
} let grouped = group_by_role(&performers);
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2);
assert_eq!(grouped.get("Vocals").unwrap().len(), 1);
}
}
+3 -3
View File
@@ -1294,9 +1294,9 @@ mod tests {
#[test] #[test]
fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() { fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() {
let raw_message = vec![ let raw_message = vec![
0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d, 0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d, 0x09,
0x09, 0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01, 0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01, 0x20, 0xa8,
0x20, 0xa8, 0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c, 0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c,
]; ];
let message = QConnectMessage::decode(raw_message.as_slice()).expect("decode message"); let message = QConnectMessage::decode(raw_message.as_slice()).expect("decode message");
+1 -3
View File
@@ -55,9 +55,7 @@ impl QueueEventType {
Self::SrvrCtrlAddRenderer => "MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER", Self::SrvrCtrlAddRenderer => "MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER",
Self::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER", Self::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER",
Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER", Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER",
Self::SrvrCtrlActiveRendererChanged => { Self::SrvrCtrlActiveRendererChanged => "MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED",
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED"
}
Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED", Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED",
Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET", Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET",
Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED", Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED",
@@ -619,7 +619,10 @@ fn handle_incoming_binary(
match decode_renderer_server_commands(&inner_payload) { match decode_renderer_server_commands(&inner_payload) {
Ok(commands) => { Ok(commands) => {
if !commands.is_empty() { if !commands.is_empty() {
log::info!("[QConnect/Decode] Renderer commands decoded: {}", commands.len()); log::info!(
"[QConnect/Decode] Renderer commands decoded: {}",
commands.len()
);
} }
for command in commands { for command in commands {
emit( emit(
+105 -12
View File
@@ -194,7 +194,7 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.60.2",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1581,7 +1581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2508,7 +2508,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -2544,7 +2544,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.6.2",
"system-configuration", "system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
@@ -3916,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4413,6 +4413,29 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "3.2.0" version = "3.2.0"
@@ -4629,6 +4652,9 @@ dependencies = [
"qbz-models", "qbz-models",
"qbz-player", "qbz-player",
"qbz-qobuz", "qbz-qobuz",
"qconnect-app",
"qconnect-core",
"qconnect-transport-ws",
"qrcode-generator", "qrcode-generator",
"rand 0.8.5", "rand 0.8.5",
"rcgen", "rcgen",
@@ -4698,6 +4724,58 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "qconnect-app"
version = "0.1.0"
dependencies = [
"async-trait",
"log",
"qconnect-core",
"qconnect-protocol",
"qconnect-transport-ws",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"uuid",
]
[[package]]
name = "qconnect-core"
version = "0.1.0"
dependencies = [
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "qconnect-protocol"
version = "0.1.0"
dependencies = [
"log",
"prost",
"qconnect-core",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
[[package]]
name = "qconnect-transport-ws"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"log",
"prost",
"qconnect-protocol",
"serde",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
]
[[package]] [[package]]
name = "qrcode-generator" name = "qrcode-generator"
version = "4.1.9" version = "4.1.9"
@@ -4743,7 +4821,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.6.2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -4780,9 +4858,9 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5098,7 +5176,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2", "wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -5303,7 +5381,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6577,7 +6655,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -6782,8 +6860,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls",
"tungstenite", "tungstenite",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -7051,6 +7133,8 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.8.5", "rand 0.8.5",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 1.0.69", "thiserror 1.0.69",
"utf-8", "utf-8",
@@ -7567,6 +7651,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.6"
@@ -7652,7 +7745,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+6 -3
View File
@@ -46,7 +46,11 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
// Step 5: Extract OAuth private_key (optional) // Step 5: Extract OAuth private_key (optional)
let private_key = extract_private_key(&bundle_content); let private_key = extract_private_key(&bundle_content);
Ok(BundleTokens { app_id, secrets, private_key }) Ok(BundleTokens {
app_id,
secrets,
private_key,
})
} }
fn extract_bundle_url(html: &str) -> Result<String> { fn extract_bundle_url(html: &str) -> Result<String> {
@@ -186,8 +190,7 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
} }
fn extract_private_key(bundle: &str) -> Option<String> { fn extract_private_key(bundle: &str) -> Option<String> {
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#) let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#).expect("Invalid regex");
.expect("Invalid regex");
re.captures(bundle) re.captures(bundle)
.and_then(|caps| caps.name("key")) .and_then(|caps| caps.name("key"))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
+8 -13
View File
@@ -187,9 +187,9 @@ impl QobuzClient {
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{HeaderMap, HeaderValue};
let tokens = self.tokens.read().await; let tokens = self.tokens.read().await;
let tokens = tokens.as_ref().ok_or_else(|| { let tokens = tokens
ApiError::BundleExtractionError("Client not initialized".to_string()) .as_ref()
})?; .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
let app_id = tokens.app_id.clone(); let app_id = tokens.app_id.clone();
let private_key = tokens.private_key.clone().ok_or_else(|| { let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError( ApiError::BundleExtractionError(
@@ -248,9 +248,8 @@ impl QobuzClient {
); );
auth_headers.insert( auth_headers.insert(
"X-User-Auth-Token", "X-User-Auth-Token",
HeaderValue::from_str(&token).map_err(|_| { HeaderValue::from_str(&token)
ApiError::AuthenticationError("Invalid OAuth token format".into()) .map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
})?,
); );
let login_resp = self let login_resp = self
@@ -304,9 +303,8 @@ impl QobuzClient {
); );
headers.insert( headers.insert(
"X-User-Auth-Token", "X-User-Auth-Token",
HeaderValue::from_str(token).map_err(|_| { HeaderValue::from_str(token)
ApiError::AuthenticationError("Invalid token format".into()) .map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?,
})?,
); );
log::info!("[OAuth] Restoring session from saved token"); log::info!("[OAuth] Restoring session from saved token");
@@ -632,10 +630,7 @@ impl QobuzClient {
} }
/// Get album suggestions (similar albums) from /album/suggest /// Get album suggestions (similar albums) from /album/suggest
pub async fn get_album_suggest( pub async fn get_album_suggest(&self, album_id: &str) -> Result<AlbumSuggestResponse> {
&self,
album_id: &str,
) -> Result<AlbumSuggestResponse> {
let url = endpoints::build_url(paths::ALBUM_SUGGEST); let url = endpoints::build_url(paths::ALBUM_SUGGEST);
let http_response = self let http_response = self
.http .http
+3 -1
View File
@@ -8,7 +8,9 @@ use tokio::sync::{Mutex, RwLock};
use crate::api::QobuzClient; use crate::api::QobuzClient;
use qbz_integrations::musicbrainz::cache::MusicBrainzCache; use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
use qbz_integrations::musicbrainz::{ArtistFullResponse, ArtistRelationships, Period, RelatedArtist}; use qbz_integrations::musicbrainz::{
ArtistFullResponse, ArtistRelationships, Period, RelatedArtist,
};
use qbz_integrations::MusicBrainzClient; use qbz_integrations::MusicBrainzClient;
use super::sparse_vector::SparseVector; use super::sparse_vector::SparseVector;
+29 -47
View File
@@ -151,9 +151,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
let mut vars = HashMap::new(); let mut vars = HashMap::new();
// Determine dark/light from the window background // Determine dark/light from the window background
let window_bg = scheme let window_bg = scheme.window_bg.unwrap_or(PaletteColor::new(40, 40, 40));
.window_bg
.unwrap_or(PaletteColor::new(40, 40, 40));
let is_dark = window_bg.luminance() < 0.5; let is_dark = window_bg.luminance() < 0.5;
// ── Backgrounds ────────────────────────────────────────────────────── // ── Backgrounds ──────────────────────────────────────────────────────
@@ -188,25 +186,23 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
vars.insert("--bg-hover".into(), bg_hover.to_hex()); vars.insert("--bg-hover".into(), bg_hover.to_hex());
// ── Text ───────────────────────────────────────────────────────────── // ── Text ─────────────────────────────────────────────────────────────
let text_primary = scheme let text_primary = scheme.window_fg.unwrap_or(if is_dark {
.window_fg PaletteColor::new(223, 223, 223)
.unwrap_or(if is_dark { } else {
PaletteColor::new(223, 223, 223) PaletteColor::new(36, 36, 36)
} else { });
PaletteColor::new(36, 36, 36)
});
let text_primary = ensure_text_contrast(text_primary, &window_bg, is_dark); let text_primary = ensure_text_contrast(text_primary, &window_bg, is_dark);
vars.insert("--text-primary".into(), text_primary.to_hex()); vars.insert("--text-primary".into(), text_primary.to_hex());
// Secondary: slightly dimmed from primary // Secondary: slightly dimmed from primary
let text_secondary = scheme.view_fg.unwrap_or_else(|| { let text_secondary = scheme
text_primary.shift_lightness(if is_dark { -0.10 } else { 0.10 }) .view_fg
}); .unwrap_or_else(|| text_primary.shift_lightness(if is_dark { -0.10 } else { 0.10 }));
vars.insert("--text-secondary".into(), text_secondary.to_hex()); vars.insert("--text-secondary".into(), text_secondary.to_hex());
let text_muted = scheme.window_fg_inactive.unwrap_or_else(|| { let text_muted = scheme
text_primary.shift_lightness(if is_dark { -0.25 } else { 0.25 }) .window_fg_inactive
}); .unwrap_or_else(|| text_primary.shift_lightness(if is_dark { -0.25 } else { 0.25 }));
vars.insert("--text-muted".into(), text_muted.to_hex()); vars.insert("--text-muted".into(), text_muted.to_hex());
let text_disabled = text_muted.shift_lightness(if is_dark { -0.10 } else { 0.10 }); let text_disabled = text_muted.shift_lightness(if is_dark { -0.10 } else { 0.10 });
@@ -229,24 +225,20 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
); );
// Button text on accent // Button text on accent
let btn_text = scheme.selection_fg.unwrap_or( let btn_text = scheme.selection_fg.unwrap_or(if accent.luminance() < 0.5 {
if accent.luminance() < 0.5 { PaletteColor::new(255, 255, 255)
PaletteColor::new(255, 255, 255) } else {
} else { PaletteColor::new(0, 0, 0)
PaletteColor::new(0, 0, 0) });
},
);
vars.insert("--btn-primary-text".into(), btn_text.to_hex()); vars.insert("--btn-primary-text".into(), btn_text.to_hex());
// ── Status colors ──────────────────────────────────────────────────── // ── Status colors ────────────────────────────────────────────────────
// Use system negative/neutral if available, else fallback to safe defaults // Use system negative/neutral if available, else fallback to safe defaults
let danger = scheme let danger = scheme.fg_negative.unwrap_or(if is_dark {
.fg_negative PaletteColor::new(239, 68, 68)
.unwrap_or(if is_dark { } else {
PaletteColor::new(239, 68, 68) PaletteColor::new(220, 38, 38)
} else { });
PaletteColor::new(220, 38, 38)
});
vars.insert("--danger".into(), danger.to_hex()); vars.insert("--danger".into(), danger.to_hex());
vars.insert("--danger-bg".into(), danger.to_rgba(0.1)); vars.insert("--danger-bg".into(), danger.to_rgba(0.1));
vars.insert("--danger-border".into(), danger.to_rgba(0.3)); vars.insert("--danger-border".into(), danger.to_rgba(0.3));
@@ -255,13 +247,11 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
danger.to_rgba(if is_dark { 0.2 } else { 0.15 }), danger.to_rgba(if is_dark { 0.2 } else { 0.15 }),
); );
let warning = scheme let warning = scheme.fg_neutral.unwrap_or(if is_dark {
.fg_neutral PaletteColor::new(251, 191, 36)
.unwrap_or(if is_dark { } else {
PaletteColor::new(251, 191, 36) PaletteColor::new(217, 119, 6)
} else { });
PaletteColor::new(217, 119, 6)
});
vars.insert("--warning".into(), warning.to_hex()); vars.insert("--warning".into(), warning.to_hex());
vars.insert("--warning-bg".into(), warning.to_rgba(0.1)); vars.insert("--warning-bg".into(), warning.to_rgba(0.1));
vars.insert("--warning-border".into(), warning.to_rgba(0.3)); vars.insert("--warning-border".into(), warning.to_rgba(0.3));
@@ -285,11 +275,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
vars.insert("--border-strong".into(), border_strong.to_hex()); vars.insert("--border-strong".into(), border_strong.to_hex());
// ── Alpha tokens ───────────────────────────────────────────────────── // ── Alpha tokens ─────────────────────────────────────────────────────
let alpha_base = if is_dark { let alpha_base = if is_dark { (255, 255, 255) } else { (0, 0, 0) };
(255, 255, 255)
} else {
(0, 0, 0)
};
let alpha_levels: &[(f64, &str)] = &[ let alpha_levels: &[(f64, &str)] = &[
(0.04, "--alpha-4"), (0.04, "--alpha-4"),
(0.05, "--alpha-5"), (0.05, "--alpha-5"),
@@ -330,11 +316,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
} }
/// Ensure text has at least WCAG AA contrast (4.5:1) against the background. /// Ensure text has at least WCAG AA contrast (4.5:1) against the background.
fn ensure_text_contrast( fn ensure_text_contrast(text: PaletteColor, bg: &PaletteColor, is_dark: bool) -> PaletteColor {
text: PaletteColor,
bg: &PaletteColor,
is_dark: bool,
) -> PaletteColor {
if text.contrast_ratio(bg) >= 4.5 { if text.contrast_ratio(bg) >= 4.5 {
return text; return text;
} }
+10 -2
View File
@@ -38,7 +38,11 @@ impl PaletteColor {
/// HSL saturation in [0.0, 1.0]. /// HSL saturation in [0.0, 1.0].
pub fn saturation(&self) -> f64 { pub fn saturation(&self) -> f64 {
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0); let (r, g, b) = (
self.r as f64 / 255.0,
self.g as f64 / 255.0,
self.b as f64 / 255.0,
);
let max = r.max(g).max(b); let max = r.max(g).max(b);
let min = r.min(g).min(b); let min = r.min(g).min(b);
let delta = max - min; let delta = max - min;
@@ -70,7 +74,11 @@ impl PaletteColor {
/// Convert to HSL (h in [0, 360), s and l in [0, 1]). /// Convert to HSL (h in [0, 360), s and l in [0, 1]).
pub fn to_hsl(&self) -> (f64, f64, f64) { pub fn to_hsl(&self) -> (f64, f64, f64) {
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0); let (r, g, b) = (
self.r as f64 / 255.0,
self.g as f64 / 255.0,
self.b as f64 / 255.0,
);
let max = r.max(g).max(b); let max = r.max(g).max(b);
let min = r.min(g).min(b); let min = r.min(g).min(b);
let l = (max + min) / 2.0; let l = (max + min) / 2.0;
+2 -10
View File
@@ -210,11 +210,7 @@ fn build_palette(mut clusters: Vec<Cluster>) -> Result<ThemePalette, String> {
} }
/// Find the best accent color: prioritize saturation, then ensure WCAG AA contrast. /// Find the best accent color: prioritize saturation, then ensure WCAG AA contrast.
fn find_best_accent( fn find_best_accent(colors: &[PaletteColor], bg: &PaletteColor, is_dark: bool) -> PaletteColor {
colors: &[PaletteColor],
bg: &PaletteColor,
is_dark: bool,
) -> PaletteColor {
// Sort candidates by saturation (highest first), skip the dominant (index 0) // Sort candidates by saturation (highest first), skip the dominant (index 0)
let mut candidates: Vec<(usize, f64)> = colors let mut candidates: Vec<(usize, f64)> = colors
.iter() .iter()
@@ -252,11 +248,7 @@ fn find_best_accent(
} }
/// Adjust a color's lightness to achieve WCAG AA contrast against a background. /// Adjust a color's lightness to achieve WCAG AA contrast against a background.
fn adjust_for_contrast( fn adjust_for_contrast(color: &PaletteColor, bg: &PaletteColor, is_dark: bool) -> PaletteColor {
color: &PaletteColor,
bg: &PaletteColor,
is_dark: bool,
) -> PaletteColor {
let (h, s, l) = color.to_hsl(); let (h, s, l) = color.to_hsl();
let direction = if is_dark { 0.05 } else { -0.05 }; let direction = if is_dark { 0.05 } else { -0.05 };
+12 -17
View File
@@ -166,13 +166,10 @@ fn get_gnome_accent() -> Result<PaletteColor, String> {
fn get_kde_wallpaper() -> Result<String, String> { fn get_kde_wallpaper() -> Result<String, String> {
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?; let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
let config_path = format!( let config_path = format!("{}/.config/plasma-org.kde.plasma.desktop-appletsrc", home);
"{}/.config/plasma-org.kde.plasma.desktop-appletsrc",
home
);
let content = let content = fs::read_to_string(&config_path)
fs::read_to_string(&config_path).map_err(|e| format!("Cannot read Plasma config: {}", e))?; .map_err(|e| format!("Cannot read Plasma config: {}", e))?;
// Look for Image= under [Wallpaper][org.kde.image][General] // Look for Image= under [Wallpaper][org.kde.image][General]
let mut in_wallpaper_section = false; let mut in_wallpaper_section = false;
@@ -375,12 +372,7 @@ fn get_xfce_wallpaper() -> Result<String, String> {
// Fallback: try generic monitor path // Fallback: try generic monitor path
let output = Command::new("xfconf-query") let output = Command::new("xfconf-query")
.args([ .args(["-c", "xfce4-desktop", "-l", "-v"])
"-c",
"xfce4-desktop",
"-l",
"-v",
])
.output() .output()
.map_err(|e| format!("Failed to list xfce4-desktop properties: {}", e))?; .map_err(|e| format!("Failed to list xfce4-desktop properties: {}", e))?;
@@ -542,7 +534,11 @@ fn get_kde_color_scheme() -> Result<SystemColorScheme, String> {
view_bg: read_kde_color_key(&content, "[Colors:View]", "BackgroundNormal"), view_bg: read_kde_color_key(&content, "[Colors:View]", "BackgroundNormal"),
button_bg: read_kde_color_key(&content, "[Colors:Button]", "BackgroundNormal"), button_bg: read_kde_color_key(&content, "[Colors:Button]", "BackgroundNormal"),
header_bg: read_kde_color_key(&content, "[Colors:Header]", "BackgroundNormal"), header_bg: read_kde_color_key(&content, "[Colors:Header]", "BackgroundNormal"),
header_bg_inactive: read_kde_color_key(&content, "[Colors:Header][Inactive]", "BackgroundNormal"), header_bg_inactive: read_kde_color_key(
&content,
"[Colors:Header][Inactive]",
"BackgroundNormal",
),
tooltip_bg: read_kde_color_key(&content, "[Colors:Tooltip]", "BackgroundNormal"), tooltip_bg: read_kde_color_key(&content, "[Colors:Tooltip]", "BackgroundNormal"),
// Foregrounds // Foregrounds
@@ -555,9 +551,8 @@ fn get_kde_color_scheme() -> Result<SystemColorScheme, String> {
selection_bg: read_kde_color_key(&content, "[Colors:Selection]", "BackgroundNormal"), selection_bg: read_kde_color_key(&content, "[Colors:Selection]", "BackgroundNormal"),
selection_fg: read_kde_color_key(&content, "[Colors:Selection]", "ForegroundNormal"), selection_fg: read_kde_color_key(&content, "[Colors:Selection]", "ForegroundNormal"),
selection_hover: read_kde_color_key(&content, "[Colors:Selection]", "DecorationHover"), selection_hover: read_kde_color_key(&content, "[Colors:Selection]", "DecorationHover"),
accent: accent_explicit.or_else(|| { accent: accent_explicit
read_kde_color_key(&content, "[Colors:Selection]", "DecorationFocus") .or_else(|| read_kde_color_key(&content, "[Colors:Selection]", "DecorationFocus")),
}),
// Semantic // Semantic
fg_link: read_kde_color_key(&content, "[Colors:Window]", "ForegroundLink"), fg_link: read_kde_color_key(&content, "[Colors:Window]", "ForegroundLink"),
@@ -602,7 +597,7 @@ fn get_gnome_color_scheme() -> Result<SystemColorScheme, String> {
// Build a minimal scheme with GNOME defaults // Build a minimal scheme with GNOME defaults
let (bg, bg_alt, view_bg, btn_bg, fg, fg_inactive) = if is_dark { let (bg, bg_alt, view_bg, btn_bg, fg, fg_inactive) = if is_dark {
( (
PaletteColor::new(36, 36, 36), // Adwaita Dark PaletteColor::new(36, 36, 36), // Adwaita Dark
PaletteColor::new(48, 48, 48), PaletteColor::new(48, 48, 48),
PaletteColor::new(30, 30, 30), PaletteColor::new(30, 30, 30),
PaletteColor::new(60, 60, 60), PaletteColor::new(60, 60, 60),
File diff suppressed because it is too large. Load diff
+22 -8
View File
@@ -64,9 +64,18 @@ impl WindowSettingsStore {
.map_err(|e| format!("Failed to insert default window settings: {}", e))?; .map_err(|e| format!("Failed to insert default window settings: {}", e))?;
// Migrations: add window size columns on existing DBs (errors ignored when column exists) // Migrations: add window size columns on existing DBs (errors ignored when column exists)
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN window_width REAL NOT NULL DEFAULT 1280.0", []); let _ = conn.execute(
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN window_height REAL NOT NULL DEFAULT 800.0", []); "ALTER TABLE window_settings ADD COLUMN window_width REAL NOT NULL DEFAULT 1280.0",
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN is_maximized INTEGER NOT NULL DEFAULT 0", []); [],
);
let _ = conn.execute(
"ALTER TABLE window_settings ADD COLUMN window_height REAL NOT NULL DEFAULT 800.0",
[],
);
let _ = conn.execute(
"ALTER TABLE window_settings ADD COLUMN is_maximized INTEGER NOT NULL DEFAULT 0",
[],
);
info!("[WindowSettings] Database initialized"); info!("[WindowSettings] Database initialized");
@@ -101,7 +110,8 @@ impl WindowSettingsStore {
} else { } else {
log::warn!( log::warn!(
"[WindowSettings] Corrupt size in DB: {}x{}, using defaults", "[WindowSettings] Corrupt size in DB: {}x{}, using defaults",
window_width, window_height window_width,
window_height
); );
(defaults.window_width, defaults.window_height) (defaults.window_width, defaults.window_height)
}; };
@@ -131,7 +141,8 @@ impl WindowSettingsStore {
if !is_valid_window_size(width, height) { if !is_valid_window_size(width, height) {
log::warn!( log::warn!(
"[WindowSettings] Ignoring invalid window size: {}x{}", "[WindowSettings] Ignoring invalid window size: {}x{}",
width, height width,
height
); );
return Ok(()); return Ok(());
} }
@@ -225,9 +236,12 @@ impl WindowSettingsState {
fn is_valid_window_size(width: f64, height: f64) -> bool { fn is_valid_window_size(width: f64, height: f64) -> bool {
const MIN: f64 = 200.0; const MIN: f64 = 200.0;
const MAX: f64 = 32767.0; const MAX: f64 = 32767.0;
width.is_finite() && height.is_finite() width.is_finite()
&& width >= MIN && width <= MAX && height.is_finite()
&& height >= MIN && height <= MAX && width >= MIN
&& width <= MAX
&& height >= MIN
&& height <= MAX
} }
// Tauri commands // Tauri commands
+4
View File
@@ -586,6 +586,10 @@ impl CoreBridge {
pub fn player(&self) -> Arc<Player> { pub fn player(&self) -> Arc<Player> {
self.core.player() self.core.player()
} }
pub fn has_loaded_audio(&self) -> bool {
self.core.player().has_loaded_audio()
}
} }
/// State wrapper for Tauri's managed state /// State wrapper for Tauri's managed state
+4 -1
View File
@@ -629,7 +629,10 @@ mod tests {
return; return;
} }
Err(e) => { Err(e) => {
eprintln!("Skipping: cannot load credentials in this environment: {}", e); eprintln!(
"Skipping: cannot load credentials in this environment: {}",
e
);
let _ = clear_qobuz_credentials(); let _ = clear_qobuz_credentials();
return; return;
} }
+1 -2
View File
@@ -101,8 +101,7 @@ impl ImageCacheService {
.unwrap_or_default() .unwrap_or_default()
.as_secs() as i64; .as_secs() as i64;
std::fs::write(&path, bytes) std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?;
.map_err(|e| format!("Failed to write cached image: {}", e))?;
let file_size = bytes.len() as i64; let file_size = bytes.len() as i64;
self.conn self.conn
+16 -9
View File
@@ -291,8 +291,13 @@ impl LastFmClient {
.map_err(|e| format!("Failed to read response: {}", e))?; .map_err(|e| format!("Failed to read response: {}", e))?;
// Parse the Last.fm response format // Parse the Last.fm response format
let data: serde_json::Value = serde_json::from_str(&text) let data: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
.map_err(|e| format!("Failed to parse response: {} - Raw: {}", e, &text[..text.len().min(200)]))?; format!(
"Failed to parse response: {} - Raw: {}",
e,
&text[..text.len().min(200)]
)
})?;
// Handle Last.fm error responses // Handle Last.fm error responses
if let Some(error) = data.get("error") { if let Some(error) = data.get("error") {
@@ -313,13 +318,15 @@ impl LastFmClient {
let name = item.get("name")?.as_str()?.to_string(); let name = item.get("name")?.as_str()?.to_string();
let match_score: f64 = item let match_score: f64 = item
.get("match") .get("match")
.and_then(|m| m.as_str().or_else(|| m.as_f64().map(|_| "")).and_then(|s| { .and_then(|m| {
if s.is_empty() { m.as_str().or_else(|| m.as_f64().map(|_| "")).and_then(|s| {
item.get("match").and_then(|m| m.as_f64()) if s.is_empty() {
} else { item.get("match").and_then(|m| m.as_f64())
s.parse().ok() } else {
} s.parse().ok()
})) }
})
})
.unwrap_or(0.0); .unwrap_or(0.0);
let mbid = item let mbid = item
.get("mbid") .get("mbid")
+78 -37
View File
@@ -28,7 +28,6 @@ pub mod credentials;
pub mod discogs; pub mod discogs;
pub mod flatpak; pub mod flatpak;
pub mod image_cache; pub mod image_cache;
pub mod snap;
pub mod lastfm; pub mod lastfm;
pub mod library; pub mod library;
pub mod listenbrainz; pub mod listenbrainz;
@@ -40,6 +39,7 @@ pub mod musicbrainz;
pub mod network; pub mod network;
pub mod offline; pub mod offline;
pub mod offline_cache; pub mod offline_cache;
pub mod pdf_viewer;
pub mod playback_context; pub mod playback_context;
#[allow(deprecated)] #[allow(deprecated)]
pub mod player; pub mod player;
@@ -50,14 +50,14 @@ pub mod radio_engine;
pub mod reco_store; pub mod reco_store;
pub mod session_store; pub mod session_store;
pub mod share; pub mod share;
pub mod snap;
pub mod tray; pub mod tray;
pub mod updates; pub mod updates;
pub mod user_data; pub mod user_data;
pub mod visualizer; pub mod visualizer;
pub mod pdf_viewer;
use std::sync::Arc;
use rustls::crypto::{aws_lc_rs, CryptoProvider}; use rustls::crypto::{aws_lc_rs, CryptoProvider};
use std::sync::Arc;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
@@ -214,7 +214,10 @@ fn setup_kwin_window_rule() -> Result<(), String> {
} }
let rule_num = if let Some(num) = qbz_rule_group { let rule_num = if let Some(num) = qbz_rule_group {
log::info!("KWin window rule for QBZ already exists (group {}), updating", num); log::info!(
"KWin window rule for QBZ already exists (group {}), updating",
num
);
num num
} else { } else {
existing_count + 1 existing_count + 1
@@ -225,16 +228,24 @@ fn setup_kwin_window_rule() -> Result<(), String> {
let rules: &[(&str, &str)] = &[ let rules: &[(&str, &str)] = &[
("Description", "QBZ Native Title Bar"), ("Description", "QBZ Native Title Bar"),
("noborder", "false"), ("noborder", "false"),
("noborderrule", "2"), // 2 = Force ("noborderrule", "2"), // 2 = Force
("wmclass", "qbz"), ("wmclass", "qbz"),
("wmclasscomplete", "false"), ("wmclasscomplete", "false"),
("wmclassmatch", "1"), // 1 = Exact match ("wmclassmatch", "1"), // 1 = Exact match
("types", "1"), // 1 = Normal windows ("types", "1"), // 1 = Normal windows
]; ];
for (key, value) in rules { for (key, value) in rules {
let output = std::process::Command::new("kwriteconfig6") let output = std::process::Command::new("kwriteconfig6")
.args(["--file", "kwinrulesrc", "--group", &group, "--key", key, value]) .args([
"--file",
"kwinrulesrc",
"--group",
&group,
"--key",
key,
value,
])
.output() .output()
.map_err(|e| format!("kwriteconfig6 failed: {}", e))?; .map_err(|e| format!("kwriteconfig6 failed: {}", e))?;
@@ -251,7 +262,15 @@ fn setup_kwin_window_rule() -> Result<(), String> {
if qbz_rule_group.is_none() { if qbz_rule_group.is_none() {
let new_count = (existing_count + 1).to_string(); let new_count = (existing_count + 1).to_string();
let output = std::process::Command::new("kwriteconfig6") let output = std::process::Command::new("kwriteconfig6")
.args(["--file", "kwinrulesrc", "--group", "General", "--key", "count", &new_count]) .args([
"--file",
"kwinrulesrc",
"--group",
"General",
"--key",
"count",
&new_count,
])
.output() .output()
.map_err(|e| format!("kwriteconfig6 count update failed: {}", e))?; .map_err(|e| format!("kwriteconfig6 count update failed: {}", e))?;
@@ -276,7 +295,10 @@ fn setup_kwin_window_rule() -> Result<(), String> {
); );
} }
log::info!("KWin window rule set for native title bar (group {})", rule_num); log::info!(
"KWin window rule set for native title bar (group {})",
rule_num
);
Ok(()) Ok(())
} }
@@ -324,7 +346,15 @@ fn remove_kwin_window_rule() {
if count > 0 { if count > 0 {
let new_count = (count - 1).to_string(); let new_count = (count - 1).to_string();
let _ = std::process::Command::new("kwriteconfig6") let _ = std::process::Command::new("kwriteconfig6")
.args(["--file", "kwinrulesrc", "--group", "General", "--key", "count", &new_count]) .args([
"--file",
"kwinrulesrc",
"--group",
"General",
"--key",
"count",
&new_count,
])
.output(); .output();
} }
@@ -356,8 +386,14 @@ fn get_screen_resolution() -> Option<(f64, f64)> {
if let Some(dims) = trimmed.split_whitespace().nth(1) { if let Some(dims) = trimmed.split_whitespace().nth(1) {
let parts: Vec<&str> = dims.split('x').collect(); let parts: Vec<&str> = dims.split('x').collect();
if parts.len() == 2 { if parts.len() == 2 {
if let (Ok(w), Ok(h)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) { if let (Ok(w), Ok(h)) =
log::info!("Screen resolution detected via xdpyinfo: {}x{}", w as u32, h as u32); (parts[0].parse::<f64>(), parts[1].parse::<f64>())
{
log::info!(
"Screen resolution detected via xdpyinfo: {}x{}",
w as u32,
h as u32
);
return Some((w, h)); return Some((w, h));
} }
} }
@@ -381,8 +417,14 @@ fn get_screen_resolution() -> Option<(f64, f64)> {
let res_part = token.split('+').next().unwrap_or(""); let res_part = token.split('+').next().unwrap_or("");
let parts: Vec<&str> = res_part.split('x').collect(); let parts: Vec<&str> = res_part.split('x').collect();
if parts.len() == 2 { if parts.len() == 2 {
if let (Ok(w), Ok(h)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) { if let (Ok(w), Ok(h)) =
log::info!("Screen resolution detected via xrandr: {}x{}", w as u32, h as u32); (parts[0].parse::<f64>(), parts[1].parse::<f64>())
{
log::info!(
"Screen resolution detected via xrandr: {}x{}",
w as u32,
h as u32
);
return Some((w, h)); return Some((w, h));
} }
} }
@@ -407,9 +449,7 @@ fn apply_linux_webkit_workarounds() {
.unwrap_or(false); .unwrap_or(false);
if force_gpu { if force_gpu {
log::warn!( log::warn!("QBZ_WEBKIT_FORCE_GPU is enabled; skipping Linux WebKit safety workarounds");
"QBZ_WEBKIT_FORCE_GPU is enabled; skipping Linux WebKit safety workarounds"
);
return; return;
} }
@@ -574,9 +614,12 @@ pub fn run() {
if saved_win_width > max_w || saved_win_height > max_h { if saved_win_width > max_w || saved_win_height > max_h {
log::warn!( log::warn!(
"Window size {}x{} exceeds screen {}x{}, clamping to {}x{}", "Window size {}x{} exceeds screen {}x{}, clamping to {}x{}",
saved_win_width as u32, saved_win_height as u32, saved_win_width as u32,
screen_w as u32, screen_h as u32, saved_win_height as u32,
max_w as u32, max_h as u32 screen_w as u32,
screen_h as u32,
max_w as u32,
max_h as u32
); );
saved_win_width = saved_win_width.min(max_w); saved_win_width = saved_win_width.min(max_w);
saved_win_height = saved_win_height.min(max_h); saved_win_height = saved_win_height.min(max_h);
@@ -675,22 +718,20 @@ pub fn run() {
let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new(); let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new();
let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new(); let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new();
let lastfm_v2_state = integrations_v2::LastFmV2State::new(); let lastfm_v2_state = integrations_v2::LastFmV2State::new();
let image_cache_settings_state = config::ImageCacheSettingsState::new() let image_cache_settings_state = config::ImageCacheSettingsState::new().unwrap_or_else(|e| {
.unwrap_or_else(|e| { log::warn!(
log::warn!( "Failed to initialize image cache settings: {}. Using empty state.",
"Failed to initialize image cache settings: {}. Using empty state.", e
e );
); config::ImageCacheSettingsState::new_empty()
config::ImageCacheSettingsState::new_empty() });
}); let image_cache_state = image_cache::ImageCacheState::new().unwrap_or_else(|e| {
let image_cache_state = image_cache::ImageCacheState::new() log::warn!(
.unwrap_or_else(|e| { "Failed to initialize image cache: {}. Using empty state.",
log::warn!( e
"Failed to initialize image cache: {}. Using empty state.", );
e image_cache::ImageCacheState::new_empty()
); });
image_cache::ImageCacheState::new_empty()
});
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new() let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
log::warn!( log::warn!(
+8 -7
View File
@@ -2627,13 +2627,14 @@ pub async fn library_backfill_downloads(
} }
// Track doesn't exist - extract track/disc number from file tags // Track doesn't exist - extract track/disc number from file tags
let (track_num, disc_num) = match MetadataExtractor::extract(std::path::Path::new(&file_path)) { let (track_num, disc_num) =
Ok(meta) => (meta.track_number, meta.disc_number), match MetadataExtractor::extract(std::path::Path::new(&file_path)) {
Err(e) => { Ok(meta) => (meta.track_number, meta.disc_number),
log::warn!("Could not extract metadata from {}: {}", file_path, e); Err(e) => {
(None, None) log::warn!("Could not extract metadata from {}: {}", file_path, e);
} (None, None)
}; }
};
// Insert as new // Insert as new
match library_db.insert_qobuz_cached_track_direct( match library_db.insert_qobuz_cached_track_direct(
+9 -8
View File
@@ -133,7 +133,9 @@ fn main() {
eprintln!("[QBZ] - gsk_renderer: auto"); eprintln!("[QBZ] - gsk_renderer: auto");
eprintln!("[QBZ] - force_dmabuf: false"); eprintln!("[QBZ] - force_dmabuf: false");
eprintln!("[QBZ] You can now start QBZ normally."); eprintln!("[QBZ] You can now start QBZ normally.");
eprintln!("[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings."); eprintln!(
"[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings."
);
} else { } else {
eprintln!("[QBZ] Some settings could not be reset:"); eprintln!("[QBZ] Some settings could not be reset:");
for e in &errors { for e in &errors {
@@ -254,7 +256,10 @@ fn main() {
// Hardware acceleration: DB value is the default, env var overrides. // Hardware acceleration: DB value is the default, env var overrides.
// QBZ_HARDWARE_ACCEL=0 is the nuclear opt-out that disables all GPU // QBZ_HARDWARE_ACCEL=0 is the nuclear opt-out that disables all GPU
// compositing and DMA-BUF everywhere. // compositing and DMA-BUF everywhere.
let hw_accel_db = graphics_db.as_ref().map(|s| s.hardware_acceleration).unwrap_or(true); let hw_accel_db = graphics_db
.as_ref()
.map(|s| s.hardware_acceleration)
.unwrap_or(true);
let hardware_accel = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() { let hardware_accel = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
Ok("0") => { Ok("0") => {
qbz_nix_lib::logging::log_startup( qbz_nix_lib::logging::log_startup(
@@ -481,9 +486,7 @@ fn main() {
"[QBZ] Wayland+NVIDIA: compositing mode disabled (prevents protocol errors)", "[QBZ] Wayland+NVIDIA: compositing mode disabled (prevents protocol errors)",
); );
} else if is_wayland && !force_x11 { } else if is_wayland && !force_x11 {
qbz_nix_lib::logging::log_startup( qbz_nix_lib::logging::log_startup("[QBZ] Wayland: compositing mode enabled");
"[QBZ] Wayland: compositing mode enabled",
);
} }
// --- DMA-BUF renderer control --- // --- DMA-BUF renderer control ---
@@ -534,9 +537,7 @@ fn main() {
} }
// Catch GTK initialization panics and show a recovery message // Catch GTK initialization panics and show a recovery message
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| qbz_nix_lib::run());
qbz_nix_lib::run()
});
if let Err(panic_info) = result { if let Err(panic_info) = result {
let msg = if let Some(s) = panic_info.downcast_ref::<String>() { let msg = if let Some(s) = panic_info.downcast_ref::<String>() {
+11 -4
View File
@@ -390,7 +390,10 @@ impl MusicBrainzCache {
// ============ Scene Discovery Cache ============ // ============ Scene Discovery Cache ============
/// Get cached scene discovery results /// Get cached scene discovery results
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> { pub fn get_scene_cache(
&self,
cache_key: &str,
) -> Result<Option<LocationDiscoveryResponse>, String> {
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS; let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
let result: Option<String> = self let result: Option<String> = self
@@ -413,10 +416,14 @@ impl MusicBrainzCache {
} }
/// Cache scene discovery results /// Cache scene discovery results
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> { pub fn set_scene_cache(
&self,
cache_key: &str,
data: &LocationDiscoveryResponse,
) -> Result<(), String> {
let fetched_at = Self::current_timestamp(); let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data) let json =
.map_err(|e| format!("Failed to serialize scene: {}", e))?; serde_json::to_string(data).map_err(|e| format!("Failed to serialize scene: {}", e))?;
self.conn self.conn
.execute( .execute(
+16 -19
View File
@@ -296,10 +296,7 @@ impl MusicBrainzClient {
} }
/// Fetch artist tags only (lightweight, no relations) /// Fetch artist tags only (lightweight, no relations)
pub async fn get_artist_tags( pub async fn get_artist_tags(&self, mbid: &str) -> Result<Vec<String>, String> {
&self,
mbid: &str,
) -> Result<Vec<String>, String> {
if !self.is_enabled().await { if !self.is_enabled().await {
return Err("MusicBrainz integration is disabled".to_string()); return Err("MusicBrainz integration is disabled".to_string());
} }
@@ -335,7 +332,10 @@ impl MusicBrainzClient {
// Sort by vote count descending — highest voted tag = primary genre // Sort by vote count descending — highest voted tag = primary genre
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0))); tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect()) Ok(tags
.into_iter()
.map(|tag| tag.name.to_lowercase())
.collect())
} }
/// Search artists by tag (genre) /// Search artists by tag (genre)
@@ -363,7 +363,11 @@ impl MusicBrainzClient {
limit limit
); );
log::debug!("MusicBrainz artist search by tag '{}' (limit {})", tag, limit); log::debug!(
"MusicBrainz artist search by tag '{}' (limit {})",
tag,
limit
);
let response = self let response = self
.client .client
@@ -414,10 +418,7 @@ impl MusicBrainzClient {
let search_area = country.unwrap_or(area_name); let search_area = country.unwrap_or(area_name);
let escaped_area = Self::escape_query(search_area); let escaped_area = Self::escape_query(search_area);
let query = format!( let query = format!("tag:\"{}\" AND area:\"{}\"", escaped_tag, escaped_area);
"tag:\"{}\" AND area:\"{}\"",
escaped_tag, escaped_area
);
let url = format!( let url = format!(
"{}/artist?query={}&fmt=json&limit={}&offset={}", "{}/artist?query={}&fmt=json&limit={}&offset={}",
base_url, base_url,
@@ -426,10 +427,7 @@ impl MusicBrainzClient {
offset offset
); );
log::debug!( log::debug!("MusicBrainz artist search: {}", query);
"MusicBrainz artist search: {}",
query
);
let response = self let response = self
.client .client
@@ -633,7 +631,9 @@ impl MusicBrainzClient {
log::debug!( log::debug!(
"MusicBrainz browse artists by area {} (limit {}, offset {})", "MusicBrainz browse artists by area {} (limit {}, offset {})",
area_id, limit, offset area_id,
limit,
offset
); );
let response = self let response = self
@@ -720,10 +720,7 @@ impl MusicBrainzClient {
self.rate_limiter.wait().await; self.rate_limiter.wait().await;
let base_url = self.base_url().await; let base_url = self.base_url().await;
let url = format!( let url = format!("{}/area/{}?inc=area-rels&fmt=json", base_url, area_id);
"{}/area/{}?inc=area-rels&fmt=json",
base_url, area_id
);
log::debug!("MusicBrainz area lookup with relations: {}", area_id); log::debug!("MusicBrainz area lookup with relations: {}", area_id);
@@ -255,7 +255,13 @@ pub fn genre_summary(seeds: &AffinitySeeds) -> String {
if seeds.genres.is_empty() { if seeds.genres.is_empty() {
return String::new(); return String::new();
} }
seeds.genres.iter().take(3).cloned().collect::<Vec<_>>().join(" / ") seeds
.genres
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(" / ")
} }
#[cfg(test)] #[cfg(test)]
@@ -276,10 +282,22 @@ mod tests {
#[test] #[test]
fn test_noisy_tags_filtered() { fn test_noisy_tags_filtered() {
let tags = vec![ let tags = vec![
Tag { name: "rock".to_string(), count: Some(10) }, Tag {
Tag { name: "seen live".to_string(), count: Some(8) }, name: "rock".to_string(),
Tag { name: "awesome".to_string(), count: Some(5) }, count: Some(10),
Tag { name: "grunge".to_string(), count: Some(4) }, },
Tag {
name: "seen live".to_string(),
count: Some(8),
},
Tag {
name: "awesome".to_string(),
count: Some(5),
},
Tag {
name: "grunge".to_string(),
count: Some(4),
},
]; ];
let seeds = extract_affinity_seeds(&tags); let seeds = extract_affinity_seeds(&tags);
@@ -246,8 +246,10 @@ pub fn compute_affinity_score(
} }
// Normalize candidate tags for comparison // Normalize candidate tags for comparison
let candidate_normalized: HashSet<String> = let candidate_normalized: HashSet<String> = candidate_tags
candidate_tags.iter().map(|tag| normalize_genre(tag)).collect(); .iter()
.map(|tag| normalize_genre(tag))
.collect();
// Core genre overlap // Core genre overlap
let core_overlap = source_seeds let core_overlap = source_seeds
+36 -14
View File
@@ -7,11 +7,11 @@ use crate::api::models::Quality;
use crate::AppState; use crate::AppState;
use super::{CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, TrackCacheInfo}; use super::{CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, TrackCacheInfo};
use crate::library::MetadataExtractor;
use crate::offline_cache::metadata::{ use crate::offline_cache::metadata::{
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork, embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
write_flac_tags, write_flac_tags,
}; };
use crate::library::MetadataExtractor;
use crate::offline_cache::OfflineCacheState; use crate::offline_cache::OfflineCacheState;
/// Post-process a cached track: fetch metadata, tag FLAC, embed artwork, organize files /// Post-process a cached track: fetch metadata, tag FLAC, embed artwork, organize files
@@ -314,7 +314,11 @@ pub async fn cache_track_for_offline(
} }
} }
Err(e) => { Err(e) => {
log::warn!("Caching failed for track {} (attempt 1): {}. Retrying with fresh URL...", track_id, e); log::warn!(
"Caching failed for track {} (attempt 1): {}. Retrying with fresh URL...",
track_id,
e
);
// Retry with a fresh stream URL — CDN edge may have returned premature EOF // Retry with a fresh stream URL — CDN edge may have returned premature EOF
tokio::time::sleep(std::time::Duration::from_millis(500)).await; tokio::time::sleep(std::time::Duration::from_millis(500)).await;
@@ -336,7 +340,11 @@ pub async fn cache_track_for_offline(
match retry_result { match retry_result {
Ok(size) => { Ok(size) => {
log::info!("Caching complete for track {} (retry succeeded): {} bytes", track_id, size); log::info!(
"Caching complete for track {} (retry succeeded): {} bytes",
track_id,
size
);
if let Some(db_guard) = db.lock().await.as_ref() { if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.mark_complete(track_id, size); let _ = db_guard.mark_complete(track_id, size);
} }
@@ -351,9 +359,14 @@ pub async fn cache_track_for_offline(
let file_path_str = file_path.to_string_lossy().to_string(); let file_path_str = file_path.to_string_lossy().to_string();
let qobuz_client = client.read().await; let qobuz_client = client.read().await;
if let Ok(new_path) = post_process_cached_track( if let Ok(new_path) = post_process_cached_track(
track_id, &file_path_str, &offline_root, track_id,
&*qobuz_client, library_db.clone(), &file_path_str,
).await { &offline_root,
&*qobuz_client,
library_db.clone(),
)
.await
{
if let Some(db_guard) = db.lock().await.as_ref() { if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_file_path(track_id, &new_path); let _ = db_guard.update_file_path(track_id, &new_path);
} }
@@ -369,7 +382,11 @@ pub async fn cache_track_for_offline(
Err(e2) => { Err(e2) => {
log::error!("Caching failed for track {} (attempt 2): {}", track_id, e2); log::error!("Caching failed for track {} (attempt 2): {}", track_id, e2);
if let Some(db_guard) = db.lock().await.as_ref() { if let Some(db_guard) = db.lock().await.as_ref() {
let _ = db_guard.update_status(track_id, OfflineCacheStatus::Failed, Some(&e2)); let _ = db_guard.update_status(
track_id,
OfflineCacheStatus::Failed,
Some(&e2),
);
} }
let _ = app.emit( let _ = app.emit(
"offline:caching_failed", "offline:caching_failed",
@@ -933,13 +950,18 @@ pub async fn sync_offline_cache_to_library(
} }
Ok(false) => { Ok(false) => {
// Extract track/disc number from file tags // Extract track/disc number from file tags
let (track_num, disc_num) = match MetadataExtractor::extract(std::path::Path::new(&track.file_path)) { let (track_num, disc_num) =
Ok(meta) => (meta.track_number, meta.disc_number), match MetadataExtractor::extract(std::path::Path::new(&track.file_path)) {
Err(e) => { Ok(meta) => (meta.track_number, meta.disc_number),
log::warn!("Could not extract metadata from {}: {}", track.file_path, e); Err(e) => {
(None, None) log::warn!(
} "Could not extract metadata from {}: {}",
}; track.file_path,
e
);
(None, None)
}
};
// Insert into library with metadata from file tags // Insert into library with metadata from file tags
match library_db.insert_qobuz_cached_track_direct( match library_db.insert_qobuz_cached_track_direct(
+6 -1
View File
@@ -83,7 +83,12 @@ impl StreamFetcher {
msg.push_str(&format!(" | caused by: {}", cause)); msg.push_str(&format!(" | caused by: {}", cause));
source = cause.source(); source = cause.source();
} }
log::error!("[Offline] Download error for track {} after {} bytes: {}", track_id, cached, msg); log::error!(
"[Offline] Download error for track {} after {} bytes: {}",
track_id,
cached,
msg
);
msg msg
})?; })?;
+18 -17
View File
@@ -91,24 +91,23 @@ pub async fn v2_booklet_open(
// Save to temp file // Save to temp file
let temp_dir = booklet_temp_dir(); let temp_dir = booklet_temp_dir();
std::fs::create_dir_all(&temp_dir) std::fs::create_dir_all(&temp_dir).map_err(|e| format!("Failed to create temp dir: {}", e))?;
.map_err(|e| format!("Failed to create temp dir: {}", e))?;
let file_name = format!("{}.pdf", uuid::Uuid::new_v4()); let file_name = format!("{}.pdf", uuid::Uuid::new_v4());
let temp_path = temp_dir.join(&file_name); let temp_path = temp_dir.join(&file_name);
std::fs::write(&temp_path, &bytes) std::fs::write(&temp_path, &bytes).map_err(|e| format!("Failed to write temp file: {}", e))?;
.map_err(|e| format!("Failed to write temp file: {}", e))?;
// Open with MuPDF and get page info (blocking operation) // Open with MuPDF and get page info (blocking operation)
let path_str = temp_path.to_string_lossy().to_string(); let path_str = temp_path.to_string_lossy().to_string();
let info = tokio::task::spawn_blocking(move || -> Result<BookletInfo, String> { let info = tokio::task::spawn_blocking(move || -> Result<BookletInfo, String> {
let document = Document::open(&path_str) let document =
.map_err(|e| format!("Failed to open PDF: {:?}", e))?; Document::open(&path_str).map_err(|e| format!("Failed to open PDF: {:?}", e))?;
let num_pages = document let num_pages = document
.page_count() .page_count()
.map_err(|e| format!("Failed to get page count: {:?}", e))? as u32; .map_err(|e| format!("Failed to get page count: {:?}", e))?
as u32;
let mut page_sizes = Vec::with_capacity(num_pages as usize); let mut page_sizes = Vec::with_capacity(num_pages as usize);
for i in 0..num_pages { for i in 0..num_pages {
@@ -170,8 +169,7 @@ pub async fn v2_booklet_render_page(
let dpi = dpi.max(36).min(600); // Clamp DPI to sane range let dpi = dpi.max(36).min(600); // Clamp DPI to sane range
tokio::task::spawn_blocking(move || -> Result<RenderedPage, String> { tokio::task::spawn_blocking(move || -> Result<RenderedPage, String> {
let document = Document::open(&path) let document = Document::open(&path).map_err(|e| format!("Failed to open PDF: {:?}", e))?;
.map_err(|e| format!("Failed to open PDF: {:?}", e))?;
let page_index = (page - 1) as i32; let page_index = (page - 1) as i32;
let pdf_page = document let pdf_page = document
@@ -196,9 +194,15 @@ pub async fn v2_booklet_render_page(
let mut m = Matrix::new_scale(scale, scale); let mut m = Matrix::new_scale(scale, scale);
m.concat(Matrix::new_rotate(rotation as f32)); m.concat(Matrix::new_rotate(rotation as f32));
match rotation % 360 { match rotation % 360 {
90 => { m.concat(Matrix::new_translate(h, 0.0)); } 90 => {
180 => { m.concat(Matrix::new_translate(w, h)); } m.concat(Matrix::new_translate(h, 0.0));
270 => { m.concat(Matrix::new_translate(0.0, w)); } }
180 => {
m.concat(Matrix::new_translate(w, h));
}
270 => {
m.concat(Matrix::new_translate(0.0, w));
}
_ => {} _ => {}
}; };
m m
@@ -246,17 +250,14 @@ pub async fn v2_booklet_save(
.clone() .clone()
}; };
std::fs::copy(&src, &dest) std::fs::copy(&src, &dest).map_err(|e| format!("Failed to save booklet: {}", e))?;
.map_err(|e| format!("Failed to save booklet: {}", e))?;
Ok(()) Ok(())
} }
/// Clean up the current booklet temp file. /// Clean up the current booklet temp file.
#[tauri::command] #[tauri::command]
pub async fn v2_booklet_close( pub async fn v2_booklet_close(state: tauri::State<'_, BookletState>) -> Result<(), String> {
state: tauri::State<'_, BookletState>,
) -> Result<(), String> {
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?; let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
if let Some(old_path) = path_lock.take() { if let Some(old_path) = path_lock.take() {
let _ = std::fs::remove_file(&old_path); let _ = std::fs::remove_file(&old_path);
+6 -5
View File
@@ -429,7 +429,7 @@ fn create_output_stream_with_config(
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the // MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
// ONLY buffer between the mixer and audio hardware. // ONLY buffer between the mixer and audio hardware.
let cpal_buffer_size = if exclusive_mode { let cpal_buffer_size = if exclusive_mode {
BufferSize::Fixed(512) // Low latency for exclusive mode BufferSize::Fixed(512) // Low latency for exclusive mode
} else { } else {
// ~100ms buffer, matching old vendored cpal period size. // ~100ms buffer, matching old vendored cpal period size.
// Prevents underruns at high sample rates (192kHz = 19200 frames). // Prevents underruns at high sample rates (192kHz = 19200 frames).
@@ -440,9 +440,11 @@ fn create_output_stream_with_config(
// Create MixerDeviceSink with custom config // Create MixerDeviceSink with custom config
match DeviceSinkBuilder::from_device(device) { match DeviceSinkBuilder::from_device(device) {
Ok(builder) => { Ok(builder) => {
match builder.with_supported_config(&supported_config) match builder
.with_supported_config(&supported_config)
.with_buffer_size(cpal_buffer_size) .with_buffer_size(cpal_buffer_size)
.open_stream() { .open_stream()
{
Ok(mixer_sink) => { Ok(mixer_sink) => {
log::info!("MixerDeviceSink created successfully at {}Hz", sample_rate); log::info!("MixerDeviceSink created successfully at {}Hz", sample_rate);
Ok(mixer_sink) Ok(mixer_sink)
@@ -976,8 +978,7 @@ impl Player {
} }
}; };
match DeviceSinkBuilder::from_device(device) match DeviceSinkBuilder::from_device(device).and_then(|b| b.open_sink_or_fallback())
.and_then(|b| b.open_sink_or_fallback())
{ {
Ok(mixer_sink) => { Ok(mixer_sink) => {
log::info!("Audio output initialized successfully"); log::info!("Audio output initialized successfully");
+6 -2
View File
@@ -85,7 +85,9 @@ impl PlaybackEngine {
"ALSA Direct gapless: next source channel full or closed".to_string() "ALSA Direct gapless: next source channel full or closed".to_string()
}); });
} }
return Err("ALSA Direct: playback thread running but no gapless channel".to_string()); return Err(
"ALSA Direct: playback thread running but no gapless channel".to_string(),
);
} }
// First source — spawn the playback thread with a gapless channel // First source — spawn the playback thread with a gapless channel
@@ -104,7 +106,9 @@ impl PlaybackEngine {
should_stop.store(false, Ordering::SeqCst); should_stop.store(false, Ordering::SeqCst);
position_clone.store(0, Ordering::SeqCst); position_clone.store(0, Ordering::SeqCst);
log::info!("[ALSA Direct Engine] Starting streaming playback thread (gapless-capable)"); log::info!(
"[ALSA Direct Engine] Starting streaming playback thread (gapless-capable)"
);
let initial_source: BoxedSourceIter = Box::new(source.into_iter()); let initial_source: BoxedSourceIter = Box::new(source.into_iter());
@@ -71,7 +71,10 @@ pub fn parse_playlist_id(url: &str) -> Option<String> {
/// Fetch track or album metadata from Spotify embed page. /// Fetch track or album metadata from Spotify embed page.
/// Returns (title, artist) if successful. /// Returns (title, artist) if successful.
pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> { pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> {
let url = format!("https://open.spotify.com/embed/{}/{}", entity_type, entity_id); let url = format!(
"https://open.spotify.com/embed/{}/{}",
entity_type, entity_id
);
let html = reqwest::get(&url).await.ok()?.text().await.ok()?; let html = reqwest::get(&url).await.ok()?.text().await.ok()?;
let json_text = extract_script(&html, "__NEXT_DATA__")?; let json_text = extract_script(&html, "__NEXT_DATA__")?;
let data: Value = serde_json::from_str(&json_text).ok()?; let data: Value = serde_json::from_str(&json_text).ok()?;
@@ -99,7 +102,12 @@ pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
}) })
.or_else(|| entity.get("subtitle").and_then(|v| v.as_str()).map(|s| s.to_string())) .or_else(|| {
entity
.get("subtitle")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.unwrap_or_default(); .unwrap_or_default();
if title.is_empty() { if title.is_empty() {
+37 -1
View File
@@ -2629,8 +2629,17 @@ async fn align_corebridge_queue_cursor(bridge: &CoreBridge, track_id: u64) -> Re
Ok(()) Ok(())
} }
fn should_reload_remote_track(
playback_state: &qbz_player::PlaybackState,
has_loaded_audio: bool,
track_id: u64,
) -> bool {
playback_state.track_id != track_id || !has_loaded_audio
}
async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> { async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
if bridge.get_playback_state().track_id == track_id { let playback_state = bridge.get_playback_state();
if !should_reload_remote_track(&playback_state, bridge.has_loaded_audio(), track_id) {
return Ok(()); return Ok(());
} }
@@ -4259,6 +4268,33 @@ mod tests {
); );
} }
#[test]
fn reloads_remote_track_when_same_track_id_has_no_loaded_audio() {
let playback_state = qbz_player::PlaybackState {
is_playing: false,
position: 0,
duration: 279,
track_id: 193849747,
volume: 1.0,
};
assert!(super::should_reload_remote_track(
&playback_state,
false,
193849747,
));
assert!(!super::should_reload_remote_track(
&playback_state,
true,
193849747,
));
assert!(super::should_reload_remote_track(
&playback_state,
true,
126886862,
));
}
#[test] #[test]
fn resolves_next_queue_item_id_from_shuffle_order() { fn resolves_next_queue_item_id_from_shuffle_order() {
let queue: QConnectQueueState = serde_json::from_value(json!({ let queue: QConnectQueueState = serde_json::from_value(json!({
+8 -2
View File
@@ -949,13 +949,19 @@ mod tests {
let state = queue.get_state(); let state = queue.get_state();
assert_eq!(state.current_track.as_ref().map(|track| track.id), Some(3)); assert_eq!(state.current_track.as_ref().map(|track| track.id), Some(3));
let mut upcoming_sorted = state.upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(); let mut upcoming_sorted = state
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>();
upcoming_sorted.sort_unstable(); upcoming_sorted.sort_unstable();
assert_eq!(upcoming_sorted, vec![4, 5, 6]); assert_eq!(upcoming_sorted, vec![4, 5, 6]);
let mut advanced = Vec::new(); let mut advanced = Vec::new();
for _ in 0..3 { for _ in 0..3 {
let track = queue.next().expect("expected next track while remanent queue exists"); let track = queue
.next()
.expect("expected next track while remanent queue exists");
advanced.push(track.id); advanced.push(track.id);
} }
advanced.sort_unstable(); advanced.sort_unstable();
+15 -10
View File
@@ -677,7 +677,8 @@ fn get_home_seeds_internal(
let recently_played_album_ids = if has_scores { let recently_played_album_ids = if has_scores {
let recent_fresh = db.get_recent_album_ids(4)?; let recent_fresh = db.get_recent_album_ids(4)?;
let scored = db.get_scored_album_ids("all", limit_recent_albums + 4)?; let scored = db.get_scored_album_ids("all", limit_recent_albums + 4)?;
let merged = merge_unique_preserve_order(recent_fresh, scored, limit_recent_albums as usize); let merged =
merge_unique_preserve_order(recent_fresh, scored, limit_recent_albums as usize);
if merged.is_empty() { if merged.is_empty() {
db.get_recent_album_ids(limit_recent_albums)? db.get_recent_album_ids(limit_recent_albums)?
} else { } else {
@@ -690,7 +691,8 @@ fn get_home_seeds_internal(
let continue_listening_track_ids = if has_scores { let continue_listening_track_ids = if has_scores {
let recent_fresh = db.get_recent_track_ids(4)?; let recent_fresh = db.get_recent_track_ids(4)?;
let scored = db.get_scored_track_ids("all", limit_continue_tracks + 4)?; let scored = db.get_scored_track_ids("all", limit_continue_tracks + 4)?;
let merged = merge_unique_preserve_order(recent_fresh, scored, limit_continue_tracks as usize); let merged =
merge_unique_preserve_order(recent_fresh, scored, limit_continue_tracks as usize);
if merged.is_empty() { if merged.is_empty() {
db.get_recent_track_ids(limit_continue_tracks)? db.get_recent_track_ids(limit_continue_tracks)?
} else { } else {
@@ -701,7 +703,8 @@ fn get_home_seeds_internal(
}; };
let top_artist_ids = if has_scores { let top_artist_ids = if has_scores {
let scored: Vec<TopArtistSeed> = db.get_scored_artist_scores("all", limit_top_artists)? let scored: Vec<TopArtistSeed> = db
.get_scored_artist_scores("all", limit_top_artists)?
.into_iter() .into_iter()
.map(|(artist_id, score)| TopArtistSeed { .map(|(artist_id, score)| TopArtistSeed {
artist_id, artist_id,
@@ -827,8 +830,12 @@ pub async fn reco_get_home_resolved(
&cache_state, &cache_state,
); );
let (recently_played_albums, resolved_favorites, all_tracks, top_artists) = let (recently_played_albums, resolved_favorites, all_tracks, top_artists) = tokio::try_join!(
tokio::try_join!(recent_albums_fut, favorite_albums_fut, tracks_fut, artists_fut)?; recent_albums_fut,
favorite_albums_fut,
tracks_fut,
artists_fut
)?;
// Build track lookup // Build track lookup
let track_map: HashMap<u64, &TrackDisplayMeta> = let track_map: HashMap<u64, &TrackDisplayMeta> =
@@ -914,11 +921,9 @@ pub async fn reco_get_home_resolved(
let _permit = sem.acquire().await.ok()?; let _permit = sem.acquire().await.ok()?;
let artist = { let artist = {
let c = client.read().await; let c = client.read().await;
c.get_artist_with_pagination_and_locale( c.get_artist_with_pagination_and_locale(artist_id, true, Some(25), None, None)
artist_id, true, Some(25), None, None, .await
) .ok()?
.await
.ok()?
}; };
let albums = artist.albums?; let albums = artist.albums?;
let mut results: Vec<AlbumCardMeta> = Vec::new(); let mut results: Vec<AlbumCardMeta> = Vec::new();
+6 -3
View File
@@ -946,8 +946,7 @@ impl RecoStoreDb {
let mut artists = Vec::new(); let mut artists = Vec::new();
for row in rows { for row in rows {
artists artists.push(row.map_err(|e| format!("Failed to read known artist name row: {}", e))?);
.push(row.map_err(|e| format!("Failed to read known artist name row: {}", e))?);
} }
Ok(artists) Ok(artists)
} }
@@ -975,7 +974,11 @@ impl RecoStoreDb {
} }
/// Dismiss an artist for a specific tag (genre context) /// Dismiss an artist for a specific tag (genre context)
pub fn dismiss_discovery_artist(&self, tag: &str, artist_name_normalized: &str) -> Result<(), String> { pub fn dismiss_discovery_artist(
&self,
tag: &str,
artist_name_normalized: &str,
) -> Result<(), String> {
let created_at = SystemTime::now() let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64) .map(|d| d.as_secs() as i64)
+7 -4
View File
@@ -104,10 +104,13 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
listenbrainz.init_at(&data_dir).await?; listenbrainz.init_at(&data_dir).await?;
// Initialize V2 integration caches at user data directory // Initialize V2 integration caches at user data directory
listenbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| { listenbrainz_v2
log::error!("[SessionLifecycle] LB V2 cache init failed: {}", e); .init_cache_at(&data_dir)
e .await
})?; .map_err(|e| {
log::error!("[SessionLifecycle] LB V2 cache init failed: {}", e);
e
})?;
musicbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| { musicbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| {
log::error!("[SessionLifecycle] MB V2 cache init failed: {}", e); log::error!("[SessionLifecycle] MB V2 cache init failed: {}", e);
e e
+1 -2
View File
@@ -56,8 +56,7 @@ fn load_tray_icon() -> Image<'static> {
// In Flatpak, try to use the installed icon file first // In Flatpak, try to use the installed icon file first
// This works better with StatusNotifierItem/libayatana-appindicator // This works better with StatusNotifierItem/libayatana-appindicator
if is_flatpak() { if is_flatpak() {
let icon_path = let icon_path = PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzfc.qbz.png");
PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzfc.qbz.png");
if icon_path.exists() { if icon_path.exists() {
log::info!("Flatpak detected, loading tray icon from: {:?}", icon_path); log::info!("Flatpak detected, loading tray icon from: {:?}", icon_path);
if let Ok(icon_data) = std::fs::read(&icon_path) { if let Ok(icon_data) = std::fs::read(&icon_path) {
+38
View File
@@ -5,6 +5,7 @@ import {
evaluateQconnectSessionPersistence, evaluateQconnectSessionPersistence,
isQconnectPeerRendererActive, isQconnectPeerRendererActive,
isQconnectRemoteModeActive, isQconnectRemoteModeActive,
resolveQconnectPlayNextAuthoritativeTrackId,
type QconnectConnectionStatus type QconnectConnectionStatus
} from './qconnectRuntime'; } from './qconnectRuntime';
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue'; import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
@@ -180,4 +181,41 @@ describe('QConnect runtime state helpers', () => {
}) })
).toBe(true); ).toBe(true);
}); });
it('does not trust the local current track for play next when a peer renderer is active', () => {
expect(
resolveQconnectPlayNextAuthoritativeTrackId({
sessionSnapshot: {
session_uuid: 'session-1',
active_renderer_id: 4,
local_renderer_id: 11,
renderers: []
},
localCurrentTrackId: 48425288
})
).toBeNull();
});
it('keeps the local current track authoritative when the local renderer is active', () => {
expect(
resolveQconnectPlayNextAuthoritativeTrackId({
sessionSnapshot: {
session_uuid: 'session-1',
active_renderer_id: 11,
local_renderer_id: 11,
renderers: []
},
localCurrentTrackId: 48425288
})
).toBe(48425288);
});
it('does not trust the local current track when the session snapshot is unavailable', () => {
expect(
resolveQconnectPlayNextAuthoritativeTrackId({
sessionSnapshot: null,
localCurrentTrackId: 48425288
})
).toBeNull();
});
}); });
+24
View File
@@ -194,6 +194,30 @@ export function isQconnectPeerRendererActive(
return activeRendererId !== localRendererId; return activeRendererId !== localRendererId;
} }
export function resolveQconnectPlayNextAuthoritativeTrackId(params: {
sessionSnapshot: QconnectSessionSnapshot | null | undefined;
localCurrentTrackId: number | null | undefined;
}): number | null {
const { sessionSnapshot, localCurrentTrackId } = params;
if (
typeof localCurrentTrackId !== 'number' ||
!Number.isFinite(localCurrentTrackId) ||
localCurrentTrackId <= 0
) {
return null;
}
const activeRendererId = sessionSnapshot?.active_renderer_id ?? null;
const localRendererId = sessionSnapshot?.local_renderer_id ?? null;
if (activeRendererId == null || localRendererId == null || activeRendererId < 0) {
return null;
}
return isQconnectPeerRendererActive(sessionSnapshot) ? null : localCurrentTrackId;
}
export function evaluateQconnectSessionPersistence( export function evaluateQconnectSessionPersistence(
remoteModeActive: boolean, remoteModeActive: boolean,
skipLogged: boolean skipLogged: boolean
+17 -5
View File
@@ -21,9 +21,13 @@ import { showToast as storeShowToast, type ToastType } from '$lib/stores/toastSt
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { QobuzTrack, Track, PlaylistTrack, LocalLibraryTrack, DisplayTrack } from '$lib/types'; import type { QobuzTrack, Track, PlaylistTrack, LocalLibraryTrack, DisplayTrack } from '$lib/types';
import { import {
qconnectAdmissionReasonKey qconnectAdmissionReasonKey,
resolveQconnectPlayNextAuthoritativeTrackId
} from '$lib/services/qconnectRuntime';
import type {
QconnectConnectionStatus,
QconnectSessionSnapshot
} from '$lib/services/qconnectRuntime'; } from '$lib/services/qconnectRuntime';
import type { QconnectConnectionStatus } from '$lib/services/qconnectRuntime';
import { import {
resolveQconnectPlayNextInsertAfter, resolveQconnectPlayNextInsertAfter,
type QconnectDiagnosticsPayload, type QconnectDiagnosticsPayload,
@@ -117,14 +121,19 @@ type QconnectPlayNextResolution = {
}; };
async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<QconnectPlayNextResolution> { async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<QconnectPlayNextResolution> {
const [queueSnapshotResult, rendererSnapshotResult] = await Promise.allSettled([ const [queueSnapshotResult, rendererSnapshotResult, sessionSnapshotResult] = await Promise.allSettled([
invoke<QconnectQueueSnapshot>('v2_qconnect_queue_snapshot'), invoke<QconnectQueueSnapshot>('v2_qconnect_queue_snapshot'),
invoke<QconnectRendererSnapshot>('v2_qconnect_renderer_snapshot') invoke<QconnectRendererSnapshot>('v2_qconnect_renderer_snapshot'),
invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot')
]); ]);
const queueSnapshot = queueSnapshotResult.status === 'fulfilled' ? queueSnapshotResult.value : null; const queueSnapshot = queueSnapshotResult.status === 'fulfilled' ? queueSnapshotResult.value : null;
const rendererSnapshot = rendererSnapshotResult.status === 'fulfilled' ? rendererSnapshotResult.value : null; const rendererSnapshot = rendererSnapshotResult.status === 'fulfilled' ? rendererSnapshotResult.value : null;
const localCurrentTrackId = getPlayerState().currentTrack?.id ?? null; const sessionSnapshot = sessionSnapshotResult.status === 'fulfilled' ? sessionSnapshotResult.value : null;
const localCurrentTrackId = resolveQconnectPlayNextAuthoritativeTrackId({
sessionSnapshot,
localCurrentTrackId: getPlayerState().currentTrack?.id ?? null
});
const resolution = resolveQconnectPlayNextInsertAfter(queueSnapshot, rendererSnapshot, { const resolution = resolveQconnectPlayNextInsertAfter(queueSnapshot, rendererSnapshot, {
authoritativeCurrentTrackId: localCurrentTrackId authoritativeCurrentTrackId: localCurrentTrackId
}); });
@@ -135,6 +144,9 @@ async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<Qconne
: null, : null,
rendererSnapshotResult.status === 'rejected' rendererSnapshotResult.status === 'rejected'
? `renderer_snapshot=${String(rendererSnapshotResult.reason)}` ? `renderer_snapshot=${String(rendererSnapshotResult.reason)}`
: null,
sessionSnapshotResult.status === 'rejected'
? `session_snapshot=${String(sessionSnapshotResult.reason)}`
: null : null
].filter((value): value is string => value !== null).join('; '); ].filter((value): value is string => value !== null).join('; ');