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.
//! 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::{
cpal::{
traits::{DeviceTrait, HostTrait},
@@ -225,7 +227,11 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
if !device_num.is_empty() {
devices.push(ProcPcmInfo {
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
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))
});
@@ -257,7 +265,8 @@ fn build_card_info_map() -> HashMap<String, (String, String)> {
/// Find card number by short name (e.g., "C20" -> "0")
fn find_card_number_by_name(short_name: &str) -> Option<String> {
let cards = read_proc_asound_cards();
cards.iter()
cards
.iter()
.find(|c| c.short_name == short_name)
.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())
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
// 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 cards = read_proc_asound_cards();
cards.iter()
cards
.iter()
.find(|c| c.number == card_num)
.map(|c| c.short_name.clone())
} else {
@@ -337,7 +351,10 @@ impl AlsaBackend {
// Check if ALSA is available
// 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());
}
@@ -346,8 +363,9 @@ impl AlsaBackend {
available_hosts
.into_iter()
.find(|h| h.name().eq_ignore_ascii_case("alsa"))
.ok_or("ALSA host not found".to_string())?
).map_err(|e| format!("Failed to create ALSA host: {}", e))?;
.ok_or("ALSA host not found".to_string())?,
)
.map_err(|e| format!("Failed to create ALSA host: {}", e))?;
log::info!("[ALSA Backend] Initialized successfully");
@@ -373,24 +391,32 @@ impl AlsaBackend {
for card in &cards {
log::debug!(
"[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)
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
let default_sample_rates = cpal_devices.get("default")
let default_sample_rates = cpal_devices
.get("default")
.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());
devices.push(AudioDevice {
id: "default".to_string(),
name: "default".to_string(),
description: None, // Frontend shows "System Default"
description: None, // Frontend shows "System Default"
is_default: true,
max_sample_rate: default_max_rate.or(Some(384000)),
supported_sample_rates: default_sample_rates,
@@ -402,7 +428,8 @@ impl AlsaBackend {
for card in &cards {
// Add sysdefault:CARD=name (card default with software mixing)
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));
devices.push(AudioDevice {
@@ -410,12 +437,13 @@ impl AlsaBackend {
name: sysdefault_id.clone(),
description: Some(format!("{}, {}", card.long_name, sysdefault_id)),
is_default: false,
max_sample_rate: sysdefault_rates.as_ref()
max_sample_rate: sysdefault_rates
.as_ref()
.and_then(|r| r.iter().max().copied())
.or(Some(192000)),
supported_sample_rates: sysdefault_rates,
device_bus: None,
is_hardware: false, // sysdefault uses dmix
is_hardware: false, // sysdefault uses dmix
});
// Add PCM-specific devices (front:, iec958:, hdmi:)
@@ -424,14 +452,18 @@ impl AlsaBackend {
let device_prefix = if pcm.name.to_lowercase().contains("hdmi") {
"hdmi"
} else if pcm.name.to_lowercase().contains("iec958")
|| pcm.name.to_lowercase().contains("spdif")
|| pcm.name.to_lowercase().contains("s/pdif") {
|| pcm.name.to_lowercase().contains("spdif")
|| pcm.name.to_lowercase().contains("s/pdif")
{
"iec958"
} 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)
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)
let sample_rates = cpal_devices.get(&device_id)
let sample_rates = cpal_devices
.get(&device_id)
.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())
.or(Some(384000)); // Assume high capability if CPAL unavailable
.or(Some(384000)); // Assume high capability if CPAL unavailable
devices.push(AudioDevice {
id: device_id.clone(),
@@ -504,7 +538,10 @@ impl AlsaBackend {
// Only use direct ALSA for hw:/plughw:/front: devices
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;
}
@@ -538,7 +575,9 @@ impl AlsaBackend {
}
log::info!(
"[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...");
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) => {
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) => {
log::warn!("[ALSA Backend] Retry after suspend also failed: {}", e2);
log::warn!(
"[ALSA Backend] Retry after suspend also failed: {}",
e2
);
}
}
} else {
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
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!(
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
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) {
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)))
}
Err(e) => {
@@ -657,16 +715,28 @@ impl AlsaBackend {
.output()
{
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));
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
match super::AlsaDirectStream::new(
&plughw_device,
config.sample_rate,
config.channels,
) {
Ok(stream) => {
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) => {
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 parts: Vec<&str> = stripped.split(',').collect();
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
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);
}
log::warn!("[ALSA] Card '{}' not found in current enumeration", card_name);
log::warn!(
"[ALSA] Card '{}' not found in current enumeration",
card_name
);
None
}
@@ -796,10 +872,7 @@ impl AudioBackend for AlsaBackend {
self.enumerate_with_proc_descriptions()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
log::info!(
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
config.sample_rate,
@@ -898,7 +971,9 @@ impl AudioBackend for AlsaBackend {
// In exclusive mode, PipeWire may have re-acquired the device after the
// previous ALSA Direct stream released it. Suspend PipeWire before opening.
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")
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
.output()
@@ -941,12 +1016,15 @@ impl AudioBackend for AlsaBackend {
if effective_rate != config.sample_rate {
log::info!(
"[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 {
log::info!(
"[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
let selected_format = {
let hwp = HwParams::any(&pcm)
.map_err(|e| format!("Failed to get hardware params: {}", e))?;
let hwp =
HwParams::any(&pcm).map_err(|e| format!("Failed to get hardware params: {}", e))?;
// Set access type (interleaved)
hwp.set_access(Access::RWInterleaved)
@@ -49,10 +49,10 @@ impl AlsaDirectStream {
// S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip)
// Then descending bit-depth for quality
let format_priority = [
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
(Format::S32LE, "S32LE"), // 32-bit
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
(Format::S16LE, "S16LE"), // 16-bit
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
(Format::S32LE, "S32LE"), // 32-bit
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
(Format::S16LE, "S16LE"), // 16-bit
(Format::FloatLE, "Float32LE"), // Float (compatibility)
];
@@ -65,8 +65,10 @@ impl AlsaDirectStream {
}
}
let format = selected_format
.ok_or_else(|| "No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)".to_string())?;
let format = selected_format.ok_or_else(|| {
"No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)"
.to_string()
})?;
// Set channels
hwp.set_channels(channels as u32)
@@ -99,8 +101,13 @@ impl AlsaDirectStream {
pcm.hw_params(&hwp)
.map_err(|e| format!("Failed to apply hardware params: {}", e))?;
log::info!("[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
sample_rate, channels, buffer_size, format);
log::info!(
"[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
sample_rate,
channels,
buffer_size,
format
);
format
};
@@ -128,18 +135,21 @@ impl AlsaDirectStream {
match self.format {
Format::FloatLE => {
// Convert i16 to f32
let samples_f32: Vec<f32> = samples_i16
.iter()
.map(|&s| s as f32 / 32768.0)
.collect();
let samples_f32: Vec<f32> =
samples_i16.iter().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))?;
match io.writei(&samples_f32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -155,18 +165,20 @@ impl AlsaDirectStream {
}
Format::S32LE => {
// Convert i16 to i32 (bit-perfect: shift left 16 bits)
let samples_i32: Vec<i32> = samples_i16
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let samples_i32: Vec<i32> = samples_i16.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))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -182,13 +194,18 @@ impl AlsaDirectStream {
}
Format::S16LE => {
// 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))?;
match io.writei(samples_i16) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -212,8 +229,8 @@ impl AlsaDirectStream {
// Convert i16 to i24 (lossless: zeros in lower 8 bits)
let s24 = (sample as i32) << 8;
// Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
}
@@ -223,7 +240,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) {
Ok(written) => {
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(())
}
@@ -240,18 +261,20 @@ impl AlsaDirectStream {
Format::S24LE => {
// S24LE: 24-bit in 32-bit container (padded)
// Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source)
let samples_i32: Vec<i32> = samples_i16
.iter()
.map(|&s| (s as i32) << 16)
.collect();
let samples_i32: Vec<i32> = samples_i16.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))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
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 {
Format::FloatLE => {
// 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))?;
match io.writei(samples_f32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -310,13 +336,18 @@ impl AlsaDirectStream {
.map(|&s| (s * 2_147_483_647.0) as i32)
.collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -341,13 +372,18 @@ impl AlsaDirectStream {
})
.collect();
let io = pcm.io_i32()
let io = pcm
.io_i32()
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
match io.writei(&samples_i32) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
Ok(())
}
@@ -370,8 +406,8 @@ impl AlsaDirectStream {
let scaled = sample * 8_388_607.0;
let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32;
// Pack as 3 bytes in little-endian order
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push((s24 & 0xFF) as u8); // LSB
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
}
@@ -380,7 +416,11 @@ impl AlsaDirectStream {
match io.writei(&bytes) {
Ok(written) => {
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(())
}
@@ -396,18 +436,21 @@ impl AlsaDirectStream {
}
Format::S16LE => {
// f32 -> i16
let samples_i16: Vec<i16> = samples_f32
.iter()
.map(|&s| (s * 32_767.0) as i16)
.collect();
let samples_i16: Vec<i16> =
samples_f32.iter().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))?;
match io.writei(&samples_i16) {
Ok(written) => {
if written != frames {
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
log::warn!(
"[ALSA Direct] Partial write: {} / {} frames",
written,
frames
);
}
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.
#[cfg(target_os = "linux")]
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
use alsa::mixer::{Mixer, SelemId};
use alsa::mixer::SelemChannelId::*;
use alsa::mixer::{Mixer, SelemId};
// Open mixer for device
let mixer = Mixer::new(&self.device_id, false)
@@ -491,8 +532,13 @@ impl AlsaDirectStream {
let (min, max) = selem.get_playback_volume_range();
let target = min + ((max - min) as f32 * volume) as i64;
log::info!("[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
name, volume * 100.0, target, max);
log::info!(
"[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
name,
volume * 100.0,
target,
max
);
// Set volume on all channels
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
+1 -4
View File
@@ -217,10 +217,7 @@ pub trait AudioBackend: Send + Sync {
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>;
/// Create an output stream for the given configuration
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink>;
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink>;
/// Check if this backend is available on the current system
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 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 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 pulse_backend;
pub mod alsa_direct;
pub mod diagnostic;
pub mod loudness;
pub mod dynamic_amplify;
pub mod analysis;
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_cache;
pub mod pipewire_backend;
pub mod pulse_backend;
pub mod settings;
pub mod visualizer;
pub mod analysis;
// Re-export commonly used types
pub use backend::{
AlsaPlugin,
AudioBackend,
AudioBackendType,
AudioDevice,
BackendConfig,
BackendManager,
BackendResult,
AlsaDirectError,
BitPerfectMode,
pub use alsa_backend::{
device_supports_sample_rate, get_device_supported_rates, normalize_device_id_to_stable,
resolve_stable_to_current_hw,
};
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 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
//! not invoked and the audio pipeline remains bit-perfect.
use symphonia::core::formats::FormatReader;
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use std::io::{Cursor, Read, Seek, SeekFrom};
use symphonia::core::formats::FormatOptions;
use symphonia::core::formats::FormatReader;
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
use symphonia::core::probe::Hint;
use symphonia::default::get_probe;
use std::io::{Cursor, Read, Seek, SeekFrom};
/// Extracted loudness data for a track
#[derive(Debug, Clone)]
@@ -28,7 +28,9 @@ struct CursorMediaSource {
impl CursorMediaSource {
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 {
fn is_seekable(&self) -> bool { true }
fn is_seekable(&self) -> bool {
true
}
fn byte_len(&self) -> Option<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| {
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 }
})
}
@@ -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> {
let s = value_to_string(value)?;
// 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()
}
@@ -240,7 +252,9 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
if gain > max_safe_gain {
log::debug!(
"Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})",
gain, max_safe_gain, peak
gain,
max_safe_gain,
peak
);
gain = max_safe_gain;
}
@@ -258,7 +272,10 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
log::debug!(
"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
@@ -283,7 +300,10 @@ mod tests {
#[test]
fn test_calculate_gain_factor_at_reference() {
// 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);
// -3 dB → ~0.708
assert!((factor - 0.708).abs() < 0.01);
@@ -292,7 +312,10 @@ mod tests {
#[test]
fn test_calculate_gain_factor_with_target_adjustment() {
// 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);
// -3 + 4 = +1 dB → ~1.122
assert!((factor - 1.122).abs() < 0.01);
@@ -301,7 +324,10 @@ mod tests {
#[test]
fn test_clipping_prevention_with_peak() {
// 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);
// 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);
@@ -310,7 +336,10 @@ mod tests {
#[test]
fn test_clipping_prevention_without_peak() {
// 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);
assert!((factor - db_to_linear(6.0)).abs() < 0.01);
}
@@ -318,18 +347,30 @@ mod tests {
#[test]
fn test_parse_gain_value_formats() {
// 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
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
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
assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001);
}
#[test]
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);
}
}
+24 -7
View File
@@ -16,13 +16,12 @@ use std::thread;
use ebur128::{EbuR128, Mode};
use super::analyzer_tap::AnalyzerMessage;
use super::loudness_cache::LoudnessCache;
use super::loudness::db_to_linear;
use super::loudness_cache::LoudnessCache;
/// Maximum gain boost in dB (conservative clipping prevention)
const MAX_GAIN_DB: f32 = 6.0;
pub struct LoudnessAnalyzer;
impl LoudnessAnalyzer {
@@ -56,10 +55,19 @@ impl LoudnessAnalyzer {
};
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!(
"[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
@@ -74,7 +82,8 @@ impl LoudnessAnalyzer {
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
// 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.initial_done = true;
state = Some(s);
@@ -197,7 +206,11 @@ impl AnalyzerState {
// -inf means silence — don't adjust
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;
}
@@ -205,7 +218,11 @@ impl AnalyzerState {
let adjustment_db = self.target_lufs - measured_lufs;
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!(
"[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}",
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
+68 -21
View File
@@ -157,7 +157,9 @@ impl PipeWireBackend {
.ok()
.and_then(|o| {
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 {
None
}
@@ -207,7 +209,8 @@ impl PipeWireBackend {
} else if line.starts_with("Name:") {
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
} 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:") {
// Check for HARDWARE flag
current_is_hardware = line.contains("HARDWARE");
@@ -223,7 +226,10 @@ impl PipeWireBackend {
}
} else if line.starts_with("device.bus = ") {
// 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);
}
}
@@ -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() {
log::info!(" [{}] {} (id: {}, bus: {:?}, hw: {})",
idx, dev.name, dev.id, dev.device_bus, dev.is_hardware);
log::info!(
" [{}] {} (id: {}, bus: {:?}, hw: {})",
idx,
dev.name,
dev.id,
dev.device_bus,
dev.is_hardware
);
}
Ok(devices)
@@ -262,10 +277,7 @@ impl AudioBackend for PipeWireBackend {
self.enumerate_pipewire_sinks()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
let target_sink = config.device_id.clone();
// 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);
}
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()
.and_then(|o| {
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 {
None
}
@@ -319,7 +336,8 @@ impl AudioBackend for PipeWireBackend {
Some(rates) if rates.contains(&config.sample_rate) => {
log::info!(
"[PipeWire Backend] DAC supports {}Hz (available: {:?})",
config.sample_rate, rates
config.sample_rate,
rates
);
config.sample_rate
}
@@ -344,14 +362,26 @@ impl AudioBackend for PipeWireBackend {
};
// 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")
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()])
.args([
"-n",
"settings",
"0",
"clock.force-rate",
&effective_rate.to_string(),
])
.output();
match metadata_result {
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) => {
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
// ONLY buffer between the mixer and audio hardware.
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 {
// ~100ms buffer, matching old vendored cpal period size.
// Prevents underruns at high sample rates (192kHz = 19200 frames).
@@ -500,9 +530,17 @@ impl AudioBackend for PipeWireBackend {
.with_supported_config(&supported_config)
.with_buffer_size(cpal_buffer_size)
.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.
// When resuming after PipeWire dropped the stream during pause,
@@ -512,9 +550,18 @@ impl AudioBackend for PipeWireBackend {
// the graph at the correct rate.
if effective_rate != 44100 && effective_rate != 48000 {
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();
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)
+1 -4
View File
@@ -30,10 +30,7 @@ impl AudioBackend for PulseBackend {
self.inner.enumerate_devices()
}
fn create_output_stream(
&self,
config: &BackendConfig,
) -> BackendResult<MixerDeviceSink> {
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
// Delegate to PipeWire backend (same mechanism)
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))?;
match json {
Some(s) if !s.is_empty() => {
serde_json::from_str(&s).map_err(|e| {
format!("Failed to parse device sample rate limits: {}", e)
})
}
Some(s) if !s.is_empty() => serde_json::from_str(&s)
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
_ => Ok(HashMap::new()),
}
}
+1 -1
View File
@@ -16,8 +16,8 @@ mod tapped_source;
pub use ring_buffer::RingBuffer;
pub use tapped_source::TappedSource;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
/// Number of frequency bins to send to frontend
pub const NUM_BARS: usize = 16;
@@ -9,8 +9,8 @@
//! - No allocations
//! - Minimal atomic operations
use std::sync::atomic::{AtomicUsize, Ordering};
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A lock-free single-producer single-consumer ring buffer
///
@@ -4,9 +4,9 @@
//! without affecting audio playback. The tap is completely transparent to the
//! audio pipeline.
use rodio::Source;
use std::sync::Arc;
use std::time::Duration;
use rodio::Source;
use super::RingBuffer;
+2 -1
View File
@@ -146,7 +146,8 @@ impl AudioCache {
let mut state = self.state.lock().unwrap();
// 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);
if let Some(track) = state.tracks.remove(&oldest_id) {
state.current_size = state.current_size.saturating_sub(track.size_bytes);
+1 -1
View File
@@ -58,5 +58,5 @@
mod audio_cache;
mod playback_cache;
pub use audio_cache::{AudioCache, CachedTrack, CacheStats};
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
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 Ok(track_id) = id_str.parse::<u64>() {
let size = metadata.len();
let last_accessed = metadata
.accessed()
.unwrap_or_else(|_| SystemTime::now());
let last_accessed =
metadata.accessed().unwrap_or_else(|_| SystemTime::now());
state.entries.insert(
track_id,
@@ -171,7 +170,11 @@ impl PlaybackCache {
}
}
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
}
}
@@ -232,7 +235,11 @@ impl PlaybackCache {
}
}
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::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent};
@@ -59,12 +59,12 @@ impl AirPlayDiscovery {
let mdns = ServiceDaemon::new()
.map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?;
let raop_rx = mdns
.browse(SERVICE_RAOP)
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e)))?;
let airplay_rx = mdns
.browse(SERVICE_AIRPLAY)
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e)))?;
let raop_rx = mdns.browse(SERVICE_RAOP).map_err(|e| {
AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e))
})?;
let airplay_rx = mdns.browse(SERVICE_AIRPLAY).map_err(|e| {
AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e))
})?;
self.running.store(true, Ordering::SeqCst);
@@ -76,12 +76,8 @@ impl AirPlayDiscovery {
state.clone(),
running.clone(),
));
self.handles.push(spawn_receiver(
"airplay",
airplay_rx,
state,
running,
));
self.handles
.push(spawn_receiver("airplay", airplay_rx, state, running));
self.daemon = Some(mdns);
Ok(())
@@ -150,8 +146,8 @@ fn spawn_receiver(
.unwrap_or("Unknown")
.to_string();
let requires_password = parse_password_required(&info);
let ip = pick_ip(info.get_addresses())
.unwrap_or_else(|| "127.0.0.1".to_string());
let ip =
pick_ip(info.get_addresses()).unwrap_or_else(|| "127.0.0.1".to_string());
let device = DiscoveredAirPlayDevice {
id: id.clone(),
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device;
pub mod discovery;
pub use crate::AirPlayError;
pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus};
pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice};
pub use crate::AirPlayError;
+14 -7
View File
@@ -2,9 +2,11 @@
use serde::{Deserialize, Serialize};
use rust_cast::CastDevice;
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason};
use rust_cast::channels::media::{
IdleReason, Image, Media, Metadata, MusicTrackMediaMetadata, PlayerState, StreamType,
};
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
use rust_cast::CastDevice;
use crate::CastError;
@@ -90,7 +92,10 @@ impl CastDeviceConnection {
/// Disconnect from device
pub fn disconnect(&mut self) -> Result<(), CastError> {
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);
self.session = None;
@@ -151,7 +156,11 @@ impl CastDeviceConnection {
let status = self
.device
.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()))?;
if let Some(entry) = status.entries.first() {
@@ -245,9 +254,7 @@ impl CastDeviceConnection {
IdleReason::Error => "ERROR".to_string(),
});
let duration = entry.media.as_ref()
.and_then(|m| m.duration)
.unwrap_or(0.0) as f64;
let duration = entry.media.as_ref().and_then(|m| m.duration).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::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use mdns_sd::{ServiceDaemon, ServiceEvent};
+1 -1
View File
@@ -8,8 +8,8 @@ use std::thread::{self, JoinHandle};
use std::time::Duration;
use crate::chromecast::device::CastDeviceConnection;
use crate::chromecast::{CastPositionInfo, CastStatus, MediaMetadata};
use crate::CastError;
use crate::chromecast::{CastStatus, MediaMetadata, CastPositionInfo};
/// Commands sent to the Chromecast thread
pub enum CastCommand {
+76 -34
View File
@@ -1,9 +1,9 @@
//! DLNA device connection and playback via AVTransport SOAP
use serde::{Deserialize, Serialize};
use rupnp::{Device, Service};
use rupnp::http::Uri;
use rupnp::ssdp::URN;
use rupnp::{Device, Service};
use serde::{Deserialize, Serialize};
use crate::dlna::DiscoveredDlnaDevice;
use crate::DlnaError;
@@ -56,13 +56,11 @@ impl DlnaConnection {
.parse()
.map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?;
let parsed_device = Device::from_url(device_url.clone())
.await
.map_err(|e| DlnaError::Connection(format!("Failed to load device description: {}", e)))?;
let parsed_device = Device::from_url(device_url.clone()).await.map_err(|e| {
DlnaError::Connection(format!("Failed to load device description: {}", e))
})?;
let av_transport_service = parsed_device
.find_service(&av_transport_urn())
.cloned();
let av_transport_service = parsed_device.find_service(&av_transport_urn()).cloned();
let rendering_control_service = parsed_device
.find_service(&rendering_control_urn())
.cloned();
@@ -110,12 +108,19 @@ impl DlnaConnection {
}
/// 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 {
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()))?;
// Build DIDL-Lite metadata with actual content type
@@ -133,7 +138,7 @@ impl DlnaConnection {
let response = tokio::time::timeout(
std::time::Duration::from_secs(10),
av_service.action(&self.device_url, "SetAVTransportURI", &payload)
av_service.action(&self.device_url, "SetAVTransportURI", &payload),
)
.await
.map_err(|_| {
@@ -158,12 +163,18 @@ impl DlnaConnection {
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()))?;
let response = tokio::time::timeout(
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
.map_err(|_| {
@@ -187,7 +198,9 @@ impl DlnaConnection {
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()))?;
av_service
@@ -206,7 +219,9 @@ impl DlnaConnection {
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()))?;
av_service
@@ -231,7 +246,9 @@ impl DlnaConnection {
let seconds = position_secs % 60;
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()))?;
let payload = format!(
@@ -254,8 +271,9 @@ impl DlnaConnection {
return Err(DlnaError::NotConnected);
}
let rc_service = self.rendering_control_service.as_ref()
.ok_or_else(|| DlnaError::Playback("Device has no RenderingControl service".to_string()))?;
let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
DlnaError::Playback("Device has no RenderingControl service".to_string())
})?;
// DLNA volume is typically 0-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);
}
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()))?;
// Get position info
let position_response = tokio::time::timeout(
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
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
@@ -295,26 +319,33 @@ impl DlnaConnection {
// Get transport state
let transport_response = tokio::time::timeout(
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
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
// 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())
.unwrap_or("0:00:00");
let position_secs = parse_time_string(rel_time);
// 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())
.unwrap_or("0:00:00");
let duration_secs = parse_time_string(track_duration);
// 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())
.unwrap_or_else(|| "UNKNOWN".to_string());
@@ -324,7 +355,6 @@ impl DlnaConnection {
transport_state,
})
}
}
/// 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 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())
.unwrap_or(0);
@@ -345,15 +377,25 @@ fn parse_time_string(time: &str) -> u64 {
/// Build DIDL-Lite metadata for a track
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
let duration = metadata.duration_secs.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
let seconds = d % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}).unwrap_or_else(|| "00:00:00".to_string());
let duration = metadata
.duration_secs
.map(|d| {
let hours = d / 3600;
let minutes = (d % 3600) / 60;
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()
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
let artwork = metadata
.artwork_url
.as_ref()
.map(|url| {
format!(
r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#,
xml_escape(url)
)
})
.unwrap_or_default();
// Use actual content type for protocolInfo - critical for DLNA compatibility
+5 -7
View File
@@ -1,8 +1,8 @@
//! DLNA/UPnP device discovery via SSDP
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::TryStreamExt;
@@ -62,7 +62,9 @@ impl DlnaDiscovery {
let handle = tokio::spawn(async move {
while running.load(Ordering::SeqCst) {
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 {
let mut stream = std::pin::pin!(stream);
@@ -72,11 +74,7 @@ impl DlnaDiscovery {
let manufacturer = device.manufacturer().to_string();
let model = device.model_name().to_string();
let url = device.url().to_string();
let ip = device
.url()
.host()
.unwrap_or("unknown")
.to_string();
let ip = device.url().host().unwrap_or("unknown").to_string();
let mut has_av_transport = false;
let mut has_rendering_control = false;
+1 -1
View File
@@ -3,6 +3,6 @@
pub mod device;
pub mod discovery;
pub use crate::DlnaError;
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
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.
//! Supports byte-range requests for seeking.
pub mod airplay;
pub mod chromecast;
pub mod dlna;
pub mod errors;
pub mod media_server;
pub mod chromecast;
pub mod airplay;
pub mod dlna;
// Re-export error types at root
pub use errors::{CastError, AirPlayError, DlnaError};
pub use errors::{AirPlayError, CastError, DlnaError};
// Re-export media server
pub use media_server::MediaServer;
// Re-export Chromecast types
pub use chromecast::{
CastApplication,
CastCommand,
CastDeviceConnection,
CastPositionInfo,
CastStatus,
ChromecastHandle,
DeviceDiscovery,
DiscoveredDevice,
MediaMetadata,
CastApplication, CastCommand, CastDeviceConnection, CastPositionInfo, CastStatus,
ChromecastHandle, DeviceDiscovery, DiscoveredDevice, MediaMetadata,
};
// Re-export AirPlay types
pub use airplay::{
AirPlayConnection,
AirPlayDiscovery,
AirPlayMetadata,
AirPlayStatus,
DiscoveredAirPlayDevice,
AirPlayConnection, AirPlayDiscovery, AirPlayMetadata, AirPlayStatus, DiscoveredAirPlayDevice,
};
// Re-export DLNA types
pub use dlna::{
DiscoveredDlnaDevice,
DlnaConnection,
DlnaDiscovery,
DlnaMetadata,
DlnaPositionInfo,
DlnaStatus,
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
};
/// 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_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 shutdown = Arc::new(AtomicBool::new(false));
@@ -70,11 +75,19 @@ impl MediaServer {
let port_for_log = port;
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) {
match server.recv_timeout(Duration::from_millis(250)) {
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);
}
Ok(None) => {}
@@ -134,10 +147,7 @@ impl MediaServer {
)));
}
let size = path
.metadata()
.map_err(CastError::Io)?
.len();
let size = path.metadata().map_err(CastError::Io)?.len();
let content_type = content_type_for_path(path);
let entry = MediaEntry {
@@ -194,7 +204,12 @@ fn handle_request(
request: &tiny_http::Request,
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
) -> 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 {
log::warn!("MediaServer: Rejected non-GET request: {}", method);
@@ -204,14 +219,22 @@ fn handle_request(
let id = match parse_audio_id(url) {
Some(id) => id,
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));
}
};
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
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
}
None => {
@@ -345,7 +368,11 @@ fn format_base_url(ip: IpAddr, port: u16) -> 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 == "wav" => "audio/wav".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::{
Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse,
LabelPageData, PageArtistResponse, Playlist,
PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData,
PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode,
SearchResultsPage, StreamUrl, Track, UserSession,
};
use qbz_player::{Player, PlaybackState, QueueManager};
use qbz_player::{PlaybackState, Player, QueueManager};
use qbz_qobuz::QobuzClient;
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()))?;
// Extract bundle tokens (required before any API calls)
client.init().await.map_err(|e| {
CoreError::Internal(format!("Failed to extract bundle tokens: {}", e))
})?;
client
.init()
.await
.map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?;
*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 = client.as_ref().ok_or(CoreError::NotInitialized)?;
client.set_session(session.clone()).await;
self.emit(CoreEvent::LoggedIn {
session,
})
.await;
self.emit(CoreEvent::LoggedIn { session }).await;
Ok(())
}
@@ -398,23 +396,17 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
/// Pause playback
pub fn pause(&self) -> Result<(), CoreError> {
self.player
.pause()
.map_err(|e| CoreError::Playback(e))
self.player.pause().map_err(|e| CoreError::Playback(e))
}
/// Resume playback
pub fn resume(&self) -> Result<(), CoreError> {
self.player
.resume()
.map_err(|e| CoreError::Playback(e))
self.player.resume().map_err(|e| CoreError::Playback(e))
}
/// Stop playback
pub fn stop(&self) -> Result<(), CoreError> {
self.player
.stop()
.map_err(|e| CoreError::Playback(e))
self.player.stop().map_err(|e| CoreError::Playback(e))
}
/// 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 = 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
@@ -612,14 +607,14 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client
.get_genres(parent_id)
.await
.map_err(CoreError::Api)
client.get_genres(parent_id).await.map_err(CoreError::Api)
}
/// 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 = 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 = client.as_ref().ok_or(CoreError::NotInitialized)?;
client
.get_playlist_tags()
.await
.map_err(CoreError::Api)
client.get_playlist_tags().await.map_err(CoreError::Api)
}
/// Get discover albums from a specific browse endpoint
+1 -1
View File
@@ -56,7 +56,7 @@ pub mod core;
pub mod error;
// 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
pub use core::QbzCore;
+13 -14
View File
@@ -3,8 +3,11 @@
use reqwest::Client;
use serde_json::json;
use super::models::{
AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession,
LastFmSimilarArtist,
};
use crate::error::{IntegrationError, IntegrationResult};
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
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)> {
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({}))
.send()
.await?;
let response = self.client.post(&url).json(&json!({})).send().await?;
let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?;
match data {
LastFmResponse::Success(r) => {
let auth_url = r.auth_url.unwrap_or_else(|| {
format!("https://www.last.fm/api/auth/?token={}", r.token)
});
let auth_url = r
.auth_url
.unwrap_or_else(|| format!("https://www.last.fm/api/auth/?token={}", r.token));
Ok((r.token, auth_url))
}
LastFmResponse::Error { error, message } => {
Err(IntegrationError::api(error, message))
}
LastFmResponse::Error { error, message } => Err(IntegrationError::api(error, message)),
}
}
@@ -171,7 +167,10 @@ impl LastFmClient {
Ok(())
} else {
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>,
duration_ms: Option<u64>,
) -> 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
.execute(
@@ -183,7 +184,8 @@ impl ListenBrainzCache {
let listens = stmt
.query_map([limit], |row| {
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 {
id: row.get(0)?,
@@ -241,7 +243,8 @@ impl ListenBrainzCache {
.iter()
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
.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
.execute(&sql, params_refs.as_slice())
.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> {
let count: i64 = self
.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);
Ok(count as u32)
}
@@ -6,8 +6,8 @@ use reqwest::Client;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// ListenBrainz API base URL
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
@@ -113,9 +113,9 @@ impl ListenBrainzClient {
return Err(IntegrationError::AuthFailed(validation.message));
}
let user_name = validation
.user_name
.ok_or_else(|| IntegrationError::AuthFailed("Token valid but no username returned".into()))?;
let user_name = validation.user_name.ok_or_else(|| {
IntegrationError::AuthFailed("Token valid but no username returned".into())
})?;
// 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
@@ -261,7 +264,11 @@ impl ListenBrainzClient {
}
/// 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 response = self
@@ -45,6 +45,6 @@ pub mod cache;
pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{
ListenType, TrackMetadata, AdditionalInfo, Listen, SubmitListensPayload,
ListenBrainzStatus, UserInfo, QueuedListen,
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
TrackMetadata, UserInfo,
};
@@ -8,8 +8,8 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use super::models::{
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse,
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType,
ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence,
ResolvedArtist, ResolvedTrack,
};
/// TTL for recording cache (30 days)
@@ -245,7 +245,10 @@ impl MusicBrainzCache {
// ============ Artist Cache (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 min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
let result: Option<String> = self
@@ -268,7 +271,11 @@ impl MusicBrainzCache {
}
/// 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 fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
@@ -285,7 +292,10 @@ impl MusicBrainzCache {
// ============ Release Cache ============
/// 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 result: Option<String> = self
.conn
@@ -345,7 +355,11 @@ impl MusicBrainzCache {
}
/// 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 json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
@@ -399,7 +413,10 @@ impl MusicBrainzCache {
// ============ Scene Discovery Cache ============
/// 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 result: Option<String> = self
.conn
@@ -421,10 +438,14 @@ impl MusicBrainzCache {
}
/// 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 json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize scene: {}", e))?;
let json =
serde_json::to_string(data).map_err(|e| format!("Failed to serialize scene: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
@@ -621,7 +642,10 @@ impl MusicBrainzCache {
}
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)
}
@@ -650,17 +674,23 @@ impl MusicBrainzCache {
/// Get cache statistics
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))
.unwrap_or(0);
let artists: i64 = self.conn
let artists: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(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))
.unwrap_or(0);
let relations: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
let relations: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| {
row.get(0)
})
.unwrap_or(0);
Ok(CacheStats {
@@ -674,12 +704,14 @@ impl MusicBrainzCache {
/// TTL-based cleanup (V2 style)
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
let tracks_deleted = self.conn
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
let artists_deleted = self.conn
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
let tracks_deleted =
self.conn
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
let artists_deleted =
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))
}
+105 -26
View File
@@ -8,8 +8,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// Proxy URL for MusicBrainz requests
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
@@ -144,7 +144,10 @@ impl MusicBrainzClient {
}
/// 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 {
return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(),
@@ -174,7 +177,11 @@ impl MusicBrainzClient {
}
/// 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 {
return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(),
@@ -220,7 +227,11 @@ impl MusicBrainzClient {
if let Some(isrc) = isrc {
let response = self.search_recording_by_isrc(isrc).await?;
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
} else {
MatchConfidence::from_score(recording.score)
@@ -275,7 +286,11 @@ impl MusicBrainzClient {
// ============ Extended API Methods ============
/// 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.rate_limiter.wait().await;
@@ -285,7 +300,11 @@ impl MusicBrainzClient {
Self::escape_query(title),
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?;
self.check_response(&response).await;
@@ -294,7 +313,10 @@ impl MusicBrainzClient {
}
/// 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.rate_limiter.wait().await;
@@ -330,11 +352,18 @@ impl MusicBrainzClient {
.filter(|tag| tag.count.unwrap_or(0) > 0)
.collect();
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)
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.rate_limiter.wait().await;
@@ -343,7 +372,9 @@ impl MusicBrainzClient {
let query = format!("tag:\"{}\"", Self::escape_query(tag));
let url = format!(
"{}/artist?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
base,
urlencoding::encode(&query),
limit
);
let response = self.client.get(&url).send().await?;
@@ -373,7 +404,10 @@ impl MusicBrainzClient {
);
let url = format!(
"{}/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?;
@@ -382,12 +416,18 @@ impl MusicBrainzClient {
}
/// 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.rate_limiter.wait().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.handle_response_status(response).await?;
@@ -395,7 +435,11 @@ impl MusicBrainzClient {
}
/// 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
}
@@ -428,7 +472,9 @@ impl MusicBrainzClient {
let limit = limit.min(25).max(1);
let url = format!(
"{}/release?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
base,
urlencoding::encode(&query),
limit
);
let response = self.client.get(&url).send().await?;
@@ -437,7 +483,10 @@ impl MusicBrainzClient {
}
/// 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.rate_limiter.wait().await;
@@ -475,7 +524,11 @@ impl MusicBrainzClient {
}
/// 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.rate_limiter.wait().await;
@@ -490,7 +543,11 @@ impl MusicBrainzClient {
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.handle_response_status(response).await?;
@@ -498,7 +555,10 @@ impl MusicBrainzClient {
}
/// 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.rate_limiter.wait().await;
@@ -511,7 +571,10 @@ impl MusicBrainzClient {
}
/// 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 path: Vec<String> = Vec::new();
let max_hops = 5;
@@ -539,7 +602,10 @@ impl MusicBrainzClient {
}
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 {
@@ -559,7 +625,10 @@ impl MusicBrainzClient {
let next = parents
.iter()
.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(|| {
parents.iter().find(|p| {
@@ -570,8 +639,12 @@ impl MusicBrainzClient {
.or_else(|| parents.first());
match next {
Some(parent) => { current_id = parent.id.clone(); }
None => { return Ok(None); }
Some(parent) => {
current_id = parent.id.clone();
}
None => {
return Ok(None);
}
}
}
@@ -594,7 +667,10 @@ impl MusicBrainzClient {
// 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() {
return Ok(response);
}
@@ -603,7 +679,10 @@ impl MusicBrainzClient {
return Err(IntegrationError::RateLimited(60));
}
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
+54 -51
View File
@@ -991,15 +991,21 @@ impl LibraryDatabase {
}
// Detect if this file is a Qobuz purchased download
let is_purchase: bool = self.conn
let is_purchase: bool = self
.conn
.query_row(
"SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1",
params![track.file_path],
|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
.execute(
@@ -1047,7 +1053,10 @@ impl LibraryDatabase {
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
let mut stmt = self
.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()))?;
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> {
let mut stmt = self
.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()))?;
stmt.query_row(params![path], |row| Self::row_to_track(row))
@@ -1692,7 +1704,10 @@ impl LibraryDatabase {
"SELECT {} FROM local_tracks \
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
@@ -1754,8 +1769,7 @@ impl LibraryDatabase {
/// Convert a database row to LocalTrack
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
const TRACK_COLUMNS: &'static str =
"id, file_path, title, artist, album, album_artist, \
const TRACK_COLUMNS: &'static str = "id, file_path, title, artist, album, album_artist, \
track_number, disc_number, year, genre, duration_secs, format, \
bit_depth, sample_rate, channels, file_size_bytes, \
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> {
Ok(LocalTrack {
id: row.get(0)?, // id
file_path: row.get(1)?, // file_path
title: row.get(2)?, // title
artist: row.get(3)?, // artist
album: row.get(4)?, // album
album_artist: row.get(5)?, // album_artist
track_number: row.get(6)?, // track_number
disc_number: row.get(7)?, // disc_number
year: row.get(8)?, // year
genre: row.get(9)?, // genre
duration_secs: row.get(10)?, // duration_secs
id: row.get(0)?, // id
file_path: row.get(1)?, // file_path
title: row.get(2)?, // title
artist: row.get(3)?, // artist
album: row.get(4)?, // album
album_artist: row.get(5)?, // album_artist
track_number: row.get(6)?, // track_number
disc_number: row.get(7)?, // disc_number
year: row.get(8)?, // year
genre: row.get(9)?, // genre
duration_secs: row.get(10)?, // duration_secs
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
channels: row.get(14)?, // channels
file_size_bytes: row.get(15)?, // file_size_bytes
cue_file_path: row.get(16)?, // cue_file_path
cue_start_secs: row.get(17)?, // cue_start_secs
cue_end_secs: row.get(18)?, // cue_end_secs
artwork_path: row.get(19)?, // artwork_path
last_modified: row.get(20)?, // last_modified
indexed_at: row.get(21)?, // indexed_at
channels: row.get(14)?, // channels
file_size_bytes: row.get(15)?, // file_size_bytes
cue_file_path: row.get(16)?, // cue_file_path
cue_start_secs: row.get(17)?, // cue_start_secs
cue_end_secs: row.get(18)?, // cue_end_secs
artwork_path: row.get(19)?, // artwork_path
last_modified: row.get(20)?, // last_modified
indexed_at: row.get(21)?, // indexed_at
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
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
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
pub fn get_custom_album_cover(
&self,
album_id: &str,
) -> Result<Option<String>, LibraryError> {
pub fn get_custom_album_cover(&self, album_id: &str) -> Result<Option<String>, LibraryError> {
let mut stmt = self
.conn
.prepare(
"SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1",
)
.prepare("SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1")
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
let result = stmt
@@ -3674,10 +3683,7 @@ impl LibraryDatabase {
}
/// Remove a custom album cover
pub fn remove_custom_album_cover(
&self,
album_id: &str,
) -> Result<(), LibraryError> {
pub fn remove_custom_album_cover(&self, album_id: &str) -> Result<(), LibraryError> {
self.conn
.execute(
"DELETE FROM custom_album_covers WHERE album_id = ?1",
@@ -3971,14 +3977,10 @@ impl LibraryDatabase {
let mut stmt = self
.conn
.prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases")
.map_err(|e| {
LibraryError::Database(format!("Failed to prepare statement: {}", e))
})?;
.map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
let rows: Vec<(i64, i64, String)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.map_err(|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
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 {
let _ = self.conn.execute(
"DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2",
@@ -4017,9 +4022,7 @@ impl LibraryDatabase {
let mut stmt = self
.conn
.prepare("SELECT track_id, format_id FROM downloaded_purchases")
.map_err(|e| {
LibraryError::Database(format!("Failed to prepare statement: {}", e))
})?;
.map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
let rows = stmt
.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();
//! ```
mod errors;
mod models;
mod scanner;
mod thumbnails;
mod metadata;
mod cue_parser;
mod database;
mod errors;
mod metadata;
mod models;
mod scanner;
mod tag_sidecar;
mod thumbnails;
// 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 metadata::MetadataExtractor;
pub use models::*;
pub use scanner::{LibraryScanner, ScanResult};
pub use thumbnails::{
generate_thumbnail, generate_thumbnail_from_bytes,
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,
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
get_or_generate_thumbnail, get_thumbnail_path, get_thumbnails_dir, thumbnail_exists,
};
// 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::time::{SystemTime, UNIX_EPOCH};
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
/// Metadata extractor using lofty
pub struct MetadataExtractor;
@@ -108,10 +108,18 @@ impl MetadataExtractor {
// Must contain at least one disc-related token
let has_disc_token = tokens.iter().any(|token| {
*token == "disc" || *token == "disk" || *token == "cd"
|| (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())
*token == "disc"
|| *token == "disk"
|| *token == "cd"
|| (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 {
@@ -215,7 +223,10 @@ impl MetadataExtractor {
// Only when leading number is 1-2 digits (disc number) followed by dash+digits
if digit_end <= 2 && rest.starts_with('-') {
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 let Ok(n) = track_digits.parse::<u32>() {
if n > 0 && n < 10000 {
@@ -280,8 +291,18 @@ impl MetadataExtractor {
if let Some(word) = first_word {
if matches!(
word,
"flac" | "mp3" | "aac" | "alac" | "wav" | "aiff" | "ogg"
| "dsd" | "opus" | "wma" | "ape" | "pcm"
"flac"
| "mp3"
| "aac"
| "alac"
| "wav"
| "aiff"
| "ogg"
| "dsd"
| "opus"
| "wma"
| "ape"
| "pcm"
) {
return true;
}
@@ -380,7 +401,9 @@ impl MetadataExtractor {
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
// 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
let properties = tagged_file.properties();
@@ -424,10 +447,7 @@ impl MetadataExtractor {
LocalTrack {
id: 0,
file_path: file_path.to_string_lossy().to_string(),
title: tag
.title()
.map(|s| s.to_string())
.unwrap_or(filename),
title: tag.title().map(|s| s.to_string()).unwrap_or(filename),
artist: Self::normalize_field(tag.artist().as_deref())
.or_else(|| fallback_artist.clone())
.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_group_key,
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)),
disc_number: tag
.disk()
@@ -443,7 +465,9 @@ impl MetadataExtractor {
.or(inferred_disc),
year: tag.year().map(|y| y as u32),
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,
format,
bit_depth,
@@ -474,8 +498,7 @@ impl MetadataExtractor {
id: 0,
file_path: file_path.to_string_lossy().to_string(),
title: filename,
artist: fallback_artist
.unwrap_or_else(|| "Unknown Artist".to_string()),
artist: fallback_artist.unwrap_or_else(|| "Unknown Artist".to_string()),
album: album_title,
album_artist: None,
album_group_key,
@@ -546,7 +569,9 @@ impl MetadataExtractor {
/// Extract and save artwork as thumbnail to cache directory
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
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()?;
@@ -645,11 +670,8 @@ impl MetadataExtractor {
}
};
let mut score = Self::artwork_score(
&file_key,
album_key.as_deref(),
folder_key.as_deref(),
);
let mut score =
Self::artwork_score(&file_key, album_key.as_deref(), folder_key.as_deref());
if score == 0 {
score = 5;
}
@@ -692,11 +714,7 @@ impl MetadataExtractor {
matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
}
fn artwork_score(
file_key: &str,
album_key: Option<&str>,
folder_key: Option<&str>,
) -> i32 {
fn artwork_score(file_key: &str, album_key: Option<&str>, folder_key: Option<&str>) -> i32 {
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
let mut score = 0;
@@ -752,9 +770,15 @@ mod tests {
#[test]
fn test_is_encoding_folder() {
// QBZ-generated quality folder names
assert!(MetadataExtractor::is_encoding_folder("FLAC 16-bit - 44.1 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(
"FLAC 16-bit - 44.1 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"));
// Common encoding folder names from other tools
@@ -792,7 +816,8 @@ mod tests {
#[test]
fn test_album_root_dir_encoding_folder() {
// 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();
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
}
@@ -800,7 +825,9 @@ mod tests {
#[test]
fn test_album_root_dir_encoding_and_disc() {
// 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();
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
}
@@ -809,16 +836,22 @@ mod tests {
fn test_infer_track_number_from_filename() {
// Common patterns: "01 - Title"
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)
);
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)
);
// "01. Title"
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)
);
// "01_Title"
@@ -848,7 +881,9 @@ mod tests {
);
// Zero is not a valid track number
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
);
}
@@ -873,11 +908,17 @@ mod tests {
#[test]
fn test_is_disc_folder_false_album_names() {
// 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("100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1"));
assert!(!MetadataExtractor::is_disc_folder(
"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("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("The Beatles"));
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
let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac");
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 root = MetadataExtractor::album_root_dir(path).unwrap();
+4 -4
View File
@@ -57,7 +57,7 @@ pub struct LocalTrack {
pub duration_secs: u64,
pub format: AudioFormat,
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 file_size_bytes: u64,
@@ -97,7 +97,7 @@ impl Default for LocalTrack {
duration_secs: 0,
format: AudioFormat::Unknown,
bit_depth: None,
sample_rate: 44100.0, // Now f64
sample_rate: 44100.0, // Now f64
channels: 2,
file_size_bytes: 0,
cue_file_path: None,
@@ -138,7 +138,7 @@ pub struct LocalAlbum {
pub total_duration_secs: u64,
pub format: AudioFormat,
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,
/// Source of the album: "user" for local files, "qobuz_download" for offline cached
#[serde(default = "default_source")]
@@ -201,7 +201,7 @@ pub struct ScanError {
pub struct AudioProperties {
pub duration_secs: u64,
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,
}
+16 -3
View File
@@ -73,7 +73,10 @@ pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, L
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)?;
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) {
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_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());
}
+6 -4
View File
@@ -20,8 +20,9 @@ pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> {
// Create directory if it doesn't exist
if !thumbnails_dir.exists() {
fs::create_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to create thumbnails directory: {}", e)))?;
fs::create_dir_all(&thumbnails_dir).map_err(|e| {
LibraryError::Other(format!("Failed to create thumbnails directory: {}", e))
})?;
}
Ok(thumbnails_dir)
@@ -135,8 +136,9 @@ pub fn clear_thumbnails() -> Result<(), LibraryError> {
if thumbnails_dir.exists() {
fs::remove_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?;
fs::create_dir_all(&thumbnails_dir)
.map_err(|e| LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e)))?;
fs::create_dir_all(&thumbnails_dir).map_err(|e| {
LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e))
})?;
}
Ok(())
+22 -68
View File
@@ -20,9 +20,7 @@ pub enum CoreEvent {
},
/// Playback state changed (play/pause/stop)
PlaybackStateChanged {
state: PlaybackState,
},
PlaybackStateChanged { state: PlaybackState },
/// Playback position updated (periodic, e.g., every second)
PositionUpdated {
@@ -31,41 +29,27 @@ pub enum CoreEvent {
},
/// Track finished playing naturally
TrackEnded {
track_id: u64,
},
TrackEnded { track_id: u64 },
/// Volume changed
VolumeChanged {
volume: f32,
},
VolumeChanged { volume: f32 },
/// Full playback status update
PlaybackStatusUpdated {
status: PlaybackStatus,
},
PlaybackStatusUpdated { status: PlaybackStatus },
// ============ Queue Events ============
/// Queue state changed (tracks added/removed/reordered)
QueueUpdated {
state: QueueState,
},
QueueUpdated { state: QueueState },
/// Shuffle mode changed
ShuffleChanged {
enabled: bool,
},
ShuffleChanged { enabled: bool },
/// Repeat mode changed
RepeatModeChanged {
mode: crate::playback::RepeatMode,
},
RepeatModeChanged { mode: crate::playback::RepeatMode },
// ============ Authentication Events ============
/// User logged in successfully
LoggedIn {
session: UserSession,
},
LoggedIn { session: UserSession },
/// User logged out
LoggedOut,
@@ -81,41 +65,26 @@ pub enum CoreEvent {
},
/// Playlist created
PlaylistCreated {
playlist: Playlist,
},
PlaylistCreated { playlist: Playlist },
/// Playlist updated (tracks added/removed)
PlaylistUpdated {
playlist_id: u64,
},
PlaylistUpdated { playlist_id: u64 },
/// Playlist deleted
PlaylistDeleted {
playlist_id: u64,
},
PlaylistDeleted { playlist_id: u64 },
// ============ Loading/Progress Events ============
/// Loading started for an operation
LoadingStarted {
operation: String,
},
LoadingStarted { operation: String },
/// Loading completed
LoadingCompleted {
operation: String,
},
LoadingCompleted { operation: String },
/// Download progress for offline content
DownloadProgress {
track_id: u64,
progress_percent: u8,
},
DownloadProgress { track_id: u64, progress_percent: u8 },
/// Download completed
DownloadCompleted {
track_id: u64,
},
DownloadCompleted { track_id: u64 },
// ============ Error Events ============
/// An error occurred
@@ -127,21 +96,14 @@ pub enum CoreEvent {
},
/// Playback error (track couldn't play)
PlaybackError {
track_id: u64,
message: String,
},
PlaybackError { track_id: u64, message: String },
/// Network error
NetworkError {
message: String,
},
NetworkError { message: String },
// ============ Audio System Events ============
/// Audio device changed
AudioDeviceChanged {
device_name: String,
},
AudioDeviceChanged { device_name: String },
/// Audio backend changed
AudioBackendChanged {
@@ -150,9 +112,7 @@ pub enum CoreEvent {
},
/// Audio system diagnostic info
AudioDiagnostic {
message: String,
},
AudioDiagnostic { message: String },
// ============ Search Events ============
/// Search results received
@@ -163,17 +123,11 @@ pub enum CoreEvent {
// ============ Navigation Hints ============
/// Suggest navigation to album
NavigateToAlbum {
album_id: String,
},
NavigateToAlbum { album_id: String },
/// Suggest navigation to artist
NavigateToArtist {
artist_id: u64,
},
NavigateToArtist { artist_id: u64 },
/// Suggest navigation to playlist
NavigateToPlaylist {
playlist_id: u64,
},
NavigateToPlaylist { 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 traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
pub use types::{
Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo,
GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail,
LabelExploreResponse, LabelPageContainer, LabelPageData, LabelPageGenericList, Playlist,
PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality,
SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer,
UserSession,
Album,
AlbumSummary,
Artist,
ArtistAlbums,
ArtistBiography,
// Discover types
DiscoverAlbum, DiscoverAlbumDates, DiscoverAlbumImage, DiscoverArtist, DiscoverAudioInfo,
DiscoverContainer, DiscoverContainers, DiscoverData, DiscoverPlaylist, DiscoverPlaylistImage,
DiscoverPlaylistsResponse, DiscoverResponse, PlaylistTag, PlaylistTagsResponse, RawPlaylistTag,
DiscoverAlbum,
DiscoverAlbumDates,
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
PageArtistAward, PageArtistBiography, PageArtistImages, PageArtistName,
PageArtistPhysicalSupport, PageArtistPlaylist, PageArtistPlaylistImages,
PageArtistPlaylistOwner, PageArtistPlaylists, PageArtistPortrait, PageArtistRelease,
PageArtistReleaseArtist, PageArtistReleaseContributor, PageArtistReleaseGroup,
PageArtistResponse, PageArtistRights, PageArtistSimilar, PageArtistSimilarItem,
PageArtistTrack, PageArtistTrackAlbum, ReleasesGridResponse,
PageArtistAward,
PageArtistBiography,
PageArtistImages,
PageArtistName,
PageArtistPhysicalSupport,
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();
//! ```
pub mod queue;
pub mod player;
pub mod queue;
// Re-export main types
pub use queue::QueueManager;
pub use player::{
Player,
SharedState,
PlaybackState,
PlaybackEvent,
BufferedMediaSource,
BufferWriter,
StreamingConfig,
IncrementalStreamingSource,
BufferWriter, BufferedMediaSource, IncrementalStreamingSource, PlaybackEvent, PlaybackState,
Player, SharedState, StreamingConfig,
};
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
pub enum PlaybackEngine {
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
Rodio {
sink: RodioPlayer,
},
Rodio { sink: RodioPlayer },
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
AlsaDirect {
stream: Arc<AlsaDirectStream>,
@@ -115,8 +113,14 @@ impl PlaybackEngine {
thread::spawn(move || {
alsa_writer_thread(
stream_c, playing_c, stop_c, pos_c, dur_c,
queue_c, transition_c, channels,
stream_c,
playing_c,
stop_c,
pos_c,
dur_c,
queue_c,
transition_c,
channels,
);
})
};
@@ -154,8 +158,7 @@ impl PlaybackEngine {
source_transition,
..
} => {
let is_first = source_queue.is_empty()
&& !is_playing.load(Ordering::SeqCst);
let is_first = source_queue.is_empty() && !is_playing.load(Ordering::SeqCst);
// Box the source iterator and push to queue
let boxed: BoxedSampleIter = Box::new(source.into_iter());
@@ -241,7 +244,11 @@ impl PlaybackEngine {
pub fn set_volume(&self, volume: f32) {
match self {
Self::Rodio { sink } => sink.set_volume(volume),
Self::AlsaDirect { stream, hardware_volume, .. } => {
Self::AlsaDirect {
stream,
hardware_volume,
..
} => {
if *hardware_volume {
#[cfg(target_os = "linux")]
{
@@ -250,7 +257,9 @@ impl PlaybackEngine {
}
}
} 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,
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 {
match self {
Self::Rodio { .. } => false,
Self::AlsaDirect { source_transition, .. } => {
source_transition.compare_exchange(
true, false, Ordering::SeqCst, Ordering::SeqCst
).is_ok()
}
Self::AlsaDirect {
source_transition, ..
} => source_transition
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
.is_ok(),
}
}
@@ -288,7 +295,11 @@ impl PlaybackEngine {
pub fn position_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect { position_frames, stream, .. } => {
Self::AlsaDirect {
position_frames,
stream,
..
} => {
let frames = position_frames.load(Ordering::SeqCst);
let sample_rate = stream.sample_rate() as u64;
Some(frames / sample_rate)
@@ -301,7 +312,11 @@ impl PlaybackEngine {
pub fn duration_secs(&self) -> Option<u64> {
match self {
Self::Rodio { .. } => None,
Self::AlsaDirect { duration_frames, stream, .. } => {
Self::AlsaDirect {
duration_frames,
stream,
..
} => {
let frames = duration_frames.load(Ordering::SeqCst);
let sample_rate = stream.sample_rate() as u64;
Some(frames / sample_rate)
@@ -185,17 +185,17 @@ impl BufferedMediaSource {
/// Returns error if download fails before initial buffer is filled.
pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
let mut state = lock
.lock()
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
while state.data.len() < self.config.initial_buffer_bytes
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
state = cvar
.wait(state)
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
}
if let Some(ref err) = state.download_error {
@@ -297,9 +297,9 @@ impl Read for BufferedMediaSource {
use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
let mut state = lock
.lock()
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
@@ -308,9 +308,9 @@ impl Read for BufferedMediaSource {
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
state = cvar
.wait(state)
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
}
// Check for errors
@@ -327,7 +327,8 @@ impl Read for BufferedMediaSource {
let available = state.data.len() - read_pos;
let to_read = buf.len().min(available);
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)
}
@@ -338,9 +339,9 @@ impl Seek for BufferedMediaSource {
use std::sync::atomic::Ordering;
let (lock, cvar) = &*self.state;
let mut state = lock.lock().map_err(|_| {
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
})?;
let mut state = lock
.lock()
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
@@ -377,9 +378,9 @@ impl Seek for BufferedMediaSource {
&& !state.download_complete
&& state.download_error.is_none()
{
state = cvar.wait(state).map_err(|_| {
IoError::new(ErrorKind::Other, "Condition variable wait failed")
})?;
state = cvar
.wait(state)
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
}
if let Some(ref err) = state.download_error {
@@ -515,9 +516,7 @@ impl IncrementalStreamingSource {
/// The BufferedMediaSource should already have its initial buffer filled.
///
/// Returns the source along with detected sample_rate and channels.
pub fn new(
buffered_source: Arc<BufferedMediaSource>,
) -> Result<Self, String> {
pub fn new(buffered_source: Arc<BufferedMediaSource>) -> Result<Self, String> {
// Create a reader from the buffered source
let reader = buffered_source.create_reader();
let media_source = Box::new(reader) as Box<dyn MediaSource>;
@@ -548,10 +547,7 @@ impl IncrementalStreamingSource {
let sample_rate = codec_params
.sample_rate
.ok_or_else(|| "No sample rate in codec params".to_string())?;
let channels = codec_params
.channels
.map(|c| c.count() as u16)
.unwrap_or(2);
let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
let decoder = get_codecs()
.make(&codec_params, &DecoderOptions::default())
@@ -603,7 +599,9 @@ impl IncrementalStreamingSource {
while self.sample_queue.len() < min_samples {
let packet = match self.format.next_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
// This happens when playback catches up with download
std::thread::sleep(Duration::from_millis(5));
@@ -636,7 +634,8 @@ impl IncrementalStreamingSource {
sample_buf.copy_interleaved_ref(audio_buf);
// 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;
}
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,
// so they must be applied to shuffle_order positions (not absolute
// 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 to_pos = base_pos + to_index;
@@ -717,12 +720,18 @@ impl QueueManager {
/// Remove one absolute track index from shuffle order and rebase remaining indices.
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);
if pos < state.shuffle_position && state.shuffle_position > 0 {
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);
}
}
+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
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
let params = format!(
"format_id{}intentstreamtrack_id{}",
format_id, track_id
);
let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id);
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).
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 {
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
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
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
// Step 1: Get login page to find bundle URL
let login_page = client
.get(LOGIN_PAGE_URL)
.send()
.await?
.text()
.await?;
let login_page = client.get(LOGIN_PAGE_URL).send().await?.text().await?;
let bundle_url = extract_bundle_url(&login_page)?;
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
// Step 2: Fetch the bundle
let bundle_content = client
.get(&full_bundle_url)
.send()
.await?
.text()
.await?;
let bundle_content = client.get(&full_bundle_url).send().await?.text().await?;
// Step 3: Extract app_id
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)");
}
Ok(BundleTokens { app_id, secrets, private_key })
Ok(BundleTokens {
app_id,
secrets,
private_key,
})
}
fn extract_bundle_url(html: &str) -> Result<String> {
// 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>"#)
.expect("Invalid regex");
let re =
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)
.expect("Invalid regex");
re.captures(html)
.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> {
// Pattern: production:{api:{appId:"XXXXXXXXX"
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#)
.expect("Invalid regex");
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#).expect("Invalid regex");
re.captures(bundle)
.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() {
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)
@@ -149,7 +149,11 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
if let Some(seed) = seeds.get(&tz_lower) {
// Concatenate seed + info + extras, remove last 44 chars, base64 decode
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 {
let trimmed = &combined[..combined.len() - 44];
@@ -159,7 +163,10 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
) {
Ok(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);
}
}
@@ -190,8 +197,7 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
fn extract_private_key(bundle: &str) -> Option<String> {
// 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})""#)
.expect("Invalid regex");
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#).expect("Invalid regex");
re.captures(bundle)
.and_then(|caps| caps.name("key"))
+288 -119
View File
@@ -153,11 +153,14 @@ impl QobuzClient {
*self.session.write().await = Some(session.clone());
Ok(session)
}
StatusCode::UNAUTHORIZED => {
Err(ApiError::AuthenticationError("Invalid credentials".to_string()))
}
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
"Invalid credentials".to_string(),
)),
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};
let tokens = self.tokens.read().await;
let tokens = tokens.as_ref().ok_or_else(|| {
ApiError::BundleExtractionError("Client not initialized".to_string())
})?;
let tokens = tokens
.as_ref()
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
let app_id = tokens.app_id.clone();
let private_key = tokens.private_key.clone().ok_or_else(|| {
ApiError::BundleExtractionError(
"OAuth private key not available in bundle".to_string(),
)
ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string())
})?;
let _ = tokens; // drop read lock
@@ -210,7 +211,11 @@ impl QobuzClient {
.http
.get(&callback_url)
.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()
.await?;
@@ -240,9 +245,8 @@ impl QobuzClient {
);
auth_headers.insert(
"X-User-Auth-Token",
HeaderValue::from_str(&token).map_err(|_| {
ApiError::AuthenticationError("Invalid OAuth token format".into())
})?,
HeaderValue::from_str(&token)
.map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
);
let login_response = self
@@ -460,7 +464,12 @@ impl QobuzClient {
}
/// 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 http_response = self
.http
@@ -473,7 +482,11 @@ impl QobuzClient {
])
.send()
.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 artists = response
@@ -484,7 +497,12 @@ impl QobuzClient {
}
/// 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 locale = self.locale().await;
@@ -501,7 +519,11 @@ impl QobuzClient {
])
.send()
.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 tracks = response
@@ -527,12 +549,21 @@ impl QobuzClient {
log::debug!("[API] get_album({}) status={}", album_id, status);
if status == StatusCode::NOT_FOUND {
log::warn!("[API] get_album({}) returned 404 — album not found", album_id);
return Err(ApiError::ApiResponse(format!("Album {} not found (404)", album_id)));
log::warn!(
"[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() {
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?;
@@ -565,7 +596,11 @@ impl QobuzClient {
.query(&query)
.send()
.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 albums = response
@@ -579,9 +614,7 @@ impl QobuzClient {
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
let url = endpoints::build_url(paths::GENRE_LIST);
// Force English for consistent genre names across all user regions
let mut query: Vec<(&str, String)> = vec![
("lang", "en".to_string()),
];
let mut query: Vec<(&str, String)> = vec![("lang", "en".to_string())];
if let Some(pid) = parent_id {
query.push(("parent_id", pid.to_string()));
@@ -594,7 +627,11 @@ impl QobuzClient {
.query(&query)
.send()
.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 genres = response
@@ -616,7 +653,11 @@ impl QobuzClient {
// Add genre_ids as comma-separated list if provided
if let Some(gids) = genre_ids {
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));
}
}
@@ -633,7 +674,10 @@ impl QobuzClient {
// Debug: log the response structure
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") {
log::error!("Discover API error: {:?}", err);
}
@@ -658,7 +702,11 @@ impl QobuzClient {
if let Some(gids) = genre_ids {
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));
}
}
@@ -673,7 +721,11 @@ impl QobuzClient {
.query(&query)
.send()
.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?;
Ok(serde_json::from_value(response)?)
@@ -699,7 +751,11 @@ impl QobuzClient {
// Add genre_ids as comma-separated list if provided
if let Some(gids) = genre_ids {
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));
}
}
@@ -712,7 +768,11 @@ impl QobuzClient {
let off = offset.unwrap_or(0);
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
let raw_response: serde_json::Value = self
@@ -725,17 +785,28 @@ impl QobuzClient {
.json()
.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
let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone())
.map_err(|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
})?;
log::debug!("[API] get_discover_playlists response: {} playlists", response.items.len());
log::debug!(
"[API] get_discover_playlists response: {} playlists",
response.items.len()
);
Ok(response)
}
@@ -765,18 +836,22 @@ impl QobuzClient {
.filter(|tag| tag.is_discover.as_deref() == Some("true"))
.filter_map(|tag| {
// Parse name_json to get localized name
let name_map: serde_json::Value =
serde_json::from_str(&tag.name_json).ok()?;
let name_map: serde_json::Value = serde_json::from_str(&tag.name_json).ok()?;
let name = name_map
.get(lang)
.or_else(|| name_map.get("en"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())?;
let id = tag.featured_tag_id
let id = tag
.featured_tag_id
.as_ref()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
Some(PlaylistTag { id, slug: tag.slug, name })
Some(PlaylistTag {
id,
slug: tag.slug,
name,
})
})
.collect();
@@ -798,12 +873,18 @@ impl QobuzClient {
log::debug!("[API] get_track({}) status={}", track_id, status);
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));
}
if !status.is_success() {
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?;
@@ -827,19 +908,20 @@ impl QobuzClient {
.query(&query)
.send()
.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?;
Ok(serde_json::from_value(response)?)
}
/// Get artist by ID
pub async fn get_artist(
&self,
artist_id: u64,
with_albums: bool,
) -> Result<Artist> {
self.get_artist_with_pagination(artist_id, with_albums, None, None).await
pub async fn get_artist(&self, artist_id: u64, 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
@@ -870,7 +952,11 @@ impl QobuzClient {
.query(&query)
.send()
.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?;
Ok(serde_json::from_value(response)?)
@@ -884,7 +970,8 @@ impl QobuzClient {
limit: Option<u32>,
offset: Option<u32>,
) -> 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
@@ -902,10 +989,7 @@ impl QobuzClient {
Some(l) => l.to_string(),
None => self.locale().await,
};
let mut query = vec![
("artist_id", artist_id.to_string()),
("lang", locale),
];
let mut query = vec![("artist_id", artist_id.to_string()), ("lang", locale)];
if with_albums {
query.push(("extra", "albums".to_string()));
}
@@ -923,7 +1007,12 @@ impl QobuzClient {
.query(&query)
.send()
.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?;
Ok(serde_json::from_value(response)?)
@@ -944,7 +1033,11 @@ impl QobuzClient {
])
.send()
.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 result: PlaylistWithTrackIds = serde_json::from_value(response)?;
log::info!(
@@ -1025,7 +1118,11 @@ impl QobuzClient {
])
.send()
.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 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();
log::debug!(
"[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
let headers = self.api_headers().await?;
// Launch all page requests concurrently
let futures: Vec<_> = offsets.iter().map(|&offset| {
let http = &self.http;
let url = &url;
let headers = headers.clone();
let pid = playlist_id.to_string();
let limit = PAGE_SIZE.to_string();
let offset_str = offset.to_string();
async move {
let resp = http
.get(url)
.headers(headers)
.query(&[
("playlist_id", pid.as_str()),
("limit", limit.as_str()),
("offset", offset_str.as_str()),
("extra", "tracks"),
])
.send()
.await?;
let value: Value = resp.json().await?;
let page: Playlist = serde_json::from_value(value)?;
Ok::<_, anyhow::Error>((offset, page))
}
}).collect();
let futures: Vec<_> = offsets
.iter()
.map(|&offset| {
let http = &self.http;
let url = &url;
let headers = headers.clone();
let pid = playlist_id.to_string();
let limit = PAGE_SIZE.to_string();
let offset_str = offset.to_string();
async move {
let resp = http
.get(url)
.headers(headers)
.query(&[
("playlist_id", pid.as_str()),
("limit", limit.as_str()),
("offset", offset_str.as_str()),
("extra", "tracks"),
])
.send()
.await?;
let value: Value = resp.json().await?;
let page: Playlist = serde_json::from_value(value)?;
Ok::<_, anyhow::Error>((offset, page))
}
})
.collect();
let results = futures_util::future::join_all(futures).await;
@@ -1079,7 +1182,11 @@ impl QobuzClient {
match result {
Ok(page) => pages.push(page),
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
}
}
@@ -1127,22 +1234,19 @@ impl QobuzClient {
}
/// Get label explore (discover more labels)
pub async fn get_label_explore(
&self,
limit: u32,
offset: u32,
) -> Result<LabelExploreResponse> {
pub async fn get_label_explore(&self, limit: u32, offset: u32) -> Result<LabelExploreResponse> {
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
.http
.get(&url)
.headers(self.api_headers().await?)
.query(&[
("limit", limit.to_string()),
("offset", offset.to_string()),
])
.query(&[("limit", limit.to_string()), ("offset", offset.to_string())])
.send()
.await?
.json()
@@ -1169,7 +1273,11 @@ impl QobuzClient {
])
.send()
.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?;
Ok(serde_json::from_value(response)?)
@@ -1179,7 +1287,11 @@ impl QobuzClient {
/// Get stream URL for a track (requires auth + signature)
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 timestamp = get_timestamp();
log::debug!("Getting secret for signing...");
@@ -1206,7 +1318,10 @@ impl QobuzClient {
match response.status() {
StatusCode::OK => {
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
let restrictions: Vec<StreamRestriction> = json
@@ -1214,12 +1329,17 @@ impl QobuzClient {
.and_then(|v| serde_json::from_value(v.clone()).ok())
.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();
if url.is_empty() {
// Log the restriction codes for debugging
let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect();
log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes);
let restriction_codes: Vec<&str> =
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));
}
@@ -1234,7 +1354,10 @@ impl QobuzClient {
})
}
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,
preferred: Quality,
) -> 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 start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
@@ -1259,24 +1386,24 @@ impl QobuzClient {
quality.id()
);
return Ok(url);
},
}
Ok(_) => {
log::info!("Quality {:?} has restrictions, trying next", quality);
continue;
},
}
Err(ApiError::InvalidAppSecret) => {
log::error!("Invalid app secret");
return Err(ApiError::InvalidAppSecret);
},
}
Err(ApiError::TrackUnavailable(_)) => {
// Track is completely unavailable on Qobuz
track_unavailable = true;
continue;
},
}
Err(e) => {
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
continue;
},
}
}
}
@@ -1310,7 +1437,11 @@ impl QobuzClient {
])
.send()
.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?;
Ok(response)
@@ -1337,7 +1468,12 @@ impl QobuzClient {
}
/// 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 http_response = self
.http
@@ -1361,7 +1497,12 @@ impl QobuzClient {
}
/// 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 mut params = vec![
@@ -1402,7 +1543,11 @@ impl QobuzClient {
/// Add tracks to a playlist
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 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
.get(&url)
@@ -1418,9 +1563,17 @@ impl QobuzClient {
}
/// 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 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
.get(&url)
@@ -1436,7 +1589,13 @@ impl QobuzClient {
}
/// 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 mut params = vec![("playlist_id", playlist_id.to_string())];
@@ -1479,7 +1638,10 @@ impl QobuzClient {
if response.status().is_success() {
Ok(())
} 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() {
Ok(())
} 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>,
) -> Result<PageArtistResponse> {
let url = endpoints::build_url(paths::ARTIST_PAGE);
let mut query = vec![
("artist_id", artist_id.to_string()),
];
let mut query = vec![("artist_id", artist_id.to_string())];
if let Some(s) = sort {
query.push(("sort", s.to_string()));
}
@@ -1553,7 +1716,13 @@ impl QobuzClient {
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
.http
.get(&url)
+2 -2
View File
@@ -39,7 +39,7 @@ pub mod link_resolver;
pub mod performers;
// Re-export main types
pub use bundle::BundleTokens;
pub use client::QobuzClient;
pub use error::{ApiError, Result};
pub use bundle::BundleTokens;
pub use link_resolver::{resolve_link, ResolvedLink, LinkResolverError};
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
+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()))?;
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
//!
//! Qobuz provides performer credits as a formatted string like:
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
//!
//! This module parses that string into structured data.
use serde::{Deserialize, Serialize};
/// A performer with their name and roles
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Performer {
pub name: String,
pub roles: Vec<String>,
}
impl Performer {
pub fn new(name: String, roles: Vec<String>) -> Self {
Self { name, roles }
}
}
/// Parse a Qobuz performers string into structured data
///
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
///
/// # Examples
///
/// ```
/// use crate::api::performers::parse_performers;
///
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
/// assert_eq!(performers.len(), 2);
/// assert_eq!(performers[0].name, "John Coltrane");
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
/// ```
pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
if performers_str.is_empty() {
return Vec::new();
}
performers_str
.split(" - ")
.filter_map(|segment| {
let segment = segment.trim();
if segment.is_empty() {
return None;
}
let parts: Vec<&str> = segment.split(", ").collect();
if parts.is_empty() {
return None;
}
let name = parts[0].trim().to_string();
if name.is_empty() {
return None;
}
let roles: Vec<String> = parts[1..]
.iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
Some(Performer::new(name, roles))
})
.collect()
}
/// Group performers by their roles
///
/// 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>> {
let mut grouped: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for performer in performers {
for role in &performer.roles {
grouped
.entry(role.clone())
.or_default()
.push(performer.name.clone());
}
}
grouped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_performer() {
let result = parse_performers("John Coltrane, Saxophone");
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "John Coltrane");
assert_eq!(result[0].roles, vec!["Saxophone"]);
}
#[test]
fn test_parse_multiple_performers() {
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[0].roles, vec!["Saxophone", "MainArtist"]);
assert_eq!(result[1].name, "McCoy Tyner");
assert_eq!(result[1].roles, vec!["Piano"]);
assert_eq!(result[2].name, "Elvin Jones");
assert_eq!(result[2].roles, vec!["Drums"]);
}
#[test]
fn test_parse_empty_string() {
let result = parse_performers("");
assert!(result.is_empty());
}
#[test]
fn test_parse_performer_no_roles() {
let result = parse_performers("John Coltrane");
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "John Coltrane");
assert!(result[0].roles.is_empty());
}
#[test]
fn test_group_by_role() {
let performers = vec![
Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
Performer::new("Jane".to_string(), vec!["Saxophone".to_string(), "Vocals".to_string()]),
];
let grouped = group_by_role(&performers);
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2);
assert_eq!(grouped.get("Vocals").unwrap().len(), 1);
}
}
//! Performers string parsing utilities
//!
//! Qobuz provides performer credits as a formatted string like:
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
//!
//! This module parses that string into structured data.
use serde::{Deserialize, Serialize};
/// A performer with their name and roles
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Performer {
pub name: String,
pub roles: Vec<String>,
}
impl Performer {
pub fn new(name: String, roles: Vec<String>) -> Self {
Self { name, roles }
}
}
/// Parse a Qobuz performers string into structured data
///
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
///
/// # Examples
///
/// ```
/// use crate::api::performers::parse_performers;
///
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
/// assert_eq!(performers.len(), 2);
/// assert_eq!(performers[0].name, "John Coltrane");
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
/// ```
pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
if performers_str.is_empty() {
return Vec::new();
}
performers_str
.split(" - ")
.filter_map(|segment| {
let segment = segment.trim();
if segment.is_empty() {
return None;
}
let parts: Vec<&str> = segment.split(", ").collect();
if parts.is_empty() {
return None;
}
let name = parts[0].trim().to_string();
if name.is_empty() {
return None;
}
let roles: Vec<String> = parts[1..]
.iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
Some(Performer::new(name, roles))
})
.collect()
}
/// Group performers by their roles
///
/// 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>> {
let mut grouped: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for performer in performers {
for role in &performer.roles {
grouped
.entry(role.clone())
.or_default()
.push(performer.name.clone());
}
}
grouped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_performer() {
let result = parse_performers("John Coltrane, Saxophone");
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "John Coltrane");
assert_eq!(result[0].roles, vec!["Saxophone"]);
}
#[test]
fn test_parse_multiple_performers() {
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[0].roles, vec!["Saxophone", "MainArtist"]);
assert_eq!(result[1].name, "McCoy Tyner");
assert_eq!(result[1].roles, vec!["Piano"]);
assert_eq!(result[2].name, "Elvin Jones");
assert_eq!(result[2].roles, vec!["Drums"]);
}
#[test]
fn test_parse_empty_string() {
let result = parse_performers("");
assert!(result.is_empty());
}
#[test]
fn test_parse_performer_no_roles() {
let result = parse_performers("John Coltrane");
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "John Coltrane");
assert!(result[0].roles.is_empty());
}
#[test]
fn test_group_by_role() {
let performers = vec![
Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
Performer::new(
"Jane".to_string(),
vec!["Saxophone".to_string(), "Vocals".to_string()],
),
];
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]
fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() {
let raw_message = vec![
0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d,
0x09, 0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01,
0x20, 0xa8, 0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c,
0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d, 0x09,
0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01, 0x20, 0xa8,
0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c,
];
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::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER",
Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER",
Self::SrvrCtrlActiveRendererChanged => {
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED"
}
Self::SrvrCtrlActiveRendererChanged => "MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED",
Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED",
Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET",
Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED",
@@ -619,7 +619,10 @@ fn handle_incoming_binary(
match decode_renderer_server_commands(&inner_payload) {
Ok(commands) => {
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 {
emit(