chore(qconnect): checkpoint current poc state
cx
This commit is contained in:
92 files changed
+3886
-2433
No files matched your search
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
+1306
-957
File diff suppressed because it is too large.
Load diff
@@ -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)) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", ¶ms, 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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user