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.
|
//! Uses CPAL's ALSA host with specific device selection.
|
||||||
//! Device enumeration reads directly from /proc/asound (no alsa-utils dependency).
|
//! Device enumeration reads directly from /proc/asound (no alsa-utils dependency).
|
||||||
|
|
||||||
use super::backend::{AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult};
|
use super::backend::{
|
||||||
|
AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig, BackendResult,
|
||||||
|
};
|
||||||
use rodio::{
|
use rodio::{
|
||||||
cpal::{
|
cpal::{
|
||||||
traits::{DeviceTrait, HostTrait},
|
traits::{DeviceTrait, HostTrait},
|
||||||
@@ -225,7 +227,11 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
|
|||||||
if !device_num.is_empty() {
|
if !device_num.is_empty() {
|
||||||
devices.push(ProcPcmInfo {
|
devices.push(ProcPcmInfo {
|
||||||
device_num,
|
device_num,
|
||||||
name: if pcm_name.is_empty() { "Unknown".to_string() } else { pcm_name },
|
name: if pcm_name.is_empty() {
|
||||||
|
"Unknown".to_string()
|
||||||
|
} else {
|
||||||
|
pcm_name
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +241,9 @@ fn read_card_pcm_devices(card_num: &str) -> Vec<ProcPcmInfo> {
|
|||||||
|
|
||||||
// Sort by device number
|
// Sort by device number
|
||||||
devices.sort_by(|a, b| {
|
devices.sort_by(|a, b| {
|
||||||
a.device_num.parse::<u32>().unwrap_or(0)
|
a.device_num
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(0)
|
||||||
.cmp(&b.device_num.parse::<u32>().unwrap_or(0))
|
.cmp(&b.device_num.parse::<u32>().unwrap_or(0))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,7 +265,8 @@ fn build_card_info_map() -> HashMap<String, (String, String)> {
|
|||||||
/// Find card number by short name (e.g., "C20" -> "0")
|
/// Find card number by short name (e.g., "C20" -> "0")
|
||||||
fn find_card_number_by_name(short_name: &str) -> Option<String> {
|
fn find_card_number_by_name(short_name: &str) -> Option<String> {
|
||||||
let cards = read_proc_asound_cards();
|
let cards = read_proc_asound_cards();
|
||||||
cards.iter()
|
cards
|
||||||
|
.iter()
|
||||||
.find(|c| c.short_name == short_name)
|
.find(|c| c.short_name == short_name)
|
||||||
.map(|c| c.number.clone())
|
.map(|c| c.number.clone())
|
||||||
}
|
}
|
||||||
@@ -271,10 +280,15 @@ fn extract_card_name_from_device(device_id: &str) -> Option<String> {
|
|||||||
Some(after_card.split(',').next()?.to_string())
|
Some(after_card.split(',').next()?.to_string())
|
||||||
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
|
} else if device_id.starts_with("hw:") || device_id.starts_with("plughw:") {
|
||||||
// hw:0,0 -> card number 0 -> look up short name
|
// hw:0,0 -> card number 0 -> look up short name
|
||||||
let prefix = if device_id.starts_with("hw:") { "hw:" } else { "plughw:" };
|
let prefix = if device_id.starts_with("hw:") {
|
||||||
|
"hw:"
|
||||||
|
} else {
|
||||||
|
"plughw:"
|
||||||
|
};
|
||||||
let card_num = device_id.strip_prefix(prefix)?.split(',').next()?;
|
let card_num = device_id.strip_prefix(prefix)?.split(',').next()?;
|
||||||
let cards = read_proc_asound_cards();
|
let cards = read_proc_asound_cards();
|
||||||
cards.iter()
|
cards
|
||||||
|
.iter()
|
||||||
.find(|c| c.number == card_num)
|
.find(|c| c.number == card_num)
|
||||||
.map(|c| c.short_name.clone())
|
.map(|c| c.short_name.clone())
|
||||||
} else {
|
} else {
|
||||||
@@ -337,7 +351,10 @@ impl AlsaBackend {
|
|||||||
|
|
||||||
// Check if ALSA is available
|
// Check if ALSA is available
|
||||||
// cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!)
|
// cpal 0.17 changed HostId::name() from "ALSA" to "Alsa" (uses stringify!)
|
||||||
if !available_hosts.iter().any(|h| h.name().eq_ignore_ascii_case("alsa")) {
|
if !available_hosts
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.name().eq_ignore_ascii_case("alsa"))
|
||||||
|
{
|
||||||
return Err("ALSA host not available on this system".to_string());
|
return Err("ALSA host not available on this system".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,8 +363,9 @@ impl AlsaBackend {
|
|||||||
available_hosts
|
available_hosts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|h| h.name().eq_ignore_ascii_case("alsa"))
|
.find(|h| h.name().eq_ignore_ascii_case("alsa"))
|
||||||
.ok_or("ALSA host not found".to_string())?
|
.ok_or("ALSA host not found".to_string())?,
|
||||||
).map_err(|e| format!("Failed to create ALSA host: {}", e))?;
|
)
|
||||||
|
.map_err(|e| format!("Failed to create ALSA host: {}", e))?;
|
||||||
|
|
||||||
log::info!("[ALSA Backend] Initialized successfully");
|
log::info!("[ALSA Backend] Initialized successfully");
|
||||||
|
|
||||||
@@ -373,24 +391,32 @@ impl AlsaBackend {
|
|||||||
for card in &cards {
|
for card in &cards {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[ALSA Backend] Card {}: {} = {} ({} PCM devices)",
|
"[ALSA Backend] Card {}: {} = {} ({} PCM devices)",
|
||||||
card.number, card.short_name, card.long_name, card.pcm_playback_devices.len()
|
card.number,
|
||||||
|
card.short_name,
|
||||||
|
card.long_name,
|
||||||
|
card.pcm_playback_devices.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build CPAL device map for sample rate enrichment (OPTIONAL - may be incomplete)
|
// Build CPAL device map for sample rate enrichment (OPTIONAL - may be incomplete)
|
||||||
let cpal_devices = self.build_cpal_device_map();
|
let cpal_devices = self.build_cpal_device_map();
|
||||||
log::debug!("[ALSA Backend] CPAL found {} devices for enrichment", cpal_devices.len());
|
log::debug!(
|
||||||
|
"[ALSA Backend] CPAL found {} devices for enrichment",
|
||||||
|
cpal_devices.len()
|
||||||
|
);
|
||||||
|
|
||||||
// Add system default device
|
// Add system default device
|
||||||
let default_sample_rates = cpal_devices.get("default")
|
let default_sample_rates = cpal_devices
|
||||||
|
.get("default")
|
||||||
.and_then(|d| get_supported_sample_rates(d));
|
.and_then(|d| get_supported_sample_rates(d));
|
||||||
let default_max_rate = default_sample_rates.as_ref()
|
let default_max_rate = default_sample_rates
|
||||||
|
.as_ref()
|
||||||
.and_then(|rates| rates.iter().max().copied());
|
.and_then(|rates| rates.iter().max().copied());
|
||||||
|
|
||||||
devices.push(AudioDevice {
|
devices.push(AudioDevice {
|
||||||
id: "default".to_string(),
|
id: "default".to_string(),
|
||||||
name: "default".to_string(),
|
name: "default".to_string(),
|
||||||
description: None, // Frontend shows "System Default"
|
description: None, // Frontend shows "System Default"
|
||||||
is_default: true,
|
is_default: true,
|
||||||
max_sample_rate: default_max_rate.or(Some(384000)),
|
max_sample_rate: default_max_rate.or(Some(384000)),
|
||||||
supported_sample_rates: default_sample_rates,
|
supported_sample_rates: default_sample_rates,
|
||||||
@@ -402,7 +428,8 @@ impl AlsaBackend {
|
|||||||
for card in &cards {
|
for card in &cards {
|
||||||
// Add sysdefault:CARD=name (card default with software mixing)
|
// Add sysdefault:CARD=name (card default with software mixing)
|
||||||
let sysdefault_id = format!("sysdefault:CARD={}", card.short_name);
|
let sysdefault_id = format!("sysdefault:CARD={}", card.short_name);
|
||||||
let sysdefault_rates = cpal_devices.get(&sysdefault_id)
|
let sysdefault_rates = cpal_devices
|
||||||
|
.get(&sysdefault_id)
|
||||||
.and_then(|d| get_supported_sample_rates(d));
|
.and_then(|d| get_supported_sample_rates(d));
|
||||||
|
|
||||||
devices.push(AudioDevice {
|
devices.push(AudioDevice {
|
||||||
@@ -410,12 +437,13 @@ impl AlsaBackend {
|
|||||||
name: sysdefault_id.clone(),
|
name: sysdefault_id.clone(),
|
||||||
description: Some(format!("{}, {}", card.long_name, sysdefault_id)),
|
description: Some(format!("{}, {}", card.long_name, sysdefault_id)),
|
||||||
is_default: false,
|
is_default: false,
|
||||||
max_sample_rate: sysdefault_rates.as_ref()
|
max_sample_rate: sysdefault_rates
|
||||||
|
.as_ref()
|
||||||
.and_then(|r| r.iter().max().copied())
|
.and_then(|r| r.iter().max().copied())
|
||||||
.or(Some(192000)),
|
.or(Some(192000)),
|
||||||
supported_sample_rates: sysdefault_rates,
|
supported_sample_rates: sysdefault_rates,
|
||||||
device_bus: None,
|
device_bus: None,
|
||||||
is_hardware: false, // sysdefault uses dmix
|
is_hardware: false, // sysdefault uses dmix
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add PCM-specific devices (front:, iec958:, hdmi:)
|
// Add PCM-specific devices (front:, iec958:, hdmi:)
|
||||||
@@ -424,14 +452,18 @@ impl AlsaBackend {
|
|||||||
let device_prefix = if pcm.name.to_lowercase().contains("hdmi") {
|
let device_prefix = if pcm.name.to_lowercase().contains("hdmi") {
|
||||||
"hdmi"
|
"hdmi"
|
||||||
} else if pcm.name.to_lowercase().contains("iec958")
|
} else if pcm.name.to_lowercase().contains("iec958")
|
||||||
|| pcm.name.to_lowercase().contains("spdif")
|
|| pcm.name.to_lowercase().contains("spdif")
|
||||||
|| pcm.name.to_lowercase().contains("s/pdif") {
|
|| pcm.name.to_lowercase().contains("s/pdif")
|
||||||
|
{
|
||||||
"iec958"
|
"iec958"
|
||||||
} else {
|
} else {
|
||||||
"front" // Default to front: for analog/USB audio
|
"front" // Default to front: for analog/USB audio
|
||||||
};
|
};
|
||||||
|
|
||||||
let device_id = format!("{}:CARD={},DEV={}", device_prefix, card.short_name, pcm.device_num);
|
let device_id = format!(
|
||||||
|
"{}:CARD={},DEV={}",
|
||||||
|
device_prefix, card.short_name, pcm.device_num
|
||||||
|
);
|
||||||
|
|
||||||
// Skip if already added (shouldn't happen, but be safe)
|
// Skip if already added (shouldn't happen, but be safe)
|
||||||
if devices.iter().any(|d| d.id == device_id) {
|
if devices.iter().any(|d| d.id == device_id) {
|
||||||
@@ -439,11 +471,13 @@ impl AlsaBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to get sample rates from CPAL (may fail if device is busy)
|
// Try to get sample rates from CPAL (may fail if device is busy)
|
||||||
let sample_rates = cpal_devices.get(&device_id)
|
let sample_rates = cpal_devices
|
||||||
|
.get(&device_id)
|
||||||
.and_then(|d| get_supported_sample_rates(d));
|
.and_then(|d| get_supported_sample_rates(d));
|
||||||
let max_rate = sample_rates.as_ref()
|
let max_rate = sample_rates
|
||||||
|
.as_ref()
|
||||||
.and_then(|r| r.iter().max().copied())
|
.and_then(|r| r.iter().max().copied())
|
||||||
.or(Some(384000)); // Assume high capability if CPAL unavailable
|
.or(Some(384000)); // Assume high capability if CPAL unavailable
|
||||||
|
|
||||||
devices.push(AudioDevice {
|
devices.push(AudioDevice {
|
||||||
id: device_id.clone(),
|
id: device_id.clone(),
|
||||||
@@ -504,7 +538,10 @@ impl AlsaBackend {
|
|||||||
|
|
||||||
// Only use direct ALSA for hw:/plughw:/front: devices
|
// Only use direct ALSA for hw:/plughw:/front: devices
|
||||||
if !super::AlsaDirectStream::is_hw_device(device_id) {
|
if !super::AlsaDirectStream::is_hw_device(device_id) {
|
||||||
log::info!("[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL", device_id);
|
log::info!(
|
||||||
|
"[ALSA Backend] Device '{}' is not hw:/plughw:/front:, using CPAL",
|
||||||
|
device_id
|
||||||
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +575,9 @@ impl AlsaBackend {
|
|||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})",
|
"[ALSA Backend] Hardware confirms support for {}Hz (card '{}', rates: {:?})",
|
||||||
config.sample_rate, card_name, hw_rates
|
config.sample_rate,
|
||||||
|
card_name,
|
||||||
|
hw_rates
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,23 +622,38 @@ impl AlsaBackend {
|
|||||||
log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release...");
|
log::info!("[ALSA Backend] PipeWire sink suspended, waiting for device release...");
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
|
||||||
match super::AlsaDirectStream::new(&hw_device, config.sample_rate, config.channels) {
|
match super::AlsaDirectStream::new(
|
||||||
|
&hw_device,
|
||||||
|
config.sample_rate,
|
||||||
|
config.channels,
|
||||||
|
) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
log::info!("[ALSA Backend] ✓ Direct hw stream created after PipeWire suspend");
|
log::info!("[ALSA Backend] ✓ Direct hw stream created after PipeWire suspend");
|
||||||
return Some(Ok((stream, super::backend::BitPerfectMode::DirectHardware)));
|
return Some(Ok((
|
||||||
|
stream,
|
||||||
|
super::backend::BitPerfectMode::DirectHardware,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
log::warn!("[ALSA Backend] Retry after suspend also failed: {}", e2);
|
log::warn!(
|
||||||
|
"[ALSA Backend] Retry after suspend also failed: {}",
|
||||||
|
e2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
log::warn!("[ALSA Backend] Failed to suspend PipeWire sink: {}", stderr);
|
log::warn!(
|
||||||
|
"[ALSA Backend] Failed to suspend PipeWire sink: {}",
|
||||||
|
stderr
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If retry failed or suspend failed, fall through to error
|
// If retry failed or suspend failed, fall through to error
|
||||||
log::error!("[ALSA Backend] Cannot acquire device even after PipeWire suspend");
|
log::error!(
|
||||||
|
"[ALSA Backend] Cannot acquire device even after PipeWire suspend"
|
||||||
|
);
|
||||||
return Some(Err(format!(
|
return Some(Err(format!(
|
||||||
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
|
"ALSA Direct failed: {}. Device may be in use or inaccessible.",
|
||||||
error
|
error
|
||||||
@@ -629,7 +683,9 @@ impl AlsaBackend {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[ALSA Backend] Format unsupported on hw, trying plughw fallback...");
|
log::info!(
|
||||||
|
"[ALSA Backend] Format unsupported on hw, trying plughw fallback..."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,7 +700,9 @@ impl AlsaBackend {
|
|||||||
|
|
||||||
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
|
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
log::info!("[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)");
|
log::info!(
|
||||||
|
"[ALSA Backend] ✓ plughw stream created (bit-perfect with format conversion)"
|
||||||
|
);
|
||||||
Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
|
Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -657,16 +715,28 @@ impl AlsaBackend {
|
|||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
log::info!("[ALSA Backend] PipeWire sink suspended, retrying plughw...");
|
log::info!(
|
||||||
|
"[ALSA Backend] PipeWire sink suspended, retrying plughw..."
|
||||||
|
);
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
|
||||||
match super::AlsaDirectStream::new(&plughw_device, config.sample_rate, config.channels) {
|
match super::AlsaDirectStream::new(
|
||||||
|
&plughw_device,
|
||||||
|
config.sample_rate,
|
||||||
|
config.channels,
|
||||||
|
) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
log::info!("[ALSA Backend] ✓ plughw stream created after PipeWire suspend");
|
log::info!("[ALSA Backend] ✓ plughw stream created after PipeWire suspend");
|
||||||
return Some(Ok((stream, super::backend::BitPerfectMode::PluginFallback)));
|
return Some(Ok((
|
||||||
|
stream,
|
||||||
|
super::backend::BitPerfectMode::PluginFallback,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
log::error!("[ALSA Backend] plughw retry after suspend also failed: {}", e2);
|
log::error!(
|
||||||
|
"[ALSA Backend] plughw retry after suspend also failed: {}",
|
||||||
|
e2
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,7 +828,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
|
|||||||
let stripped = device_id.strip_prefix("front:CARD=")?;
|
let stripped = device_id.strip_prefix("front:CARD=")?;
|
||||||
let parts: Vec<&str> = stripped.split(',').collect();
|
let parts: Vec<&str> = stripped.split(',').collect();
|
||||||
let card_name = parts.first()?;
|
let card_name = parts.first()?;
|
||||||
let dev_part = parts.get(1).and_then(|s| s.strip_prefix("DEV=")).unwrap_or("0");
|
let dev_part = parts
|
||||||
|
.get(1)
|
||||||
|
.and_then(|s| s.strip_prefix("DEV="))
|
||||||
|
.unwrap_or("0");
|
||||||
|
|
||||||
// Find current card number for this name using /proc/asound
|
// Find current card number for this name using /proc/asound
|
||||||
if let Some(card_num) = find_card_number_by_name(card_name) {
|
if let Some(card_num) = find_card_number_by_name(card_name) {
|
||||||
@@ -767,7 +840,10 @@ pub fn resolve_stable_to_current_hw(device_id: &str) -> Option<String> {
|
|||||||
return Some(hw_id);
|
return Some(hw_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::warn!("[ALSA] Card '{}' not found in current enumeration", card_name);
|
log::warn!(
|
||||||
|
"[ALSA] Card '{}' not found in current enumeration",
|
||||||
|
card_name
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,10 +872,7 @@ impl AudioBackend for AlsaBackend {
|
|||||||
self.enumerate_with_proc_descriptions()
|
self.enumerate_with_proc_descriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_output_stream(
|
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
|
||||||
&self,
|
|
||||||
config: &BackendConfig,
|
|
||||||
) -> BackendResult<MixerDeviceSink> {
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
|
"[ALSA Backend] Creating stream: {}Hz, {} channels, exclusive: {}, plugin: {:?}",
|
||||||
config.sample_rate,
|
config.sample_rate,
|
||||||
@@ -898,7 +971,9 @@ impl AudioBackend for AlsaBackend {
|
|||||||
// In exclusive mode, PipeWire may have re-acquired the device after the
|
// In exclusive mode, PipeWire may have re-acquired the device after the
|
||||||
// previous ALSA Direct stream released it. Suspend PipeWire before opening.
|
// previous ALSA Direct stream released it. Suspend PipeWire before opening.
|
||||||
if config.exclusive_mode {
|
if config.exclusive_mode {
|
||||||
log::info!("[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream");
|
log::info!(
|
||||||
|
"[ALSA Backend] Exclusive mode: suspending PipeWire sinks before CPAL stream"
|
||||||
|
);
|
||||||
if let Ok(output) = std::process::Command::new("pactl")
|
if let Ok(output) = std::process::Command::new("pactl")
|
||||||
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
|
.args(["suspend-sink", "@DEFAULT_SINK@", "1"])
|
||||||
.output()
|
.output()
|
||||||
@@ -941,12 +1016,15 @@ impl AudioBackend for AlsaBackend {
|
|||||||
if effective_rate != config.sample_rate {
|
if effective_rate != config.sample_rate {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})",
|
"[ALSA Backend] Output stream created at {}Hz (resampled from {}Hz, exclusive: {})",
|
||||||
effective_rate, config.sample_rate, config.exclusive_mode
|
effective_rate,
|
||||||
|
config.sample_rate,
|
||||||
|
config.exclusive_mode
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
|
"[ALSA Backend] Output stream created successfully at {}Hz (exclusive: {})",
|
||||||
config.sample_rate, config.exclusive_mode
|
config.sample_rate,
|
||||||
|
config.exclusive_mode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ impl AlsaDirectStream {
|
|||||||
|
|
||||||
// Set hardware parameters and auto-detect best format
|
// Set hardware parameters and auto-detect best format
|
||||||
let selected_format = {
|
let selected_format = {
|
||||||
let hwp = HwParams::any(&pcm)
|
let hwp =
|
||||||
.map_err(|e| format!("Failed to get hardware params: {}", e))?;
|
HwParams::any(&pcm).map_err(|e| format!("Failed to get hardware params: {}", e))?;
|
||||||
|
|
||||||
// Set access type (interleaved)
|
// Set access type (interleaved)
|
||||||
hwp.set_access(Access::RWInterleaved)
|
hwp.set_access(Access::RWInterleaved)
|
||||||
@@ -49,10 +49,10 @@ impl AlsaDirectStream {
|
|||||||
// S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip)
|
// S24_3LE first: required by SMSL-class USB DACs (TAS1020B chip)
|
||||||
// Then descending bit-depth for quality
|
// Then descending bit-depth for quality
|
||||||
let format_priority = [
|
let format_priority = [
|
||||||
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
|
(Format::S243LE, "S24_3LE"), // 24-bit packed (SMSL, Topping, Fosi DACs)
|
||||||
(Format::S32LE, "S32LE"), // 32-bit
|
(Format::S32LE, "S32LE"), // 32-bit
|
||||||
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
|
(Format::S24LE, "S24LE"), // 24-bit in 32-bit container
|
||||||
(Format::S16LE, "S16LE"), // 16-bit
|
(Format::S16LE, "S16LE"), // 16-bit
|
||||||
(Format::FloatLE, "Float32LE"), // Float (compatibility)
|
(Format::FloatLE, "Float32LE"), // Float (compatibility)
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -65,8 +65,10 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let format = selected_format
|
let format = selected_format.ok_or_else(|| {
|
||||||
.ok_or_else(|| "No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)".to_string())?;
|
"No supported audio format found (tried S24_3LE, S32LE, S24LE, S16LE, FloatLE)"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
// Set channels
|
// Set channels
|
||||||
hwp.set_channels(channels as u32)
|
hwp.set_channels(channels as u32)
|
||||||
@@ -99,8 +101,13 @@ impl AlsaDirectStream {
|
|||||||
pcm.hw_params(&hwp)
|
pcm.hw_params(&hwp)
|
||||||
.map_err(|e| format!("Failed to apply hardware params: {}", e))?;
|
.map_err(|e| format!("Failed to apply hardware params: {}", e))?;
|
||||||
|
|
||||||
log::info!("[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
|
log::info!(
|
||||||
sample_rate, channels, buffer_size, format);
|
"[ALSA Direct] Hardware configured: {}Hz, {}ch, buffer: {} frames, format: {:?}",
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
buffer_size,
|
||||||
|
format
|
||||||
|
);
|
||||||
|
|
||||||
format
|
format
|
||||||
};
|
};
|
||||||
@@ -128,18 +135,21 @@ impl AlsaDirectStream {
|
|||||||
match self.format {
|
match self.format {
|
||||||
Format::FloatLE => {
|
Format::FloatLE => {
|
||||||
// Convert i16 to f32
|
// Convert i16 to f32
|
||||||
let samples_f32: Vec<f32> = samples_i16
|
let samples_f32: Vec<f32> =
|
||||||
.iter()
|
samples_i16.iter().map(|&s| s as f32 / 32768.0).collect();
|
||||||
.map(|&s| s as f32 / 32768.0)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let io = pcm.io_f32()
|
let io = pcm
|
||||||
|
.io_f32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_f32) {
|
match io.writei(&samples_f32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -155,18 +165,20 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
Format::S32LE => {
|
Format::S32LE => {
|
||||||
// Convert i16 to i32 (bit-perfect: shift left 16 bits)
|
// Convert i16 to i32 (bit-perfect: shift left 16 bits)
|
||||||
let samples_i32: Vec<i32> = samples_i16
|
let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
|
||||||
.iter()
|
|
||||||
.map(|&s| (s as i32) << 16)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let io = pcm.io_i32()
|
let io = pcm
|
||||||
|
.io_i32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_i32) {
|
match io.writei(&samples_i32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -182,13 +194,18 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
Format::S16LE => {
|
Format::S16LE => {
|
||||||
// Direct write (no conversion needed)
|
// Direct write (no conversion needed)
|
||||||
let io = pcm.io_i16()
|
let io = pcm
|
||||||
|
.io_i16()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(samples_i16) {
|
match io.writei(samples_i16) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -212,8 +229,8 @@ impl AlsaDirectStream {
|
|||||||
// Convert i16 to i24 (lossless: zeros in lower 8 bits)
|
// Convert i16 to i24 (lossless: zeros in lower 8 bits)
|
||||||
let s24 = (sample as i32) << 8;
|
let s24 = (sample as i32) << 8;
|
||||||
// Pack as 3 bytes in little-endian order
|
// Pack as 3 bytes in little-endian order
|
||||||
bytes.push((s24 & 0xFF) as u8); // LSB
|
bytes.push((s24 & 0xFF) as u8); // LSB
|
||||||
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
|
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
|
||||||
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
|
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +240,11 @@ impl AlsaDirectStream {
|
|||||||
match io.writei(&bytes) {
|
match io.writei(&bytes) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -240,18 +261,20 @@ impl AlsaDirectStream {
|
|||||||
Format::S24LE => {
|
Format::S24LE => {
|
||||||
// S24LE: 24-bit in 32-bit container (padded)
|
// S24LE: 24-bit in 32-bit container (padded)
|
||||||
// Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source)
|
// Convert i16 → i32, shift left 16 bits (same as S32LE for i16 source)
|
||||||
let samples_i32: Vec<i32> = samples_i16
|
let samples_i32: Vec<i32> = samples_i16.iter().map(|&s| (s as i32) << 16).collect();
|
||||||
.iter()
|
|
||||||
.map(|&s| (s as i32) << 16)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let io = pcm.io_i32()
|
let io = pcm
|
||||||
|
.io_i32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_i32) {
|
match io.writei(&samples_i32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -265,9 +288,7 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => Err(format!("Unsupported format: {:?}", self.format)),
|
||||||
Err(format!("Unsupported format: {:?}", self.format))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,13 +304,18 @@ impl AlsaDirectStream {
|
|||||||
match self.format {
|
match self.format {
|
||||||
Format::FloatLE => {
|
Format::FloatLE => {
|
||||||
// Direct write - no conversion needed
|
// Direct write - no conversion needed
|
||||||
let io = pcm.io_f32()
|
let io = pcm
|
||||||
|
.io_f32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(samples_f32) {
|
match io.writei(samples_f32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -310,13 +336,18 @@ impl AlsaDirectStream {
|
|||||||
.map(|&s| (s * 2_147_483_647.0) as i32)
|
.map(|&s| (s * 2_147_483_647.0) as i32)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let io = pcm.io_i32()
|
let io = pcm
|
||||||
|
.io_i32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_i32) {
|
match io.writei(&samples_i32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -341,13 +372,18 @@ impl AlsaDirectStream {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let io = pcm.io_i32()
|
let io = pcm
|
||||||
|
.io_i32()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_i32) {
|
match io.writei(&samples_i32) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -370,8 +406,8 @@ impl AlsaDirectStream {
|
|||||||
let scaled = sample * 8_388_607.0;
|
let scaled = sample * 8_388_607.0;
|
||||||
let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
let s24 = scaled.clamp(-8_388_608.0, 8_388_607.0) as i32;
|
||||||
// Pack as 3 bytes in little-endian order
|
// Pack as 3 bytes in little-endian order
|
||||||
bytes.push((s24 & 0xFF) as u8); // LSB
|
bytes.push((s24 & 0xFF) as u8); // LSB
|
||||||
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
|
bytes.push(((s24 >> 8) & 0xFF) as u8); // Middle
|
||||||
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
|
bytes.push(((s24 >> 16) & 0xFF) as u8); // MSB (sign-extended)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +416,11 @@ impl AlsaDirectStream {
|
|||||||
match io.writei(&bytes) {
|
match io.writei(&bytes) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames (S24_3LE)", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames (S24_3LE)",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -396,18 +436,21 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
Format::S16LE => {
|
Format::S16LE => {
|
||||||
// f32 -> i16
|
// f32 -> i16
|
||||||
let samples_i16: Vec<i16> = samples_f32
|
let samples_i16: Vec<i16> =
|
||||||
.iter()
|
samples_f32.iter().map(|&s| (s * 32_767.0) as i16).collect();
|
||||||
.map(|&s| (s * 32_767.0) as i16)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let io = pcm.io_i16()
|
let io = pcm
|
||||||
|
.io_i16()
|
||||||
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
.map_err(|e| format!("Failed to get PCM I/O: {}", e))?;
|
||||||
|
|
||||||
match io.writei(&samples_i16) {
|
match io.writei(&samples_i16) {
|
||||||
Ok(written) => {
|
Ok(written) => {
|
||||||
if written != frames {
|
if written != frames {
|
||||||
log::warn!("[ALSA Direct] Partial write: {} / {} frames", written, frames);
|
log::warn!(
|
||||||
|
"[ALSA Direct] Partial write: {} / {} frames",
|
||||||
|
written,
|
||||||
|
frames
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -421,9 +464,7 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => Err(format!("Unsupported format: {:?}", self.format)),
|
||||||
Err(format!("Unsupported format: {:?}", self.format))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,8 +512,8 @@ impl AlsaDirectStream {
|
|||||||
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
|
/// NOTE: Failure doesn't break playback, just means volume can't be controlled.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
|
pub fn set_hardware_volume(&self, volume: f32) -> Result<(), String> {
|
||||||
use alsa::mixer::{Mixer, SelemId};
|
|
||||||
use alsa::mixer::SelemChannelId::*;
|
use alsa::mixer::SelemChannelId::*;
|
||||||
|
use alsa::mixer::{Mixer, SelemId};
|
||||||
|
|
||||||
// Open mixer for device
|
// Open mixer for device
|
||||||
let mixer = Mixer::new(&self.device_id, false)
|
let mixer = Mixer::new(&self.device_id, false)
|
||||||
@@ -491,8 +532,13 @@ impl AlsaDirectStream {
|
|||||||
let (min, max) = selem.get_playback_volume_range();
|
let (min, max) = selem.get_playback_volume_range();
|
||||||
let target = min + ((max - min) as f32 * volume) as i64;
|
let target = min + ((max - min) as f32 * volume) as i64;
|
||||||
|
|
||||||
log::info!("[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
|
log::info!(
|
||||||
name, volume * 100.0, target, max);
|
"[ALSA Direct] Setting hardware volume via '{}': {:.0}% (raw: {}/{})",
|
||||||
|
name,
|
||||||
|
volume * 100.0,
|
||||||
|
target,
|
||||||
|
max
|
||||||
|
);
|
||||||
|
|
||||||
// Set volume on all channels
|
// Set volume on all channels
|
||||||
for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] {
|
for channel in &[FrontLeft, FrontRight, FrontCenter, RearLeft, RearRight] {
|
||||||
@@ -504,7 +550,10 @@ impl AlsaDirectStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!("No volume control found for {}. DAC may not support hardware mixer.", self.device_id))
|
Err(format!(
|
||||||
|
"No volume control found for {}. DAC may not support hardware mixer.",
|
||||||
|
self.device_id
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if device is a bit-perfect hardware device
|
/// Check if device is a bit-perfect hardware device
|
||||||
|
|||||||
@@ -217,10 +217,7 @@ pub trait AudioBackend: Send + Sync {
|
|||||||
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>;
|
fn enumerate_devices(&self) -> BackendResult<Vec<AudioDevice>>;
|
||||||
|
|
||||||
/// Create an output stream for the given configuration
|
/// Create an output stream for the given configuration
|
||||||
fn create_output_stream(
|
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink>;
|
||||||
&self,
|
|
||||||
config: &BackendConfig,
|
|
||||||
) -> BackendResult<MixerDeviceSink>;
|
|
||||||
|
|
||||||
/// Check if this backend is available on the current system
|
/// Check if this backend is available on the current system
|
||||||
fn is_available(&self) -> bool;
|
fn is_available(&self) -> bool;
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ impl AudioDiagnostic {
|
|||||||
let sample_rate = self.sample_rate.load(Ordering::SeqCst);
|
let sample_rate = self.sample_rate.load(Ordering::SeqCst);
|
||||||
let channels = self.channels.load(Ordering::SeqCst);
|
let channels = self.channels.load(Ordering::SeqCst);
|
||||||
|
|
||||||
let trailing_zeros = if or_mask == 0 { 32 } else { or_mask.trailing_zeros() };
|
let trailing_zeros = if or_mask == 0 {
|
||||||
|
32
|
||||||
|
} else {
|
||||||
|
or_mask.trailing_zeros()
|
||||||
|
};
|
||||||
let effective_bits = 32 - trailing_zeros;
|
let effective_bits = 32 - trailing_zeros;
|
||||||
|
|
||||||
let frames = if channels > 0 {
|
let frames = if channels > 0 {
|
||||||
|
|||||||
+23
-27
@@ -26,41 +26,37 @@
|
|||||||
//! └───────────────┘
|
//! └───────────────┘
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod backend;
|
|
||||||
pub mod pipewire_backend;
|
|
||||||
pub mod alsa_backend;
|
pub mod alsa_backend;
|
||||||
pub mod pulse_backend;
|
|
||||||
pub mod alsa_direct;
|
pub mod alsa_direct;
|
||||||
pub mod diagnostic;
|
pub mod analysis;
|
||||||
pub mod loudness;
|
|
||||||
pub mod dynamic_amplify;
|
|
||||||
pub mod analyzer_tap;
|
pub mod analyzer_tap;
|
||||||
pub mod loudness_cache;
|
pub mod backend;
|
||||||
|
pub mod diagnostic;
|
||||||
|
pub mod dynamic_amplify;
|
||||||
|
pub mod loudness;
|
||||||
pub mod loudness_analyzer;
|
pub mod loudness_analyzer;
|
||||||
|
pub mod loudness_cache;
|
||||||
|
pub mod pipewire_backend;
|
||||||
|
pub mod pulse_backend;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod visualizer;
|
pub mod visualizer;
|
||||||
pub mod analysis;
|
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use backend::{
|
pub use alsa_backend::{
|
||||||
AlsaPlugin,
|
device_supports_sample_rate, get_device_supported_rates, normalize_device_id_to_stable,
|
||||||
AudioBackend,
|
resolve_stable_to_current_hw,
|
||||||
AudioBackendType,
|
|
||||||
AudioDevice,
|
|
||||||
BackendConfig,
|
|
||||||
BackendManager,
|
|
||||||
BackendResult,
|
|
||||||
AlsaDirectError,
|
|
||||||
BitPerfectMode,
|
|
||||||
};
|
};
|
||||||
pub use alsa_direct::AlsaDirectStream;
|
pub use alsa_direct::AlsaDirectStream;
|
||||||
pub use alsa_backend::{normalize_device_id_to_stable, resolve_stable_to_current_hw, device_supports_sample_rate, get_device_supported_rates};
|
|
||||||
pub use diagnostic::{AudioDiagnostic, DiagnosticSource, BitDepthResult};
|
|
||||||
pub use loudness::{ReplayGainData, extract_replaygain, calculate_gain_factor, db_to_linear};
|
|
||||||
pub use dynamic_amplify::DynamicAmplify;
|
|
||||||
pub use analyzer_tap::{AnalyzerTap, AnalyzerMessage};
|
|
||||||
pub use loudness_cache::LoudnessCache;
|
|
||||||
pub use loudness_analyzer::LoudnessAnalyzer;
|
|
||||||
pub use settings::AudioSettings;
|
|
||||||
pub use visualizer::{VisualizerTap, TappedSource, RingBuffer};
|
|
||||||
pub use analysis::SpectralAnalyzer;
|
pub use analysis::SpectralAnalyzer;
|
||||||
|
pub use analyzer_tap::{AnalyzerMessage, AnalyzerTap};
|
||||||
|
pub use backend::{
|
||||||
|
AlsaDirectError, AlsaPlugin, AudioBackend, AudioBackendType, AudioDevice, BackendConfig,
|
||||||
|
BackendManager, BackendResult, BitPerfectMode,
|
||||||
|
};
|
||||||
|
pub use diagnostic::{AudioDiagnostic, BitDepthResult, DiagnosticSource};
|
||||||
|
pub use dynamic_amplify::DynamicAmplify;
|
||||||
|
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
|
||||||
|
pub use loudness_analyzer::LoudnessAnalyzer;
|
||||||
|
pub use loudness_cache::LoudnessCache;
|
||||||
|
pub use settings::AudioSettings;
|
||||||
|
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
//! for volume normalization. When normalization is disabled, this module is
|
//! for volume normalization. When normalization is disabled, this module is
|
||||||
//! not invoked and the audio pipeline remains bit-perfect.
|
//! not invoked and the audio pipeline remains bit-perfect.
|
||||||
|
|
||||||
use symphonia::core::formats::FormatReader;
|
use std::io::{Cursor, Read, Seek, SeekFrom};
|
||||||
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
|
|
||||||
use symphonia::core::formats::FormatOptions;
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::formats::FormatReader;
|
||||||
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
||||||
|
use symphonia::core::meta::{MetadataOptions, StandardTagKey, Tag, Value};
|
||||||
use symphonia::core::probe::Hint;
|
use symphonia::core::probe::Hint;
|
||||||
use symphonia::default::get_probe;
|
use symphonia::default::get_probe;
|
||||||
use std::io::{Cursor, Read, Seek, SeekFrom};
|
|
||||||
|
|
||||||
/// Extracted loudness data for a track
|
/// Extracted loudness data for a track
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -28,7 +28,9 @@ struct CursorMediaSource {
|
|||||||
|
|
||||||
impl CursorMediaSource {
|
impl CursorMediaSource {
|
||||||
fn new(data: Vec<u8>) -> Self {
|
fn new(data: Vec<u8>) -> Self {
|
||||||
Self { inner: Cursor::new(data) }
|
Self {
|
||||||
|
inner: Cursor::new(data),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +47,9 @@ impl Seek for CursorMediaSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MediaSource for CursorMediaSource {
|
impl MediaSource for CursorMediaSource {
|
||||||
fn is_seekable(&self) -> bool { true }
|
fn is_seekable(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
fn byte_len(&self) -> Option<u64> {
|
fn byte_len(&self) -> Option<u64> {
|
||||||
Some(self.inner.get_ref().len() as u64)
|
Some(self.inner.get_ref().len() as u64)
|
||||||
}
|
}
|
||||||
@@ -122,7 +126,11 @@ pub fn extract_replaygain_from_reader(format: &mut dyn FormatReader) -> Option<R
|
|||||||
}
|
}
|
||||||
|
|
||||||
gain_db.map(|db| {
|
gain_db.map(|db| {
|
||||||
log::info!("Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}", db, peak);
|
log::info!(
|
||||||
|
"Loudness: found ReplayGain (streaming): {:.2} dB, peak: {:?}",
|
||||||
|
db,
|
||||||
|
peak
|
||||||
|
);
|
||||||
ReplayGainData { gain_db: db, peak }
|
ReplayGainData { gain_db: db, peak }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -174,7 +182,11 @@ fn extract_from_tags(tags: &[Tag], gain_db: &mut Option<f32>, peak: &mut Option<
|
|||||||
fn parse_gain_value(value: &Value) -> Option<f32> {
|
fn parse_gain_value(value: &Value) -> Option<f32> {
|
||||||
let s = value_to_string(value)?;
|
let s = value_to_string(value)?;
|
||||||
// Strip " dB" suffix if present, then parse
|
// Strip " dB" suffix if present, then parse
|
||||||
let trimmed = s.trim().trim_end_matches(" dB").trim_end_matches(" db").trim_end_matches("dB");
|
let trimmed = s
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(" dB")
|
||||||
|
.trim_end_matches(" db")
|
||||||
|
.trim_end_matches("dB");
|
||||||
trimmed.parse::<f32>().ok()
|
trimmed.parse::<f32>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,9 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
|
|||||||
if gain > max_safe_gain {
|
if gain > max_safe_gain {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})",
|
"Loudness: capping gain from {:.3} to {:.3} (peak: {:.4})",
|
||||||
gain, max_safe_gain, peak
|
gain,
|
||||||
|
max_safe_gain,
|
||||||
|
peak
|
||||||
);
|
);
|
||||||
gain = max_safe_gain;
|
gain = max_safe_gain;
|
||||||
}
|
}
|
||||||
@@ -258,7 +272,10 @@ pub fn calculate_gain_factor(rg: &ReplayGainData, target_lufs: f32) -> f32 {
|
|||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Loudness: gain_db={:.2}, target={:.1} LUFS, adjusted={:.2} dB, factor={:.4}",
|
"Loudness: gain_db={:.2}, target={:.1} LUFS, adjusted={:.2} dB, factor={:.4}",
|
||||||
rg.gain_db, target_lufs, adjusted_gain_db, gain
|
rg.gain_db,
|
||||||
|
target_lufs,
|
||||||
|
adjusted_gain_db,
|
||||||
|
gain
|
||||||
);
|
);
|
||||||
|
|
||||||
gain
|
gain
|
||||||
@@ -283,7 +300,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_calculate_gain_factor_at_reference() {
|
fn test_calculate_gain_factor_at_reference() {
|
||||||
// At -18 LUFS target (ReplayGain reference), gain_db should pass through directly
|
// At -18 LUFS target (ReplayGain reference), gain_db should pass through directly
|
||||||
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.9) };
|
let rg = ReplayGainData {
|
||||||
|
gain_db: -3.0,
|
||||||
|
peak: Some(0.9),
|
||||||
|
};
|
||||||
let factor = calculate_gain_factor(&rg, -18.0);
|
let factor = calculate_gain_factor(&rg, -18.0);
|
||||||
// -3 dB → ~0.708
|
// -3 dB → ~0.708
|
||||||
assert!((factor - 0.708).abs() < 0.01);
|
assert!((factor - 0.708).abs() < 0.01);
|
||||||
@@ -292,7 +312,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_calculate_gain_factor_with_target_adjustment() {
|
fn test_calculate_gain_factor_with_target_adjustment() {
|
||||||
// At -14 LUFS target, we add +4 dB to the RG gain
|
// At -14 LUFS target, we add +4 dB to the RG gain
|
||||||
let rg = ReplayGainData { gain_db: -3.0, peak: Some(0.5) };
|
let rg = ReplayGainData {
|
||||||
|
gain_db: -3.0,
|
||||||
|
peak: Some(0.5),
|
||||||
|
};
|
||||||
let factor = calculate_gain_factor(&rg, -14.0);
|
let factor = calculate_gain_factor(&rg, -14.0);
|
||||||
// -3 + 4 = +1 dB → ~1.122
|
// -3 + 4 = +1 dB → ~1.122
|
||||||
assert!((factor - 1.122).abs() < 0.01);
|
assert!((factor - 1.122).abs() < 0.01);
|
||||||
@@ -301,7 +324,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_clipping_prevention_with_peak() {
|
fn test_clipping_prevention_with_peak() {
|
||||||
// High positive gain but peak close to 1.0 — should be capped
|
// High positive gain but peak close to 1.0 — should be capped
|
||||||
let rg = ReplayGainData { gain_db: 10.0, peak: Some(0.95) };
|
let rg = ReplayGainData {
|
||||||
|
gain_db: 10.0,
|
||||||
|
peak: Some(0.95),
|
||||||
|
};
|
||||||
let factor = calculate_gain_factor(&rg, -18.0);
|
let factor = calculate_gain_factor(&rg, -18.0);
|
||||||
// max_safe_gain = 1/0.95 ≈ 1.053, which is less than db_to_linear(10) ≈ 3.162
|
// max_safe_gain = 1/0.95 ≈ 1.053, which is less than db_to_linear(10) ≈ 3.162
|
||||||
assert!((factor - (1.0 / 0.95)).abs() < 0.01);
|
assert!((factor - (1.0 / 0.95)).abs() < 0.01);
|
||||||
@@ -310,7 +336,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_clipping_prevention_without_peak() {
|
fn test_clipping_prevention_without_peak() {
|
||||||
// High gain without peak data — capped at +6 dB
|
// High gain without peak data — capped at +6 dB
|
||||||
let rg = ReplayGainData { gain_db: 12.0, peak: None };
|
let rg = ReplayGainData {
|
||||||
|
gain_db: 12.0,
|
||||||
|
peak: None,
|
||||||
|
};
|
||||||
let factor = calculate_gain_factor(&rg, -18.0);
|
let factor = calculate_gain_factor(&rg, -18.0);
|
||||||
assert!((factor - db_to_linear(6.0)).abs() < 0.01);
|
assert!((factor - db_to_linear(6.0)).abs() < 0.01);
|
||||||
}
|
}
|
||||||
@@ -318,18 +347,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_parse_gain_value_formats() {
|
fn test_parse_gain_value_formats() {
|
||||||
// Standard format: "-6.54 dB"
|
// Standard format: "-6.54 dB"
|
||||||
assert!((parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs() < 0.001);
|
assert!(
|
||||||
|
(parse_gain_value(&Value::String("-6.54 dB".to_string())).unwrap() - (-6.54)).abs()
|
||||||
|
< 0.001
|
||||||
|
);
|
||||||
// Without dB suffix
|
// Without dB suffix
|
||||||
assert!((parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs() < 0.001);
|
assert!(
|
||||||
|
(parse_gain_value(&Value::String("-6.54".to_string())).unwrap() - (-6.54)).abs()
|
||||||
|
< 0.001
|
||||||
|
);
|
||||||
// Positive
|
// Positive
|
||||||
assert!((parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs() < 0.001);
|
assert!(
|
||||||
|
(parse_gain_value(&Value::String("+3.21 dB".to_string())).unwrap() - 3.21).abs()
|
||||||
|
< 0.001
|
||||||
|
);
|
||||||
// Float value
|
// Float value
|
||||||
assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001);
|
assert!((parse_gain_value(&Value::Float(-6.54)).unwrap() - (-6.54)).abs() < 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_peak_value() {
|
fn test_parse_peak_value() {
|
||||||
assert!((parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs() < 0.0001);
|
assert!(
|
||||||
|
(parse_peak_value(&Value::String("0.988553".to_string())).unwrap() - 0.988553).abs()
|
||||||
|
< 0.0001
|
||||||
|
);
|
||||||
assert!((parse_peak_value(&Value::Float(0.95)).unwrap() - 0.95).abs() < 0.001);
|
assert!((parse_peak_value(&Value::Float(0.95)).unwrap() - 0.95).abs() < 0.001);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,12 @@ use std::thread;
|
|||||||
use ebur128::{EbuR128, Mode};
|
use ebur128::{EbuR128, Mode};
|
||||||
|
|
||||||
use super::analyzer_tap::AnalyzerMessage;
|
use super::analyzer_tap::AnalyzerMessage;
|
||||||
use super::loudness_cache::LoudnessCache;
|
|
||||||
use super::loudness::db_to_linear;
|
use super::loudness::db_to_linear;
|
||||||
|
use super::loudness_cache::LoudnessCache;
|
||||||
|
|
||||||
/// Maximum gain boost in dB (conservative clipping prevention)
|
/// Maximum gain boost in dB (conservative clipping prevention)
|
||||||
const MAX_GAIN_DB: f32 = 6.0;
|
const MAX_GAIN_DB: f32 = 6.0;
|
||||||
|
|
||||||
|
|
||||||
pub struct LoudnessAnalyzer;
|
pub struct LoudnessAnalyzer;
|
||||||
|
|
||||||
impl LoudnessAnalyzer {
|
impl LoudnessAnalyzer {
|
||||||
@@ -56,10 +55,19 @@ impl LoudnessAnalyzer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
AnalyzerMessage::NewTrack { track_id, sample_rate, channels, target_lufs, gain_atomic } => {
|
AnalyzerMessage::NewTrack {
|
||||||
|
track_id,
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
target_lufs,
|
||||||
|
gain_atomic,
|
||||||
|
} => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
|
"[LoudnessAnalyzer] New track {} ({}Hz, {}ch, target {:.1} LUFS)",
|
||||||
track_id, sample_rate, channels, target_lufs
|
track_id,
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
target_lufs
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
@@ -74,7 +82,8 @@ impl LoudnessAnalyzer {
|
|||||||
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
gain_atomic.store(gain.to_bits(), Ordering::Relaxed);
|
||||||
|
|
||||||
// Create state marked as cached — still accept samples for refinement
|
// Create state marked as cached — still accept samples for refinement
|
||||||
let mut s = AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
let mut s =
|
||||||
|
AnalyzerState::new(track_id, sample_rate, channels, target_lufs);
|
||||||
s.gain_atomic = Some(gain_atomic);
|
s.gain_atomic = Some(gain_atomic);
|
||||||
s.initial_done = true;
|
s.initial_done = true;
|
||||||
state = Some(s);
|
state = Some(s);
|
||||||
@@ -197,7 +206,11 @@ impl AnalyzerState {
|
|||||||
|
|
||||||
// -inf means silence — don't adjust
|
// -inf means silence — don't adjust
|
||||||
if loudness.is_infinite() || loudness.is_nan() {
|
if loudness.is_infinite() || loudness.is_nan() {
|
||||||
log::debug!("[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping", self.track_id, loudness);
|
log::debug!(
|
||||||
|
"[LoudnessAnalyzer] Track {}: loudness is {:?}, skipping",
|
||||||
|
self.track_id,
|
||||||
|
loudness
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +218,11 @@ impl AnalyzerState {
|
|||||||
let adjustment_db = self.target_lufs - measured_lufs;
|
let adjustment_db = self.target_lufs - measured_lufs;
|
||||||
let gain = compute_gain_capped(adjustment_db);
|
let gain = compute_gain_capped(adjustment_db);
|
||||||
|
|
||||||
let phase = if self.initial_done { "refine" } else { "initial" };
|
let phase = if self.initial_done {
|
||||||
|
"refine"
|
||||||
|
} else {
|
||||||
|
"initial"
|
||||||
|
};
|
||||||
log::info!(
|
log::info!(
|
||||||
"[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}",
|
"[LoudnessAnalyzer] Track {} ({}): measured {:.1} LUFS, target {:.1}, adjustment {:.2} dB, gain {:.4}",
|
||||||
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
|
self.track_id, phase, measured_lufs, self.target_lufs, adjustment_db, gain
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ impl PipeWireBackend {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|o| {
|
.and_then(|o| {
|
||||||
if o.status.success() {
|
if o.status.success() {
|
||||||
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string())
|
String::from_utf8(o.stdout)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -207,7 +209,8 @@ impl PipeWireBackend {
|
|||||||
} else if line.starts_with("Name:") {
|
} else if line.starts_with("Name:") {
|
||||||
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
|
current_name = Some(line.trim_start_matches("Name:").trim().to_string());
|
||||||
} else if line.starts_with("Description:") {
|
} else if line.starts_with("Description:") {
|
||||||
current_description = Some(line.trim_start_matches("Description:").trim().to_string());
|
current_description =
|
||||||
|
Some(line.trim_start_matches("Description:").trim().to_string());
|
||||||
} else if line.starts_with("Flags:") {
|
} else if line.starts_with("Flags:") {
|
||||||
// Check for HARDWARE flag
|
// Check for HARDWARE flag
|
||||||
current_is_hardware = line.contains("HARDWARE");
|
current_is_hardware = line.contains("HARDWARE");
|
||||||
@@ -223,7 +226,10 @@ impl PipeWireBackend {
|
|||||||
}
|
}
|
||||||
} else if line.starts_with("device.bus = ") {
|
} else if line.starts_with("device.bus = ") {
|
||||||
// Parse device.bus property (e.g., "usb", "pci", "bluetooth")
|
// Parse device.bus property (e.g., "usb", "pci", "bluetooth")
|
||||||
let bus = line.trim_start_matches("device.bus = ").trim_matches('"').to_string();
|
let bus = line
|
||||||
|
.trim_start_matches("device.bus = ")
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
current_device_bus = Some(bus);
|
current_device_bus = Some(bus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,10 +249,19 @@ impl PipeWireBackend {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("[PipeWire Backend] Enumerated {} devices via pactl", devices.len());
|
log::info!(
|
||||||
|
"[PipeWire Backend] Enumerated {} devices via pactl",
|
||||||
|
devices.len()
|
||||||
|
);
|
||||||
for (idx, dev) in devices.iter().enumerate() {
|
for (idx, dev) in devices.iter().enumerate() {
|
||||||
log::info!(" [{}] {} (id: {}, bus: {:?}, hw: {})",
|
log::info!(
|
||||||
idx, dev.name, dev.id, dev.device_bus, dev.is_hardware);
|
" [{}] {} (id: {}, bus: {:?}, hw: {})",
|
||||||
|
idx,
|
||||||
|
dev.name,
|
||||||
|
dev.id,
|
||||||
|
dev.device_bus,
|
||||||
|
dev.is_hardware
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(devices)
|
Ok(devices)
|
||||||
@@ -262,10 +277,7 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
self.enumerate_pipewire_sinks()
|
self.enumerate_pipewire_sinks()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_output_stream(
|
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
|
||||||
&self,
|
|
||||||
config: &BackendConfig,
|
|
||||||
) -> BackendResult<MixerDeviceSink> {
|
|
||||||
let target_sink = config.device_id.clone();
|
let target_sink = config.device_id.clone();
|
||||||
|
|
||||||
// Temporarily set default sink to target (if specified)
|
// Temporarily set default sink to target (if specified)
|
||||||
@@ -287,7 +299,10 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr);
|
log::warn!("[PipeWire Backend] Failed to set default sink: {}", stderr);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[PipeWire Backend] Error executing pactl set-default-sink: {}", e);
|
log::warn!(
|
||||||
|
"[PipeWire Backend] Error executing pactl set-default-sink: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +322,9 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|o| {
|
.and_then(|o| {
|
||||||
if o.status.success() {
|
if o.status.success() {
|
||||||
String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string())
|
String::from_utf8(o.stdout)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -319,7 +336,8 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
Some(rates) if rates.contains(&config.sample_rate) => {
|
Some(rates) if rates.contains(&config.sample_rate) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PipeWire Backend] DAC supports {}Hz (available: {:?})",
|
"[PipeWire Backend] DAC supports {}Hz (available: {:?})",
|
||||||
config.sample_rate, rates
|
config.sample_rate,
|
||||||
|
rates
|
||||||
);
|
);
|
||||||
config.sample_rate
|
config.sample_rate
|
||||||
}
|
}
|
||||||
@@ -344,14 +362,26 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Force PipeWire to use the effective sample rate (for bit-perfect playback)
|
// Force PipeWire to use the effective sample rate (for bit-perfect playback)
|
||||||
log::info!("[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata", effective_rate);
|
log::info!(
|
||||||
|
"[PipeWire Backend] Forcing sample rate to {}Hz via pw-metadata",
|
||||||
|
effective_rate
|
||||||
|
);
|
||||||
let metadata_result = Command::new("pw-metadata")
|
let metadata_result = Command::new("pw-metadata")
|
||||||
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()])
|
.args([
|
||||||
|
"-n",
|
||||||
|
"settings",
|
||||||
|
"0",
|
||||||
|
"clock.force-rate",
|
||||||
|
&effective_rate.to_string(),
|
||||||
|
])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
match metadata_result {
|
match metadata_result {
|
||||||
Ok(output) if output.status.success() => {
|
Ok(output) if output.status.success() => {
|
||||||
log::info!("[PipeWire Backend] Sample rate forced to {}Hz", effective_rate);
|
log::info!(
|
||||||
|
"[PipeWire Backend] Sample rate forced to {}Hz",
|
||||||
|
effective_rate
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
@@ -486,7 +516,7 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
||||||
// ONLY buffer between the mixer and audio hardware.
|
// ONLY buffer between the mixer and audio hardware.
|
||||||
let cpal_buffer_size = if config.exclusive_mode {
|
let cpal_buffer_size = if config.exclusive_mode {
|
||||||
BufferSize::Fixed(512) // Low latency for exclusive mode
|
BufferSize::Fixed(512) // Low latency for exclusive mode
|
||||||
} else {
|
} else {
|
||||||
// ~100ms buffer, matching old vendored cpal period size.
|
// ~100ms buffer, matching old vendored cpal period size.
|
||||||
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
||||||
@@ -500,9 +530,17 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
.with_supported_config(&supported_config)
|
.with_supported_config(&supported_config)
|
||||||
.with_buffer_size(cpal_buffer_size)
|
.with_buffer_size(cpal_buffer_size)
|
||||||
.open_stream()
|
.open_stream()
|
||||||
.map_err(|e| format!("Failed to create output stream at {}Hz: {}", effective_rate, e))?;
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to create output stream at {}Hz: {}",
|
||||||
|
effective_rate, e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
log::info!("[PipeWire Backend] Output stream created successfully at {}Hz", effective_rate);
|
log::info!(
|
||||||
|
"[PipeWire Backend] Output stream created successfully at {}Hz",
|
||||||
|
effective_rate
|
||||||
|
);
|
||||||
|
|
||||||
// Re-apply clock.force-rate AFTER stream creation.
|
// Re-apply clock.force-rate AFTER stream creation.
|
||||||
// When resuming after PipeWire dropped the stream during pause,
|
// When resuming after PipeWire dropped the stream during pause,
|
||||||
@@ -512,9 +550,18 @@ impl AudioBackend for PipeWireBackend {
|
|||||||
// the graph at the correct rate.
|
// the graph at the correct rate.
|
||||||
if effective_rate != 44100 && effective_rate != 48000 {
|
if effective_rate != 44100 && effective_rate != 48000 {
|
||||||
let _ = Command::new("pw-metadata")
|
let _ = Command::new("pw-metadata")
|
||||||
.args(["-n", "settings", "0", "clock.force-rate", &effective_rate.to_string()])
|
.args([
|
||||||
|
"-n",
|
||||||
|
"settings",
|
||||||
|
"0",
|
||||||
|
"clock.force-rate",
|
||||||
|
&effective_rate.to_string(),
|
||||||
|
])
|
||||||
.output();
|
.output();
|
||||||
log::info!("[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation", effective_rate);
|
log::info!(
|
||||||
|
"[PipeWire Backend] Re-applied clock.force-rate={}Hz after stream creation",
|
||||||
|
effective_rate
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(mixer_sink)
|
Ok(mixer_sink)
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ impl AudioBackend for PulseBackend {
|
|||||||
self.inner.enumerate_devices()
|
self.inner.enumerate_devices()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_output_stream(
|
fn create_output_stream(&self, config: &BackendConfig) -> BackendResult<MixerDeviceSink> {
|
||||||
&self,
|
|
||||||
config: &BackendConfig,
|
|
||||||
) -> BackendResult<MixerDeviceSink> {
|
|
||||||
// Delegate to PipeWire backend (same mechanism)
|
// Delegate to PipeWire backend (same mechanism)
|
||||||
self.inner.create_output_stream(config)
|
self.inner.create_output_stream(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,11 +402,8 @@ impl AudioSettingsStore {
|
|||||||
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
|
.map_err(|e| format!("Failed to get device sample rate limits: {}", e))?;
|
||||||
|
|
||||||
match json {
|
match json {
|
||||||
Some(s) if !s.is_empty() => {
|
Some(s) if !s.is_empty() => serde_json::from_str(&s)
|
||||||
serde_json::from_str(&s).map_err(|e| {
|
.map_err(|e| format!("Failed to parse device sample rate limits: {}", e)),
|
||||||
format!("Failed to parse device sample rate limits: {}", e)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => Ok(HashMap::new()),
|
_ => Ok(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ mod tapped_source;
|
|||||||
pub use ring_buffer::RingBuffer;
|
pub use ring_buffer::RingBuffer;
|
||||||
pub use tapped_source::TappedSource;
|
pub use tapped_source::TappedSource;
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Number of frequency bins to send to frontend
|
/// Number of frequency bins to send to frontend
|
||||||
pub const NUM_BARS: usize = 16;
|
pub const NUM_BARS: usize = 16;
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
//! - No allocations
|
//! - No allocations
|
||||||
//! - Minimal atomic operations
|
//! - Minimal atomic operations
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::cell::UnsafeCell;
|
use std::cell::UnsafeCell;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
/// A lock-free single-producer single-consumer ring buffer
|
/// A lock-free single-producer single-consumer ring buffer
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
//! without affecting audio playback. The tap is completely transparent to the
|
//! without affecting audio playback. The tap is completely transparent to the
|
||||||
//! audio pipeline.
|
//! audio pipeline.
|
||||||
|
|
||||||
|
use rodio::Source;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use rodio::Source;
|
|
||||||
|
|
||||||
use super::RingBuffer;
|
use super::RingBuffer;
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,8 @@ impl AudioCache {
|
|||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
|
|
||||||
// Evict old entries to make room
|
// Evict old entries to make room
|
||||||
while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty() {
|
while state.current_size + size > self.max_size_bytes && !state.access_order.is_empty()
|
||||||
|
{
|
||||||
let oldest_id = state.access_order.remove(0);
|
let oldest_id = state.access_order.remove(0);
|
||||||
if let Some(track) = state.tracks.remove(&oldest_id) {
|
if let Some(track) = state.tracks.remove(&oldest_id) {
|
||||||
state.current_size = state.current_size.saturating_sub(track.size_bytes);
|
state.current_size = state.current_size.saturating_sub(track.size_bytes);
|
||||||
|
|||||||
@@ -58,5 +58,5 @@
|
|||||||
mod audio_cache;
|
mod audio_cache;
|
||||||
mod playback_cache;
|
mod playback_cache;
|
||||||
|
|
||||||
pub use audio_cache::{AudioCache, CachedTrack, CacheStats};
|
pub use audio_cache::{AudioCache, CacheStats, CachedTrack};
|
||||||
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
pub use playback_cache::{PlaybackCache, PlaybackCacheStats};
|
||||||
@@ -94,9 +94,8 @@ impl PlaybackCache {
|
|||||||
if let Some(id_str) = filename.strip_suffix(".audio") {
|
if let Some(id_str) = filename.strip_suffix(".audio") {
|
||||||
if let Ok(track_id) = id_str.parse::<u64>() {
|
if let Ok(track_id) = id_str.parse::<u64>() {
|
||||||
let size = metadata.len();
|
let size = metadata.len();
|
||||||
let last_accessed = metadata
|
let last_accessed =
|
||||||
.accessed()
|
metadata.accessed().unwrap_or_else(|_| SystemTime::now());
|
||||||
.unwrap_or_else(|_| SystemTime::now());
|
|
||||||
|
|
||||||
state.entries.insert(
|
state.entries.insert(
|
||||||
track_id,
|
track_id,
|
||||||
@@ -171,7 +170,11 @@ impl PlaybackCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to open playback cache file for track {}: {}", track_id, e);
|
log::warn!(
|
||||||
|
"Failed to open playback cache file for track {}: {}",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +235,11 @@ impl PlaybackCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Failed to create playback cache file for track {}: {}", track_id, e);
|
log::warn!(
|
||||||
|
"Failed to create playback cache file for track {}: {}",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
@@ -59,12 +59,12 @@ impl AirPlayDiscovery {
|
|||||||
let mdns = ServiceDaemon::new()
|
let mdns = ServiceDaemon::new()
|
||||||
.map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?;
|
.map_err(|e| AirPlayError::Discovery(format!("Failed to create mDNS daemon: {}", e)))?;
|
||||||
|
|
||||||
let raop_rx = mdns
|
let raop_rx = mdns.browse(SERVICE_RAOP).map_err(|e| {
|
||||||
.browse(SERVICE_RAOP)
|
AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e))
|
||||||
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse RAOP services: {}", e)))?;
|
})?;
|
||||||
let airplay_rx = mdns
|
let airplay_rx = mdns.browse(SERVICE_AIRPLAY).map_err(|e| {
|
||||||
.browse(SERVICE_AIRPLAY)
|
AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e))
|
||||||
.map_err(|e| AirPlayError::Discovery(format!("Failed to browse AirPlay services: {}", e)))?;
|
})?;
|
||||||
|
|
||||||
self.running.store(true, Ordering::SeqCst);
|
self.running.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
@@ -76,12 +76,8 @@ impl AirPlayDiscovery {
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
running.clone(),
|
running.clone(),
|
||||||
));
|
));
|
||||||
self.handles.push(spawn_receiver(
|
self.handles
|
||||||
"airplay",
|
.push(spawn_receiver("airplay", airplay_rx, state, running));
|
||||||
airplay_rx,
|
|
||||||
state,
|
|
||||||
running,
|
|
||||||
));
|
|
||||||
|
|
||||||
self.daemon = Some(mdns);
|
self.daemon = Some(mdns);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -150,8 +146,8 @@ fn spawn_receiver(
|
|||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let requires_password = parse_password_required(&info);
|
let requires_password = parse_password_required(&info);
|
||||||
let ip = pick_ip(info.get_addresses())
|
let ip =
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
pick_ip(info.get_addresses()).unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
|
||||||
let device = DiscoveredAirPlayDevice {
|
let device = DiscoveredAirPlayDevice {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|
||||||
|
pub use crate::AirPlayError;
|
||||||
pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus};
|
pub use device::{AirPlayConnection, AirPlayMetadata, AirPlayStatus};
|
||||||
pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice};
|
pub use discovery::{AirPlayDiscovery, DiscoveredAirPlayDevice};
|
||||||
pub use crate::AirPlayError;
|
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use rust_cast::CastDevice;
|
use rust_cast::channels::media::{
|
||||||
use rust_cast::channels::media::{Image, Media, Metadata, MusicTrackMediaMetadata, StreamType, PlayerState, IdleReason};
|
IdleReason, Image, Media, Metadata, MusicTrackMediaMetadata, PlayerState, StreamType,
|
||||||
|
};
|
||||||
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
|
use rust_cast::channels::receiver::{CastDeviceApp, Status as ReceiverStatus};
|
||||||
|
use rust_cast::CastDevice;
|
||||||
|
|
||||||
use crate::CastError;
|
use crate::CastError;
|
||||||
|
|
||||||
@@ -90,7 +92,10 @@ impl CastDeviceConnection {
|
|||||||
/// Disconnect from device
|
/// Disconnect from device
|
||||||
pub fn disconnect(&mut self) -> Result<(), CastError> {
|
pub fn disconnect(&mut self) -> Result<(), CastError> {
|
||||||
if let Some(session) = &self.session {
|
if let Some(session) = &self.session {
|
||||||
let _ = self.device.connection.disconnect(session.transport_id.as_str());
|
let _ = self
|
||||||
|
.device
|
||||||
|
.connection
|
||||||
|
.disconnect(session.transport_id.as_str());
|
||||||
}
|
}
|
||||||
let _ = self.device.connection.disconnect(DEFAULT_RECEIVER_ID);
|
let _ = self.device.connection.disconnect(DEFAULT_RECEIVER_ID);
|
||||||
self.session = None;
|
self.session = None;
|
||||||
@@ -151,7 +156,11 @@ impl CastDeviceConnection {
|
|||||||
let status = self
|
let status = self
|
||||||
.device
|
.device
|
||||||
.media
|
.media
|
||||||
.load(session.transport_id.as_str(), session.session_id.as_str(), &media)
|
.load(
|
||||||
|
session.transport_id.as_str(),
|
||||||
|
session.session_id.as_str(),
|
||||||
|
&media,
|
||||||
|
)
|
||||||
.map_err(|e| CastError::Media(e.to_string()))?;
|
.map_err(|e| CastError::Media(e.to_string()))?;
|
||||||
|
|
||||||
if let Some(entry) = status.entries.first() {
|
if let Some(entry) = status.entries.first() {
|
||||||
@@ -245,9 +254,7 @@ impl CastDeviceConnection {
|
|||||||
IdleReason::Error => "ERROR".to_string(),
|
IdleReason::Error => "ERROR".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let duration = entry.media.as_ref()
|
let duration = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64;
|
||||||
.and_then(|m| m.duration)
|
|
||||||
.unwrap_or(0.0) as f64;
|
|
||||||
|
|
||||||
let position = entry.current_time.unwrap_or(0.0) as f64;
|
let position = entry.current_time.unwrap_or(0.0) as f64;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread::{self, JoinHandle};
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use std::thread::{self, JoinHandle};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::chromecast::device::CastDeviceConnection;
|
use crate::chromecast::device::CastDeviceConnection;
|
||||||
|
use crate::chromecast::{CastPositionInfo, CastStatus, MediaMetadata};
|
||||||
use crate::CastError;
|
use crate::CastError;
|
||||||
use crate::chromecast::{CastStatus, MediaMetadata, CastPositionInfo};
|
|
||||||
|
|
||||||
/// Commands sent to the Chromecast thread
|
/// Commands sent to the Chromecast thread
|
||||||
pub enum CastCommand {
|
pub enum CastCommand {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! DLNA device connection and playback via AVTransport SOAP
|
//! DLNA device connection and playback via AVTransport SOAP
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use rupnp::{Device, Service};
|
|
||||||
use rupnp::http::Uri;
|
use rupnp::http::Uri;
|
||||||
use rupnp::ssdp::URN;
|
use rupnp::ssdp::URN;
|
||||||
|
use rupnp::{Device, Service};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::dlna::DiscoveredDlnaDevice;
|
use crate::dlna::DiscoveredDlnaDevice;
|
||||||
use crate::DlnaError;
|
use crate::DlnaError;
|
||||||
@@ -56,13 +56,11 @@ impl DlnaConnection {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?;
|
.map_err(|e| DlnaError::Connection(format!("Invalid device URL: {}", e)))?;
|
||||||
|
|
||||||
let parsed_device = Device::from_url(device_url.clone())
|
let parsed_device = Device::from_url(device_url.clone()).await.map_err(|e| {
|
||||||
.await
|
DlnaError::Connection(format!("Failed to load device description: {}", e))
|
||||||
.map_err(|e| DlnaError::Connection(format!("Failed to load device description: {}", e)))?;
|
})?;
|
||||||
|
|
||||||
let av_transport_service = parsed_device
|
let av_transport_service = parsed_device.find_service(&av_transport_urn()).cloned();
|
||||||
.find_service(&av_transport_urn())
|
|
||||||
.cloned();
|
|
||||||
let rendering_control_service = parsed_device
|
let rendering_control_service = parsed_device
|
||||||
.find_service(&rendering_control_urn())
|
.find_service(&rendering_control_urn())
|
||||||
.cloned();
|
.cloned();
|
||||||
@@ -110,12 +108,19 @@ impl DlnaConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the media URI and start playback
|
/// Set the media URI and start playback
|
||||||
pub async fn load_media(&mut self, uri: &str, metadata: &DlnaMetadata, content_type: &str) -> Result<(), DlnaError> {
|
pub async fn load_media(
|
||||||
|
&mut self,
|
||||||
|
uri: &str,
|
||||||
|
metadata: &DlnaMetadata,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<(), DlnaError> {
|
||||||
if !self.connected {
|
if !self.connected {
|
||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
// Build DIDL-Lite metadata with actual content type
|
// Build DIDL-Lite metadata with actual content type
|
||||||
@@ -133,7 +138,7 @@ impl DlnaConnection {
|
|||||||
|
|
||||||
let response = tokio::time::timeout(
|
let response = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(10),
|
std::time::Duration::from_secs(10),
|
||||||
av_service.action(&self.device_url, "SetAVTransportURI", &payload)
|
av_service.action(&self.device_url, "SetAVTransportURI", &payload),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -158,12 +163,18 @@ impl DlnaConnection {
|
|||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
let response = tokio::time::timeout(
|
let response = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(10),
|
std::time::Duration::from_secs(10),
|
||||||
av_service.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
av_service.action(
|
||||||
|
&self.device_url,
|
||||||
|
"Play",
|
||||||
|
"<InstanceID>0</InstanceID><Speed>1</Speed>",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -187,7 +198,9 @@ impl DlnaConnection {
|
|||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
av_service
|
av_service
|
||||||
@@ -206,7 +219,9 @@ impl DlnaConnection {
|
|||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
av_service
|
av_service
|
||||||
@@ -231,7 +246,9 @@ impl DlnaConnection {
|
|||||||
let seconds = position_secs % 60;
|
let seconds = position_secs % 60;
|
||||||
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
let time_str = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
let payload = format!(
|
let payload = format!(
|
||||||
@@ -254,8 +271,9 @@ impl DlnaConnection {
|
|||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rc_service = self.rendering_control_service.as_ref()
|
let rc_service = self.rendering_control_service.as_ref().ok_or_else(|| {
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no RenderingControl service".to_string()))?;
|
DlnaError::Playback("Device has no RenderingControl service".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
// DLNA volume is typically 0-100
|
// DLNA volume is typically 0-100
|
||||||
let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100);
|
let dlna_volume = ((volume.clamp(0.0, 1.0) * 100.0) as u32).min(100);
|
||||||
@@ -280,13 +298,19 @@ impl DlnaConnection {
|
|||||||
return Err(DlnaError::NotConnected);
|
return Err(DlnaError::NotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
let av_service = self.av_transport_service.as_ref()
|
let av_service = self
|
||||||
|
.av_transport_service
|
||||||
|
.as_ref()
|
||||||
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
.ok_or_else(|| DlnaError::Playback("Device has no AVTransport service".to_string()))?;
|
||||||
|
|
||||||
// Get position info
|
// Get position info
|
||||||
let position_response = tokio::time::timeout(
|
let position_response = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(5),
|
std::time::Duration::from_secs(5),
|
||||||
av_service.action(&self.device_url, "GetPositionInfo", "<InstanceID>0</InstanceID>")
|
av_service.action(
|
||||||
|
&self.device_url,
|
||||||
|
"GetPositionInfo",
|
||||||
|
"<InstanceID>0</InstanceID>",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
|
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
|
||||||
@@ -295,26 +319,33 @@ impl DlnaConnection {
|
|||||||
// Get transport state
|
// Get transport state
|
||||||
let transport_response = tokio::time::timeout(
|
let transport_response = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(5),
|
std::time::Duration::from_secs(5),
|
||||||
av_service.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
|
av_service.action(
|
||||||
|
&self.device_url,
|
||||||
|
"GetTransportInfo",
|
||||||
|
"<InstanceID>0</InstanceID>",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
|
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
|
||||||
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
.map_err(|e| DlnaError::Playback(e.to_string()))?;
|
||||||
|
|
||||||
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
|
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
|
||||||
let rel_time = position_response.get("RelTime")
|
let rel_time = position_response
|
||||||
|
.get("RelTime")
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.unwrap_or("0:00:00");
|
.unwrap_or("0:00:00");
|
||||||
let position_secs = parse_time_string(rel_time);
|
let position_secs = parse_time_string(rel_time);
|
||||||
|
|
||||||
// Parse TrackDuration - format: "HH:MM:SS"
|
// Parse TrackDuration - format: "HH:MM:SS"
|
||||||
let track_duration = position_response.get("TrackDuration")
|
let track_duration = position_response
|
||||||
|
.get("TrackDuration")
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.unwrap_or("0:00:00");
|
.unwrap_or("0:00:00");
|
||||||
let duration_secs = parse_time_string(track_duration);
|
let duration_secs = parse_time_string(track_duration);
|
||||||
|
|
||||||
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
|
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
|
||||||
let transport_state = transport_response.get("CurrentTransportState")
|
let transport_state = transport_response
|
||||||
|
.get("CurrentTransportState")
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| "UNKNOWN".to_string());
|
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||||
|
|
||||||
@@ -324,7 +355,6 @@ impl DlnaConnection {
|
|||||||
transport_state,
|
transport_state,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
|
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds
|
||||||
@@ -336,7 +366,9 @@ fn parse_time_string(time: &str) -> u64 {
|
|||||||
|
|
||||||
let hours: u64 = parts[0].parse().unwrap_or(0);
|
let hours: u64 = parts[0].parse().unwrap_or(0);
|
||||||
let minutes: u64 = parts[1].parse().unwrap_or(0);
|
let minutes: u64 = parts[1].parse().unwrap_or(0);
|
||||||
let seconds: u64 = parts[2].split('.').next()
|
let seconds: u64 = parts[2]
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
@@ -345,15 +377,25 @@ fn parse_time_string(time: &str) -> u64 {
|
|||||||
|
|
||||||
/// Build DIDL-Lite metadata for a track
|
/// Build DIDL-Lite metadata for a track
|
||||||
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
|
fn build_didl_metadata(uri: &str, metadata: &DlnaMetadata, content_type: &str) -> String {
|
||||||
let duration = metadata.duration_secs.map(|d| {
|
let duration = metadata
|
||||||
let hours = d / 3600;
|
.duration_secs
|
||||||
let minutes = (d % 3600) / 60;
|
.map(|d| {
|
||||||
let seconds = d % 60;
|
let hours = d / 3600;
|
||||||
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
let minutes = (d % 3600) / 60;
|
||||||
}).unwrap_or_else(|| "00:00:00".to_string());
|
let seconds = d % 60;
|
||||||
|
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "00:00:00".to_string());
|
||||||
|
|
||||||
let artwork = metadata.artwork_url.as_ref()
|
let artwork = metadata
|
||||||
.map(|url| format!(r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#, xml_escape(url)))
|
.artwork_url
|
||||||
|
.as_ref()
|
||||||
|
.map(|url| {
|
||||||
|
format!(
|
||||||
|
r#"<upnp:albumArtURI>{}</upnp:albumArtURI>"#,
|
||||||
|
xml_escape(url)
|
||||||
|
)
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Use actual content type for protocolInfo - critical for DLNA compatibility
|
// Use actual content type for protocolInfo - critical for DLNA compatibility
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! DLNA/UPnP device discovery via SSDP
|
//! DLNA/UPnP device discovery via SSDP
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
@@ -62,7 +62,9 @@ impl DlnaDiscovery {
|
|||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
while running.load(Ordering::SeqCst) {
|
while running.load(Ordering::SeqCst) {
|
||||||
let target = SearchTarget::URN(media_renderer_urn());
|
let target = SearchTarget::URN(media_renderer_urn());
|
||||||
let discover = rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None).await;
|
let discover =
|
||||||
|
rupnp::discover(&target, Duration::from_secs(DISCOVERY_WINDOW_SECS), None)
|
||||||
|
.await;
|
||||||
|
|
||||||
if let Ok(stream) = discover {
|
if let Ok(stream) = discover {
|
||||||
let mut stream = std::pin::pin!(stream);
|
let mut stream = std::pin::pin!(stream);
|
||||||
@@ -72,11 +74,7 @@ impl DlnaDiscovery {
|
|||||||
let manufacturer = device.manufacturer().to_string();
|
let manufacturer = device.manufacturer().to_string();
|
||||||
let model = device.model_name().to_string();
|
let model = device.model_name().to_string();
|
||||||
let url = device.url().to_string();
|
let url = device.url().to_string();
|
||||||
let ip = device
|
let ip = device.url().host().unwrap_or("unknown").to_string();
|
||||||
.url()
|
|
||||||
.host()
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut has_av_transport = false;
|
let mut has_av_transport = false;
|
||||||
let mut has_rendering_control = false;
|
let mut has_rendering_control = false;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|
||||||
|
pub use crate::DlnaError;
|
||||||
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
|
pub use device::{DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus};
|
||||||
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
|
pub use discovery::{DiscoveredDlnaDevice, DlnaDiscovery};
|
||||||
pub use crate::DlnaError;
|
|
||||||
@@ -15,48 +15,32 @@
|
|||||||
//! - **MediaServer**: Local HTTP server for streaming audio to cast devices.
|
//! - **MediaServer**: Local HTTP server for streaming audio to cast devices.
|
||||||
//! Supports byte-range requests for seeking.
|
//! Supports byte-range requests for seeking.
|
||||||
|
|
||||||
|
pub mod airplay;
|
||||||
|
pub mod chromecast;
|
||||||
|
pub mod dlna;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod media_server;
|
pub mod media_server;
|
||||||
pub mod chromecast;
|
|
||||||
pub mod airplay;
|
|
||||||
pub mod dlna;
|
|
||||||
|
|
||||||
// Re-export error types at root
|
// Re-export error types at root
|
||||||
pub use errors::{CastError, AirPlayError, DlnaError};
|
pub use errors::{AirPlayError, CastError, DlnaError};
|
||||||
|
|
||||||
// Re-export media server
|
// Re-export media server
|
||||||
pub use media_server::MediaServer;
|
pub use media_server::MediaServer;
|
||||||
|
|
||||||
// Re-export Chromecast types
|
// Re-export Chromecast types
|
||||||
pub use chromecast::{
|
pub use chromecast::{
|
||||||
CastApplication,
|
CastApplication, CastCommand, CastDeviceConnection, CastPositionInfo, CastStatus,
|
||||||
CastCommand,
|
ChromecastHandle, DeviceDiscovery, DiscoveredDevice, MediaMetadata,
|
||||||
CastDeviceConnection,
|
|
||||||
CastPositionInfo,
|
|
||||||
CastStatus,
|
|
||||||
ChromecastHandle,
|
|
||||||
DeviceDiscovery,
|
|
||||||
DiscoveredDevice,
|
|
||||||
MediaMetadata,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export AirPlay types
|
// Re-export AirPlay types
|
||||||
pub use airplay::{
|
pub use airplay::{
|
||||||
AirPlayConnection,
|
AirPlayConnection, AirPlayDiscovery, AirPlayMetadata, AirPlayStatus, DiscoveredAirPlayDevice,
|
||||||
AirPlayDiscovery,
|
|
||||||
AirPlayMetadata,
|
|
||||||
AirPlayStatus,
|
|
||||||
DiscoveredAirPlayDevice,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export DLNA types
|
// Re-export DLNA types
|
||||||
pub use dlna::{
|
pub use dlna::{
|
||||||
DiscoveredDlnaDevice,
|
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
|
||||||
DlnaConnection,
|
|
||||||
DlnaDiscovery,
|
|
||||||
DlnaMetadata,
|
|
||||||
DlnaPositionInfo,
|
|
||||||
DlnaStatus,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Cast device type alias for backwards compatibility
|
/// Cast device type alias for backwards compatibility
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ impl MediaServer {
|
|||||||
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
let base_ip = local_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||||
let base_url = format_base_url(base_ip, port);
|
let base_url = format_base_url(base_ip, port);
|
||||||
|
|
||||||
log::info!("MediaServer: Started on {}:{} (base_url: {})", base_ip, port, base_url);
|
log::info!(
|
||||||
|
"MediaServer: Started on {}:{} (base_url: {})",
|
||||||
|
base_ip,
|
||||||
|
port,
|
||||||
|
base_url
|
||||||
|
);
|
||||||
|
|
||||||
let entries = Arc::new(Mutex::new(HashMap::new()));
|
let entries = Arc::new(Mutex::new(HashMap::new()));
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
@@ -70,11 +75,19 @@ impl MediaServer {
|
|||||||
let port_for_log = port;
|
let port_for_log = port;
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let handle = thread::spawn(move || {
|
||||||
log::info!("MediaServer: Thread started, listening on port {}", port_for_log);
|
log::info!(
|
||||||
|
"MediaServer: Thread started, listening on port {}",
|
||||||
|
port_for_log
|
||||||
|
);
|
||||||
while !shutdown_clone.load(Ordering::SeqCst) {
|
while !shutdown_clone.load(Ordering::SeqCst) {
|
||||||
match server.recv_timeout(Duration::from_millis(250)) {
|
match server.recv_timeout(Duration::from_millis(250)) {
|
||||||
Ok(Some(request)) => {
|
Ok(Some(request)) => {
|
||||||
let response = handle_request(request.method(), request.url(), &request, &entries_clone);
|
let response = handle_request(
|
||||||
|
request.method(),
|
||||||
|
request.url(),
|
||||||
|
&request,
|
||||||
|
&entries_clone,
|
||||||
|
);
|
||||||
let _ = request.respond(response);
|
let _ = request.respond(response);
|
||||||
}
|
}
|
||||||
Ok(None) => {}
|
Ok(None) => {}
|
||||||
@@ -134,10 +147,7 @@ impl MediaServer {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let size = path
|
let size = path.metadata().map_err(CastError::Io)?.len();
|
||||||
.metadata()
|
|
||||||
.map_err(CastError::Io)?
|
|
||||||
.len();
|
|
||||||
let content_type = content_type_for_path(path);
|
let content_type = content_type_for_path(path);
|
||||||
|
|
||||||
let entry = MediaEntry {
|
let entry = MediaEntry {
|
||||||
@@ -194,7 +204,12 @@ fn handle_request(
|
|||||||
request: &tiny_http::Request,
|
request: &tiny_http::Request,
|
||||||
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
|
entries: &Arc<Mutex<HashMap<u64, MediaEntry>>>,
|
||||||
) -> Response<std::io::Cursor<Vec<u8>>> {
|
) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||||
log::info!("MediaServer: {} request from {:?} for {}", method, request.remote_addr(), url);
|
log::info!(
|
||||||
|
"MediaServer: {} request from {:?} for {}",
|
||||||
|
method,
|
||||||
|
request.remote_addr(),
|
||||||
|
url
|
||||||
|
);
|
||||||
|
|
||||||
if method != &Method::Get {
|
if method != &Method::Get {
|
||||||
log::warn!("MediaServer: Rejected non-GET request: {}", method);
|
log::warn!("MediaServer: Rejected non-GET request: {}", method);
|
||||||
@@ -204,14 +219,22 @@ fn handle_request(
|
|||||||
let id = match parse_audio_id(url) {
|
let id = match parse_audio_id(url) {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
log::warn!("MediaServer: 404 - Could not parse audio ID from URL: {}", url);
|
log::warn!(
|
||||||
|
"MediaServer: 404 - Could not parse audio ID from URL: {}",
|
||||||
|
url
|
||||||
|
);
|
||||||
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
|
return Response::from_data(Vec::new()).with_status_code(StatusCode(404));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
|
let entry = match entries.lock().ok().and_then(|map| map.get(&id).cloned()) {
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
log::info!("MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes", id, entry.content_type, entry.size);
|
log::info!(
|
||||||
|
"MediaServer: Found entry for ID {}, content-type: {}, size: {} bytes",
|
||||||
|
id,
|
||||||
|
entry.content_type,
|
||||||
|
entry.size
|
||||||
|
);
|
||||||
entry
|
entry
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -345,7 +368,11 @@ fn format_base_url(ip: IpAddr, port: u16) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn content_type_for_path(path: &Path) -> String {
|
fn content_type_for_path(path: &Path) -> String {
|
||||||
match path.extension().and_then(|e| e.to_str()).map(|s| s.to_lowercase()) {
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
{
|
||||||
Some(ext) if ext == "flac" => "audio/flac".to_string(),
|
Some(ext) if ext == "flac" => "audio/flac".to_string(),
|
||||||
Some(ext) if ext == "wav" => "audio/wav".to_string(),
|
Some(ext) if ext == "wav" => "audio/wav".to_string(),
|
||||||
Some(ext) if ext == "m4a" => "audio/mp4".to_string(),
|
Some(ext) if ext == "m4a" => "audio/mp4".to_string(),
|
||||||
|
|||||||
+22
-30
@@ -8,11 +8,11 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use qbz_models::{
|
use qbz_models::{
|
||||||
Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
|
Album, Artist, CoreEvent, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse,
|
||||||
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse,
|
DiscoverResponse, FrontendAdapter, GenreInfo, LabelDetail, LabelExploreResponse, LabelPageData,
|
||||||
LabelPageData, PageArtistResponse, Playlist,
|
PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode,
|
||||||
PlaylistTag, Quality, QueueState, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
|
SearchResultsPage, StreamUrl, Track, UserSession,
|
||||||
};
|
};
|
||||||
use qbz_player::{Player, PlaybackState, QueueManager};
|
use qbz_player::{PlaybackState, Player, QueueManager};
|
||||||
use qbz_qobuz::QobuzClient;
|
use qbz_qobuz::QobuzClient;
|
||||||
|
|
||||||
use crate::error::CoreError;
|
use crate::error::CoreError;
|
||||||
@@ -63,9 +63,10 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
|
let client = QobuzClient::new().map_err(|e| CoreError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
// Extract bundle tokens (required before any API calls)
|
// Extract bundle tokens (required before any API calls)
|
||||||
client.init().await.map_err(|e| {
|
client
|
||||||
CoreError::Internal(format!("Failed to extract bundle tokens: {}", e))
|
.init()
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| CoreError::Internal(format!("Failed to extract bundle tokens: {}", e)))?;
|
||||||
|
|
||||||
*self.client.write().await = Some(client);
|
*self.client.write().await = Some(client);
|
||||||
|
|
||||||
@@ -115,10 +116,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
client.set_session(session.clone()).await;
|
client.set_session(session.clone()).await;
|
||||||
self.emit(CoreEvent::LoggedIn {
|
self.emit(CoreEvent::LoggedIn { session }).await;
|
||||||
session,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,23 +396,17 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), CoreError> {
|
pub fn pause(&self) -> Result<(), CoreError> {
|
||||||
self.player
|
self.player.pause().map_err(|e| CoreError::Playback(e))
|
||||||
.pause()
|
|
||||||
.map_err(|e| CoreError::Playback(e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume playback
|
/// Resume playback
|
||||||
pub fn resume(&self) -> Result<(), CoreError> {
|
pub fn resume(&self) -> Result<(), CoreError> {
|
||||||
self.player
|
self.player.resume().map_err(|e| CoreError::Playback(e))
|
||||||
.resume()
|
|
||||||
.map_err(|e| CoreError::Playback(e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop playback
|
/// Stop playback
|
||||||
pub fn stop(&self) -> Result<(), CoreError> {
|
pub fn stop(&self) -> Result<(), CoreError> {
|
||||||
self.player
|
self.player.stop().map_err(|e| CoreError::Playback(e))
|
||||||
.stop()
|
|
||||||
.map_err(|e| CoreError::Playback(e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seek to position in seconds
|
/// Seek to position in seconds
|
||||||
@@ -503,7 +495,10 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
|
|
||||||
client.get_playlist(playlist_id).await.map_err(CoreError::Api)
|
client
|
||||||
|
.get_playlist(playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::Api)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add tracks to playlist
|
/// Add tracks to playlist
|
||||||
@@ -612,14 +607,14 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
|
|
||||||
client
|
client.get_genres(parent_id).await.map_err(CoreError::Api)
|
||||||
.get_genres(parent_id)
|
|
||||||
.await
|
|
||||||
.map_err(CoreError::Api)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get discover index
|
/// Get discover index
|
||||||
pub async fn get_discover_index(&self, genre_ids: Option<Vec<u64>>) -> Result<DiscoverResponse, CoreError> {
|
pub async fn get_discover_index(
|
||||||
|
&self,
|
||||||
|
genre_ids: Option<Vec<u64>>,
|
||||||
|
) -> Result<DiscoverResponse, CoreError> {
|
||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
|
|
||||||
@@ -651,10 +646,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
let client = self.client.read().await;
|
let client = self.client.read().await;
|
||||||
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
|
||||||
|
|
||||||
client
|
client.get_playlist_tags().await.map_err(CoreError::Api)
|
||||||
.get_playlist_tags()
|
|
||||||
.await
|
|
||||||
.map_err(CoreError::Api)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get discover albums from a specific browse endpoint
|
/// Get discover albums from a specific browse endpoint
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub mod core;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
// Re-exports from qbz-models for convenience
|
// Re-exports from qbz-models for convenience
|
||||||
pub use qbz_models::{CoreEvent, FrontendAdapter, NoOpAdapter, LoggingAdapter};
|
pub use qbz_models::{CoreEvent, FrontendAdapter, LoggingAdapter, NoOpAdapter};
|
||||||
|
|
||||||
// Re-exports from this crate
|
// Re-exports from this crate
|
||||||
pub use core::QbzCore;
|
pub use core::QbzCore;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::models::{
|
||||||
|
AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession,
|
||||||
|
LastFmSimilarArtist,
|
||||||
|
};
|
||||||
use crate::error::{IntegrationError, IntegrationResult};
|
use crate::error::{IntegrationError, IntegrationResult};
|
||||||
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
|
|
||||||
|
|
||||||
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
|
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
|
||||||
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
|
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
|
||||||
@@ -78,25 +81,18 @@ impl LastFmClient {
|
|||||||
pub async fn get_token(&self) -> IntegrationResult<(String, String)> {
|
pub async fn get_token(&self) -> IntegrationResult<(String, String)> {
|
||||||
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
|
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
|
||||||
|
|
||||||
let response = self
|
let response = self.client.post(&url).json(&json!({})).send().await?;
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.json(&json!({}))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?;
|
let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?;
|
||||||
|
|
||||||
match data {
|
match data {
|
||||||
LastFmResponse::Success(r) => {
|
LastFmResponse::Success(r) => {
|
||||||
let auth_url = r.auth_url.unwrap_or_else(|| {
|
let auth_url = r
|
||||||
format!("https://www.last.fm/api/auth/?token={}", r.token)
|
.auth_url
|
||||||
});
|
.unwrap_or_else(|| format!("https://www.last.fm/api/auth/?token={}", r.token));
|
||||||
Ok((r.token, auth_url))
|
Ok((r.token, auth_url))
|
||||||
}
|
}
|
||||||
LastFmResponse::Error { error, message } => {
|
LastFmResponse::Error { error, message } => Err(IntegrationError::api(error, message)),
|
||||||
Err(IntegrationError::api(error, message))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +167,10 @@ impl LastFmClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let text = response.text().await.unwrap_or_default();
|
let text = response.text().await.unwrap_or_default();
|
||||||
Err(IntegrationError::internal(format!("Scrobble failed: {}", text)))
|
Err(IntegrationError::internal(format!(
|
||||||
|
"Scrobble failed: {}",
|
||||||
|
text
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ impl ListenBrainzCache {
|
|||||||
isrc: Option<&str>,
|
isrc: Option<&str>,
|
||||||
duration_ms: Option<u64>,
|
duration_ms: Option<u64>,
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
let artist_mbids_json = artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
|
let artist_mbids_json =
|
||||||
|
artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
|
||||||
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -183,7 +184,8 @@ impl ListenBrainzCache {
|
|||||||
let listens = stmt
|
let listens = stmt
|
||||||
.query_map([limit], |row| {
|
.query_map([limit], |row| {
|
||||||
let artist_mbids_json: Option<String> = row.get(7)?;
|
let artist_mbids_json: Option<String> = row.get(7)?;
|
||||||
let artist_mbids = artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok());
|
let artist_mbids =
|
||||||
|
artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok());
|
||||||
|
|
||||||
Ok(QueuedListen {
|
Ok(QueuedListen {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
@@ -241,7 +243,8 @@ impl ListenBrainzCache {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
|
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
|
||||||
.collect();
|
.collect();
|
||||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||||
|
params.iter().map(|p| p.as_ref()).collect();
|
||||||
self.conn
|
self.conn
|
||||||
.execute(&sql, params_refs.as_slice())
|
.execute(&sql, params_refs.as_slice())
|
||||||
.map_err(|e| format!("Failed to batch mark sent: {}", e))?;
|
.map_err(|e| format!("Failed to batch mark sent: {}", e))?;
|
||||||
@@ -252,7 +255,11 @@ impl ListenBrainzCache {
|
|||||||
pub fn get_queue_count(&self) -> Result<u32, String> {
|
pub fn get_queue_count(&self) -> Result<u32, String> {
|
||||||
let count: i64 = self
|
let count: i64 = self
|
||||||
.conn
|
.conn
|
||||||
.query_row("SELECT COUNT(*) FROM listen_queue WHERE sent = 0", [], |row| row.get(0))
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM listen_queue WHERE sent = 0",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
Ok(count as u32)
|
Ok(count as u32)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use reqwest::Client;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::error::{IntegrationError, IntegrationResult};
|
|
||||||
use super::models::*;
|
use super::models::*;
|
||||||
|
use crate::error::{IntegrationError, IntegrationResult};
|
||||||
|
|
||||||
/// ListenBrainz API base URL
|
/// ListenBrainz API base URL
|
||||||
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
|
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
|
||||||
@@ -113,9 +113,9 @@ impl ListenBrainzClient {
|
|||||||
return Err(IntegrationError::AuthFailed(validation.message));
|
return Err(IntegrationError::AuthFailed(validation.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_name = validation
|
let user_name = validation.user_name.ok_or_else(|| {
|
||||||
.user_name
|
IntegrationError::AuthFailed("Token valid but no username returned".into())
|
||||||
.ok_or_else(|| IntegrationError::AuthFailed("Token valid but no username returned".into()))?;
|
})?;
|
||||||
|
|
||||||
// Store validated token and username
|
// Store validated token and username
|
||||||
{
|
{
|
||||||
@@ -174,7 +174,10 @@ impl ListenBrainzClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
response.json::<TokenValidationResponse>().await.map_err(Into::into)
|
response
|
||||||
|
.json::<TokenValidationResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submit "now playing" notification
|
/// Submit "now playing" notification
|
||||||
@@ -261,7 +264,11 @@ impl ListenBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Internal: Submit listens to API
|
/// Internal: Submit listens to API
|
||||||
async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> IntegrationResult<()> {
|
async fn submit_listens(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
payload: &SubmitListensPayload,
|
||||||
|
) -> IntegrationResult<()> {
|
||||||
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
|
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
|
|||||||
@@ -45,6 +45,6 @@ pub mod cache;
|
|||||||
|
|
||||||
pub use client::{ListenBrainzClient, ListenBrainzConfig};
|
pub use client::{ListenBrainzClient, ListenBrainzConfig};
|
||||||
pub use models::{
|
pub use models::{
|
||||||
ListenType, TrackMetadata, AdditionalInfo, Listen, SubmitListensPayload,
|
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
|
||||||
ListenBrainzStatus, UserInfo, QueuedListen,
|
TrackMetadata, UserInfo,
|
||||||
};
|
};
|
||||||
@@ -8,8 +8,8 @@ use std::path::Path;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use super::models::{
|
use super::models::{
|
||||||
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse,
|
ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence,
|
||||||
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType,
|
ResolvedArtist, ResolvedTrack,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// TTL for recording cache (30 days)
|
/// TTL for recording cache (30 days)
|
||||||
@@ -245,7 +245,10 @@ impl MusicBrainzCache {
|
|||||||
// ============ Artist Cache (JSON-serialized) ============
|
// ============ Artist Cache (JSON-serialized) ============
|
||||||
|
|
||||||
/// Get cached artist by name (JSON-serialized)
|
/// Get cached artist by name (JSON-serialized)
|
||||||
pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(&self, name: &str) -> Result<Option<T>, String> {
|
pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<T>, String> {
|
||||||
let normalized = Self::normalize_name(name);
|
let normalized = Self::normalize_name(name);
|
||||||
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
|
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
|
||||||
let result: Option<String> = self
|
let result: Option<String> = self
|
||||||
@@ -268,7 +271,11 @@ impl MusicBrainzCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache an artist (JSON-serialized)
|
/// Cache an artist (JSON-serialized)
|
||||||
pub fn set_artist_by_name<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<(), String> {
|
pub fn set_artist_by_name<T: serde::Serialize>(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
data: &T,
|
||||||
|
) -> Result<(), String> {
|
||||||
let normalized = Self::normalize_name(name);
|
let normalized = Self::normalize_name(name);
|
||||||
let fetched_at = Self::current_timestamp();
|
let fetched_at = Self::current_timestamp();
|
||||||
let json = serde_json::to_string(data)
|
let json = serde_json::to_string(data)
|
||||||
@@ -285,7 +292,10 @@ impl MusicBrainzCache {
|
|||||||
// ============ Release Cache ============
|
// ============ Release Cache ============
|
||||||
|
|
||||||
/// Get cached release by barcode
|
/// Get cached release by barcode
|
||||||
pub fn get_release<T: serde::de::DeserializeOwned>(&self, barcode: &str) -> Result<Option<T>, String> {
|
pub fn get_release<T: serde::de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
barcode: &str,
|
||||||
|
) -> Result<Option<T>, String> {
|
||||||
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
|
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
|
||||||
let result: Option<String> = self
|
let result: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
@@ -345,7 +355,11 @@ impl MusicBrainzCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache artist relationships
|
/// Cache artist relationships
|
||||||
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
|
pub fn set_artist_relations(
|
||||||
|
&self,
|
||||||
|
mbid: &str,
|
||||||
|
data: &ArtistRelationships,
|
||||||
|
) -> Result<(), String> {
|
||||||
let fetched_at = Self::current_timestamp();
|
let fetched_at = Self::current_timestamp();
|
||||||
let json = serde_json::to_string(data)
|
let json = serde_json::to_string(data)
|
||||||
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
|
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
|
||||||
@@ -399,7 +413,10 @@ impl MusicBrainzCache {
|
|||||||
// ============ Scene Discovery Cache ============
|
// ============ Scene Discovery Cache ============
|
||||||
|
|
||||||
/// Get cached scene discovery results
|
/// Get cached scene discovery results
|
||||||
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> {
|
pub fn get_scene_cache(
|
||||||
|
&self,
|
||||||
|
cache_key: &str,
|
||||||
|
) -> Result<Option<LocationDiscoveryResponse>, String> {
|
||||||
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
|
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
|
||||||
let result: Option<String> = self
|
let result: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
@@ -421,10 +438,14 @@ impl MusicBrainzCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache scene discovery results
|
/// Cache scene discovery results
|
||||||
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> {
|
pub fn set_scene_cache(
|
||||||
|
&self,
|
||||||
|
cache_key: &str,
|
||||||
|
data: &LocationDiscoveryResponse,
|
||||||
|
) -> Result<(), String> {
|
||||||
let fetched_at = Self::current_timestamp();
|
let fetched_at = Self::current_timestamp();
|
||||||
let json = serde_json::to_string(data)
|
let json =
|
||||||
.map_err(|e| format!("Failed to serialize scene: {}", e))?;
|
serde_json::to_string(data).map_err(|e| format!("Failed to serialize scene: {}", e))?;
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
|
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
|
||||||
@@ -621,7 +642,10 @@ impl MusicBrainzCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if total_deleted > 0 {
|
if total_deleted > 0 {
|
||||||
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
|
log::info!(
|
||||||
|
"MusicBrainz cache cleanup: removed {} expired entries",
|
||||||
|
total_deleted
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(total_deleted)
|
Ok(total_deleted)
|
||||||
}
|
}
|
||||||
@@ -650,17 +674,23 @@ impl MusicBrainzCache {
|
|||||||
|
|
||||||
/// Get cache statistics
|
/// Get cache statistics
|
||||||
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
||||||
let recordings: i64 = self.conn
|
let recordings: i64 = self
|
||||||
|
.conn
|
||||||
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let artists: i64 = self.conn
|
let artists: i64 = self
|
||||||
|
.conn
|
||||||
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let releases: i64 = self.conn
|
let releases: i64 = self
|
||||||
|
.conn
|
||||||
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let relations: i64 = self.conn
|
let relations: i64 = self
|
||||||
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(CacheStats {
|
Ok(CacheStats {
|
||||||
@@ -674,12 +704,14 @@ impl MusicBrainzCache {
|
|||||||
/// TTL-based cleanup (V2 style)
|
/// TTL-based cleanup (V2 style)
|
||||||
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
|
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
|
||||||
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
|
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
|
||||||
let tracks_deleted = self.conn
|
let tracks_deleted =
|
||||||
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
|
self.conn
|
||||||
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
|
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
|
||||||
let artists_deleted = self.conn
|
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
|
||||||
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
|
let artists_deleted =
|
||||||
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
|
self.conn
|
||||||
|
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
|
||||||
|
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
|
||||||
Ok((tracks_deleted, artists_deleted))
|
Ok((tracks_deleted, artists_deleted))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::error::{IntegrationError, IntegrationResult};
|
|
||||||
use super::models::*;
|
use super::models::*;
|
||||||
|
use crate::error::{IntegrationError, IntegrationResult};
|
||||||
|
|
||||||
/// Proxy URL for MusicBrainz requests
|
/// Proxy URL for MusicBrainz requests
|
||||||
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
|
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
|
||||||
@@ -144,7 +144,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search recordings by ISRC
|
/// Search recordings by ISRC
|
||||||
pub async fn search_recording_by_isrc(&self, isrc: &str) -> IntegrationResult<RecordingSearchResponse> {
|
pub async fn search_recording_by_isrc(
|
||||||
|
&self,
|
||||||
|
isrc: &str,
|
||||||
|
) -> IntegrationResult<RecordingSearchResponse> {
|
||||||
if !self.is_enabled().await {
|
if !self.is_enabled().await {
|
||||||
return Err(IntegrationError::ServiceUnavailable(
|
return Err(IntegrationError::ServiceUnavailable(
|
||||||
"MusicBrainz integration is disabled".into(),
|
"MusicBrainz integration is disabled".into(),
|
||||||
@@ -174,7 +177,11 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search artists by name
|
/// Search artists by name
|
||||||
pub async fn search_artist(&self, name: &str, limit: u32) -> IntegrationResult<ArtistSearchResponse> {
|
pub async fn search_artist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
limit: u32,
|
||||||
|
) -> IntegrationResult<ArtistSearchResponse> {
|
||||||
if !self.is_enabled().await {
|
if !self.is_enabled().await {
|
||||||
return Err(IntegrationError::ServiceUnavailable(
|
return Err(IntegrationError::ServiceUnavailable(
|
||||||
"MusicBrainz integration is disabled".into(),
|
"MusicBrainz integration is disabled".into(),
|
||||||
@@ -220,7 +227,11 @@ impl MusicBrainzClient {
|
|||||||
if let Some(isrc) = isrc {
|
if let Some(isrc) = isrc {
|
||||||
let response = self.search_recording_by_isrc(isrc).await?;
|
let response = self.search_recording_by_isrc(isrc).await?;
|
||||||
if let Some(recording) = response.recordings.first() {
|
if let Some(recording) = response.recordings.first() {
|
||||||
let confidence = if recording.isrcs.as_ref().map_or(false, |isrcs| isrcs.contains(&isrc.to_string())) {
|
let confidence = if recording
|
||||||
|
.isrcs
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |isrcs| isrcs.contains(&isrc.to_string()))
|
||||||
|
{
|
||||||
MatchConfidence::Exact
|
MatchConfidence::Exact
|
||||||
} else {
|
} else {
|
||||||
MatchConfidence::from_score(recording.score)
|
MatchConfidence::from_score(recording.score)
|
||||||
@@ -275,7 +286,11 @@ impl MusicBrainzClient {
|
|||||||
// ============ Extended API Methods ============
|
// ============ Extended API Methods ============
|
||||||
|
|
||||||
/// Search recordings by title and artist
|
/// Search recordings by title and artist
|
||||||
pub async fn search_recording(&self, title: &str, artist: &str) -> IntegrationResult<RecordingSearchResponse> {
|
pub async fn search_recording(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
artist: &str,
|
||||||
|
) -> IntegrationResult<RecordingSearchResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -285,7 +300,11 @@ impl MusicBrainzClient {
|
|||||||
Self::escape_query(title),
|
Self::escape_query(title),
|
||||||
Self::escape_query(artist)
|
Self::escape_query(artist)
|
||||||
);
|
);
|
||||||
let url = format!("{}/recording?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
|
let url = format!(
|
||||||
|
"{}/recording?query={}&fmt=json&limit=5",
|
||||||
|
base,
|
||||||
|
urlencoding::encode(&query)
|
||||||
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
self.check_response(&response).await;
|
self.check_response(&response).await;
|
||||||
@@ -294,7 +313,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get artist details with relationships and tags
|
/// Get artist details with relationships and tags
|
||||||
pub async fn get_artist_with_relations(&self, mbid: &str) -> IntegrationResult<ArtistFullResponse> {
|
pub async fn get_artist_with_relations(
|
||||||
|
&self,
|
||||||
|
mbid: &str,
|
||||||
|
) -> IntegrationResult<ArtistFullResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -330,11 +352,18 @@ impl MusicBrainzClient {
|
|||||||
.filter(|tag| tag.count.unwrap_or(0) > 0)
|
.filter(|tag| tag.count.unwrap_or(0) > 0)
|
||||||
.collect();
|
.collect();
|
||||||
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
|
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
|
||||||
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect())
|
Ok(tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| tag.name.to_lowercase())
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search artists by tag (genre)
|
/// Search artists by tag (genre)
|
||||||
pub async fn search_artists_by_tag(&self, tag: &str, limit: usize) -> IntegrationResult<ArtistSearchResponse> {
|
pub async fn search_artists_by_tag(
|
||||||
|
&self,
|
||||||
|
tag: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> IntegrationResult<ArtistSearchResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -343,7 +372,9 @@ impl MusicBrainzClient {
|
|||||||
let query = format!("tag:\"{}\"", Self::escape_query(tag));
|
let query = format!("tag:\"{}\"", Self::escape_query(tag));
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/artist?query={}&fmt=json&limit={}",
|
"{}/artist?query={}&fmt=json&limit={}",
|
||||||
base, urlencoding::encode(&query), limit
|
base,
|
||||||
|
urlencoding::encode(&query),
|
||||||
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
@@ -373,7 +404,10 @@ impl MusicBrainzClient {
|
|||||||
);
|
);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/artist?query={}&fmt=json&limit={}&offset={}",
|
"{}/artist?query={}&fmt=json&limit={}&offset={}",
|
||||||
base, urlencoding::encode(&query), limit, offset
|
base,
|
||||||
|
urlencoding::encode(&query),
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
@@ -382,12 +416,18 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search releases by barcode (UPC/EAN)
|
/// Search releases by barcode (UPC/EAN)
|
||||||
pub async fn search_release_by_barcode(&self, barcode: &str) -> IntegrationResult<ReleaseSearchResponse> {
|
pub async fn search_release_by_barcode(
|
||||||
|
&self,
|
||||||
|
barcode: &str,
|
||||||
|
) -> IntegrationResult<ReleaseSearchResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
let base = self.base_url().await;
|
let base = self.base_url().await;
|
||||||
let url = format!("{}/release?query=barcode:{}&fmt=json&limit=5", base, barcode);
|
let url = format!(
|
||||||
|
"{}/release?query=barcode:{}&fmt=json&limit=5",
|
||||||
|
base, barcode
|
||||||
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
let response = self.handle_response_status(response).await?;
|
let response = self.handle_response_status(response).await?;
|
||||||
@@ -395,7 +435,11 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search releases by title and artist
|
/// Search releases by title and artist
|
||||||
pub async fn search_release(&self, title: &str, artist: &str) -> IntegrationResult<ReleaseSearchResponse> {
|
pub async fn search_release(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
artist: &str,
|
||||||
|
) -> IntegrationResult<ReleaseSearchResponse> {
|
||||||
self.search_releases_extended(title, artist, None, 5).await
|
self.search_releases_extended(title, artist, None, 5).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +472,9 @@ impl MusicBrainzClient {
|
|||||||
let limit = limit.min(25).max(1);
|
let limit = limit.min(25).max(1);
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/release?query={}&fmt=json&limit={}",
|
"{}/release?query={}&fmt=json&limit={}",
|
||||||
base, urlencoding::encode(&query), limit
|
base,
|
||||||
|
urlencoding::encode(&query),
|
||||||
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
@@ -437,7 +483,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get full release details including tracks
|
/// Get full release details including tracks
|
||||||
pub async fn get_release_with_tracks(&self, release_id: &str) -> IntegrationResult<ReleaseFullResponse> {
|
pub async fn get_release_with_tracks(
|
||||||
|
&self,
|
||||||
|
release_id: &str,
|
||||||
|
) -> IntegrationResult<ReleaseFullResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -475,7 +524,11 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search for an area by name
|
/// Search for an area by name
|
||||||
pub async fn search_area(&self, name: &str, area_type: Option<&str>) -> IntegrationResult<AreaSearchResponse> {
|
pub async fn search_area(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
area_type: Option<&str>,
|
||||||
|
) -> IntegrationResult<AreaSearchResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -490,7 +543,11 @@ impl MusicBrainzClient {
|
|||||||
format!("area:\"{}\"", Self::escape_query(name))
|
format!("area:\"{}\"", Self::escape_query(name))
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/area?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
|
let url = format!(
|
||||||
|
"{}/area?query={}&fmt=json&limit=5",
|
||||||
|
base,
|
||||||
|
urlencoding::encode(&query)
|
||||||
|
);
|
||||||
|
|
||||||
let response = self.client.get(&url).send().await?;
|
let response = self.client.get(&url).send().await?;
|
||||||
let response = self.handle_response_status(response).await?;
|
let response = self.handle_response_status(response).await?;
|
||||||
@@ -498,7 +555,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Look up an area and its parent relationships
|
/// Look up an area and its parent relationships
|
||||||
pub async fn get_area_with_relations(&self, area_id: &str) -> IntegrationResult<AreaDetailResponse> {
|
pub async fn get_area_with_relations(
|
||||||
|
&self,
|
||||||
|
area_id: &str,
|
||||||
|
) -> IntegrationResult<AreaDetailResponse> {
|
||||||
self.check_enabled().await?;
|
self.check_enabled().await?;
|
||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
@@ -511,7 +571,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a city area to its parent subdivision (state/region)
|
/// Resolve a city area to its parent subdivision (state/region)
|
||||||
pub async fn resolve_parent_subdivision(&self, area_id: &str) -> IntegrationResult<Option<(String, String)>> {
|
pub async fn resolve_parent_subdivision(
|
||||||
|
&self,
|
||||||
|
area_id: &str,
|
||||||
|
) -> IntegrationResult<Option<(String, String)>> {
|
||||||
let mut current_id = area_id.to_string();
|
let mut current_id = area_id.to_string();
|
||||||
let mut path: Vec<String> = Vec::new();
|
let mut path: Vec<String> = Vec::new();
|
||||||
let max_hops = 5;
|
let max_hops = 5;
|
||||||
@@ -539,7 +602,10 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let has_country_parent = parents.iter().any(|p| {
|
let has_country_parent = parents.iter().any(|p| {
|
||||||
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("country")).unwrap_or(false)
|
p.area_type
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| t.eq_ignore_ascii_case("country"))
|
||||||
|
.unwrap_or(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
if has_country_parent {
|
if has_country_parent {
|
||||||
@@ -559,7 +625,10 @@ impl MusicBrainzClient {
|
|||||||
let next = parents
|
let next = parents
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| {
|
.find(|p| {
|
||||||
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("subdivision")).unwrap_or(false)
|
p.area_type
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| t.eq_ignore_ascii_case("subdivision"))
|
||||||
|
.unwrap_or(false)
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
parents.iter().find(|p| {
|
parents.iter().find(|p| {
|
||||||
@@ -570,8 +639,12 @@ impl MusicBrainzClient {
|
|||||||
.or_else(|| parents.first());
|
.or_else(|| parents.first());
|
||||||
|
|
||||||
match next {
|
match next {
|
||||||
Some(parent) => { current_id = parent.id.clone(); }
|
Some(parent) => {
|
||||||
None => { return Ok(None); }
|
current_id = parent.id.clone();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +667,10 @@ impl MusicBrainzClient {
|
|||||||
// Placeholder for response logging/metrics
|
// Placeholder for response logging/metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_response_status(&self, response: reqwest::Response) -> IntegrationResult<reqwest::Response> {
|
async fn handle_response_status(
|
||||||
|
&self,
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> IntegrationResult<reqwest::Response> {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
@@ -603,7 +679,10 @@ impl MusicBrainzClient {
|
|||||||
return Err(IntegrationError::RateLimited(60));
|
return Err(IntegrationError::RateLimited(60));
|
||||||
}
|
}
|
||||||
let text = response.text().await.unwrap_or_default();
|
let text = response.text().await.unwrap_or_default();
|
||||||
Err(IntegrationError::internal(format!("MusicBrainz API error {}: {}", status, text)))
|
Err(IntegrationError::internal(format!(
|
||||||
|
"MusicBrainz API error {}: {}",
|
||||||
|
status, text
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape special characters in Lucene queries
|
/// Escape special characters in Lucene queries
|
||||||
|
|||||||
@@ -991,15 +991,21 @@ impl LibraryDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect if this file is a Qobuz purchased download
|
// Detect if this file is a Qobuz purchased download
|
||||||
let is_purchase: bool = self.conn
|
let is_purchase: bool = self
|
||||||
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1",
|
"SELECT COUNT(*) FROM downloaded_purchases WHERE file_path = ?1",
|
||||||
params![track.file_path],
|
params![track.file_path],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(0) > 0;
|
.unwrap_or(0)
|
||||||
|
> 0;
|
||||||
|
|
||||||
let source = if is_purchase { "qobuz_purchase" } else { "user" };
|
let source = if is_purchase {
|
||||||
|
"qobuz_purchase"
|
||||||
|
} else {
|
||||||
|
"user"
|
||||||
|
};
|
||||||
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -1047,7 +1053,10 @@ impl LibraryDatabase {
|
|||||||
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
|
pub fn get_track(&self, id: i64) -> Result<Option<LocalTrack>, LibraryError> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE id = ?", Self::TRACK_COLUMNS))
|
.prepare(&format!(
|
||||||
|
"SELECT {} FROM local_tracks WHERE id = ?",
|
||||||
|
Self::TRACK_COLUMNS
|
||||||
|
))
|
||||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||||
|
|
||||||
stmt.query_row(params![id], |row| Self::row_to_track(row))
|
stmt.query_row(params![id], |row| Self::row_to_track(row))
|
||||||
@@ -1059,7 +1068,10 @@ impl LibraryDatabase {
|
|||||||
pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> {
|
pub fn get_track_by_path(&self, path: &str) -> Result<Option<LocalTrack>, LibraryError> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(&format!("SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL", Self::TRACK_COLUMNS))
|
.prepare(&format!(
|
||||||
|
"SELECT {} FROM local_tracks WHERE file_path = ? AND cue_file_path IS NULL",
|
||||||
|
Self::TRACK_COLUMNS
|
||||||
|
))
|
||||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||||
|
|
||||||
stmt.query_row(params![path], |row| Self::row_to_track(row))
|
stmt.query_row(params![path], |row| Self::row_to_track(row))
|
||||||
@@ -1692,7 +1704,10 @@ impl LibraryDatabase {
|
|||||||
"SELECT {} FROM local_tracks \
|
"SELECT {} FROM local_tracks \
|
||||||
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
|
WHERE (title LIKE ?1 OR artist LIKE ?1 OR album LIKE ?1) \
|
||||||
{} {} {}",
|
{} {} {}",
|
||||||
Self::TRACK_COLUMNS, source_filter, network_filter, limit_clause
|
Self::TRACK_COLUMNS,
|
||||||
|
source_filter,
|
||||||
|
network_filter,
|
||||||
|
limit_clause
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
@@ -1754,8 +1769,7 @@ impl LibraryDatabase {
|
|||||||
|
|
||||||
/// Convert a database row to LocalTrack
|
/// Convert a database row to LocalTrack
|
||||||
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
|
/// Column list for SELECT queries (avoids fragile SELECT * with positional indices)
|
||||||
const TRACK_COLUMNS: &'static str =
|
const TRACK_COLUMNS: &'static str = "id, file_path, title, artist, album, album_artist, \
|
||||||
"id, file_path, title, artist, album, album_artist, \
|
|
||||||
track_number, disc_number, year, genre, duration_secs, format, \
|
track_number, disc_number, year, genre, duration_secs, format, \
|
||||||
bit_depth, sample_rate, channels, file_size_bytes, \
|
bit_depth, sample_rate, channels, file_size_bytes, \
|
||||||
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
|
cue_file_path, cue_start_secs, cue_end_secs, artwork_path, \
|
||||||
@@ -1764,33 +1778,33 @@ impl LibraryDatabase {
|
|||||||
|
|
||||||
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
|
fn row_to_track(row: &rusqlite::Row) -> rusqlite::Result<LocalTrack> {
|
||||||
Ok(LocalTrack {
|
Ok(LocalTrack {
|
||||||
id: row.get(0)?, // id
|
id: row.get(0)?, // id
|
||||||
file_path: row.get(1)?, // file_path
|
file_path: row.get(1)?, // file_path
|
||||||
title: row.get(2)?, // title
|
title: row.get(2)?, // title
|
||||||
artist: row.get(3)?, // artist
|
artist: row.get(3)?, // artist
|
||||||
album: row.get(4)?, // album
|
album: row.get(4)?, // album
|
||||||
album_artist: row.get(5)?, // album_artist
|
album_artist: row.get(5)?, // album_artist
|
||||||
track_number: row.get(6)?, // track_number
|
track_number: row.get(6)?, // track_number
|
||||||
disc_number: row.get(7)?, // disc_number
|
disc_number: row.get(7)?, // disc_number
|
||||||
year: row.get(8)?, // year
|
year: row.get(8)?, // year
|
||||||
genre: row.get(9)?, // genre
|
genre: row.get(9)?, // genre
|
||||||
duration_secs: row.get(10)?, // duration_secs
|
duration_secs: row.get(10)?, // duration_secs
|
||||||
format: Self::parse_format(&row.get::<_, String>(11)?), // format
|
format: Self::parse_format(&row.get::<_, String>(11)?), // format
|
||||||
bit_depth: row.get(12)?, // bit_depth
|
bit_depth: row.get(12)?, // bit_depth
|
||||||
sample_rate: row.get::<_, f64>(13)?, // sample_rate
|
sample_rate: row.get::<_, f64>(13)?, // sample_rate
|
||||||
channels: row.get(14)?, // channels
|
channels: row.get(14)?, // channels
|
||||||
file_size_bytes: row.get(15)?, // file_size_bytes
|
file_size_bytes: row.get(15)?, // file_size_bytes
|
||||||
cue_file_path: row.get(16)?, // cue_file_path
|
cue_file_path: row.get(16)?, // cue_file_path
|
||||||
cue_start_secs: row.get(17)?, // cue_start_secs
|
cue_start_secs: row.get(17)?, // cue_start_secs
|
||||||
cue_end_secs: row.get(18)?, // cue_end_secs
|
cue_end_secs: row.get(18)?, // cue_end_secs
|
||||||
artwork_path: row.get(19)?, // artwork_path
|
artwork_path: row.get(19)?, // artwork_path
|
||||||
last_modified: row.get(20)?, // last_modified
|
last_modified: row.get(20)?, // last_modified
|
||||||
indexed_at: row.get(21)?, // indexed_at
|
indexed_at: row.get(21)?, // indexed_at
|
||||||
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key
|
album_group_key: row.get::<_, Option<String>>(22)?.unwrap_or_default(), // album_group_key
|
||||||
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title
|
album_group_title: row.get::<_, Option<String>>(23)?.unwrap_or_default(), // album_group_title
|
||||||
source: row.get(24).ok().flatten(), // source
|
source: row.get(24).ok().flatten(), // source
|
||||||
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
|
qobuz_track_id: row.get(25).ok().flatten(), // qobuz_track_id
|
||||||
catalog_number: row.get(26).ok().flatten(), // catalog_number
|
catalog_number: row.get(26).ok().flatten(), // catalog_number
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3652,15 +3666,10 @@ impl LibraryDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get custom album cover path for a single album
|
/// Get custom album cover path for a single album
|
||||||
pub fn get_custom_album_cover(
|
pub fn get_custom_album_cover(&self, album_id: &str) -> Result<Option<String>, LibraryError> {
|
||||||
&self,
|
|
||||||
album_id: &str,
|
|
||||||
) -> Result<Option<String>, LibraryError> {
|
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare(
|
.prepare("SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1")
|
||||||
"SELECT custom_image_path FROM custom_album_covers WHERE album_id = ?1",
|
|
||||||
)
|
|
||||||
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
|
.map_err(|e| LibraryError::Database(format!("Failed to prepare query: {}", e)))?;
|
||||||
|
|
||||||
let result = stmt
|
let result = stmt
|
||||||
@@ -3674,10 +3683,7 @@ impl LibraryDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a custom album cover
|
/// Remove a custom album cover
|
||||||
pub fn remove_custom_album_cover(
|
pub fn remove_custom_album_cover(&self, album_id: &str) -> Result<(), LibraryError> {
|
||||||
&self,
|
|
||||||
album_id: &str,
|
|
||||||
) -> Result<(), LibraryError> {
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM custom_album_covers WHERE album_id = ?1",
|
"DELETE FROM custom_album_covers WHERE album_id = ?1",
|
||||||
@@ -3971,14 +3977,10 @@ impl LibraryDatabase {
|
|||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases")
|
.prepare("SELECT track_id, format_id, file_path FROM downloaded_purchases")
|
||||||
.map_err(|e| {
|
.map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
|
||||||
LibraryError::Database(format!("Failed to prepare statement: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let rows: Vec<(i64, i64, String)> = stmt
|
let rows: Vec<(i64, i64, String)> = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
|
||||||
})
|
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
LibraryError::Database(format!("Failed to query downloaded purchases: {}", e))
|
LibraryError::Database(format!("Failed to query downloaded purchases: {}", e))
|
||||||
})?
|
})?
|
||||||
@@ -3998,7 +4000,10 @@ impl LibraryDatabase {
|
|||||||
|
|
||||||
// Remove stale entries where the file no longer exists
|
// Remove stale entries where the file no longer exists
|
||||||
if !stale.is_empty() {
|
if !stale.is_empty() {
|
||||||
log::info!("Removing {} stale downloaded_purchases entries (files deleted)", stale.len());
|
log::info!(
|
||||||
|
"Removing {} stale downloaded_purchases entries (files deleted)",
|
||||||
|
stale.len()
|
||||||
|
);
|
||||||
for (track_id, format_id) in &stale {
|
for (track_id, format_id) in &stale {
|
||||||
let _ = self.conn.execute(
|
let _ = self.conn.execute(
|
||||||
"DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2",
|
"DELETE FROM downloaded_purchases WHERE track_id = ?1 AND format_id = ?2",
|
||||||
@@ -4017,9 +4022,7 @@ impl LibraryDatabase {
|
|||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT track_id, format_id FROM downloaded_purchases")
|
.prepare("SELECT track_id, format_id FROM downloaded_purchases")
|
||||||
.map_err(|e| {
|
.map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
|
||||||
LibraryError::Database(format!("Failed to prepare statement: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
|
.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
|
||||||
|
|||||||
@@ -28,31 +28,28 @@
|
|||||||
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
|
//! let db = LibraryDatabase::open(Path::new("library.db")).unwrap();
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod errors;
|
|
||||||
mod models;
|
|
||||||
mod scanner;
|
|
||||||
mod thumbnails;
|
|
||||||
mod metadata;
|
|
||||||
mod cue_parser;
|
mod cue_parser;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod errors;
|
||||||
|
mod metadata;
|
||||||
|
mod models;
|
||||||
|
mod scanner;
|
||||||
mod tag_sidecar;
|
mod tag_sidecar;
|
||||||
|
mod thumbnails;
|
||||||
|
|
||||||
// Re-exports
|
// Re-exports
|
||||||
|
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTime, CueTrack};
|
||||||
|
pub use database::{
|
||||||
|
AlbumTrackUpdate, LibraryDatabase, LibraryFolder, LibraryStats, LocalContentStatus,
|
||||||
|
PlaylistFolder, PlaylistSettings, PlaylistStats, TrackMetadataUpdateFull,
|
||||||
|
};
|
||||||
pub use errors::LibraryError;
|
pub use errors::LibraryError;
|
||||||
|
pub use metadata::MetadataExtractor;
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use scanner::{LibraryScanner, ScanResult};
|
pub use scanner::{LibraryScanner, ScanResult};
|
||||||
pub use thumbnails::{
|
pub use thumbnails::{
|
||||||
generate_thumbnail, generate_thumbnail_from_bytes,
|
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
|
||||||
get_thumbnail_path, get_thumbnails_dir, thumbnail_exists,
|
get_or_generate_thumbnail, get_thumbnail_path, get_thumbnails_dir, thumbnail_exists,
|
||||||
get_or_generate_thumbnail, clear_thumbnails, get_cache_size,
|
|
||||||
};
|
|
||||||
pub use metadata::MetadataExtractor;
|
|
||||||
pub use cue_parser::{CueParser, CueSheet, CueTrack, CueTime, cue_to_tracks};
|
|
||||||
pub use database::{
|
|
||||||
LibraryDatabase, LibraryFolder, LibraryStats,
|
|
||||||
AlbumTrackUpdate, TrackMetadataUpdateFull,
|
|
||||||
PlaylistFolder, PlaylistSettings, PlaylistStats,
|
|
||||||
LocalContentStatus,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export database module for backwards compatibility
|
// Re-export database module for backwards compatibility
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
|
|
||||||
use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
|
use crate::thumbnails::{generate_thumbnail, generate_thumbnail_from_bytes};
|
||||||
|
use crate::{AudioFormat, AudioProperties, LibraryError, LocalTrack};
|
||||||
|
|
||||||
/// Metadata extractor using lofty
|
/// Metadata extractor using lofty
|
||||||
pub struct MetadataExtractor;
|
pub struct MetadataExtractor;
|
||||||
@@ -108,10 +108,18 @@ impl MetadataExtractor {
|
|||||||
|
|
||||||
// Must contain at least one disc-related token
|
// Must contain at least one disc-related token
|
||||||
let has_disc_token = tokens.iter().any(|token| {
|
let has_disc_token = tokens.iter().any(|token| {
|
||||||
*token == "disc" || *token == "disk" || *token == "cd"
|
*token == "disc"
|
||||||
|| (token.starts_with("disc") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty())
|
|| *token == "disk"
|
||||||
|| (token.starts_with("disk") && token[4..].chars().all(|c| c.is_ascii_digit()) && !token[4..].is_empty())
|
|| *token == "cd"
|
||||||
|| (token.starts_with("cd") && token[2..].chars().all(|c| c.is_ascii_digit()) && !token[2..].is_empty())
|
|| (token.starts_with("disc")
|
||||||
|
&& token[4..].chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& !token[4..].is_empty())
|
||||||
|
|| (token.starts_with("disk")
|
||||||
|
&& token[4..].chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& !token[4..].is_empty())
|
||||||
|
|| (token.starts_with("cd")
|
||||||
|
&& token[2..].chars().all(|c| c.is_ascii_digit())
|
||||||
|
&& !token[2..].is_empty())
|
||||||
});
|
});
|
||||||
|
|
||||||
if !has_disc_token {
|
if !has_disc_token {
|
||||||
@@ -215,7 +223,10 @@ impl MetadataExtractor {
|
|||||||
// Only when leading number is 1-2 digits (disc number) followed by dash+digits
|
// Only when leading number is 1-2 digits (disc number) followed by dash+digits
|
||||||
if digit_end <= 2 && rest.starts_with('-') {
|
if digit_end <= 2 && rest.starts_with('-') {
|
||||||
let after_dash = &rest[1..];
|
let after_dash = &rest[1..];
|
||||||
let track_digits: String = after_dash.chars().take_while(|c| c.is_ascii_digit()).collect();
|
let track_digits: String = after_dash
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_digit())
|
||||||
|
.collect();
|
||||||
if !track_digits.is_empty() {
|
if !track_digits.is_empty() {
|
||||||
if let Ok(n) = track_digits.parse::<u32>() {
|
if let Ok(n) = track_digits.parse::<u32>() {
|
||||||
if n > 0 && n < 10000 {
|
if n > 0 && n < 10000 {
|
||||||
@@ -280,8 +291,18 @@ impl MetadataExtractor {
|
|||||||
if let Some(word) = first_word {
|
if let Some(word) = first_word {
|
||||||
if matches!(
|
if matches!(
|
||||||
word,
|
word,
|
||||||
"flac" | "mp3" | "aac" | "alac" | "wav" | "aiff" | "ogg"
|
"flac"
|
||||||
| "dsd" | "opus" | "wma" | "ape" | "pcm"
|
| "mp3"
|
||||||
|
| "aac"
|
||||||
|
| "alac"
|
||||||
|
| "wav"
|
||||||
|
| "aiff"
|
||||||
|
| "ogg"
|
||||||
|
| "dsd"
|
||||||
|
| "opus"
|
||||||
|
| "wma"
|
||||||
|
| "ape"
|
||||||
|
| "pcm"
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -380,7 +401,9 @@ impl MetadataExtractor {
|
|||||||
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
|
.map_err(|e| LibraryError::Metadata(format!("Failed to read file: {}", e)))?;
|
||||||
|
|
||||||
// Get the primary tag (prefer ID3v2/Vorbis/APE)
|
// Get the primary tag (prefer ID3v2/Vorbis/APE)
|
||||||
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag());
|
let tag = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged_file.first_tag());
|
||||||
|
|
||||||
// Get audio properties
|
// Get audio properties
|
||||||
let properties = tagged_file.properties();
|
let properties = tagged_file.properties();
|
||||||
@@ -424,10 +447,7 @@ impl MetadataExtractor {
|
|||||||
LocalTrack {
|
LocalTrack {
|
||||||
id: 0,
|
id: 0,
|
||||||
file_path: file_path.to_string_lossy().to_string(),
|
file_path: file_path.to_string_lossy().to_string(),
|
||||||
title: tag
|
title: tag.title().map(|s| s.to_string()).unwrap_or(filename),
|
||||||
.title()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or(filename),
|
|
||||||
artist: Self::normalize_field(tag.artist().as_deref())
|
artist: Self::normalize_field(tag.artist().as_deref())
|
||||||
.or_else(|| fallback_artist.clone())
|
.or_else(|| fallback_artist.clone())
|
||||||
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||||
@@ -435,7 +455,9 @@ impl MetadataExtractor {
|
|||||||
album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()),
|
album_artist: tag.get_string(&ItemKey::AlbumArtist).map(|s| s.to_string()),
|
||||||
album_group_key,
|
album_group_key,
|
||||||
album_group_title,
|
album_group_title,
|
||||||
track_number: tag.track().map(|t| t as u32)
|
track_number: tag
|
||||||
|
.track()
|
||||||
|
.map(|t| t as u32)
|
||||||
.or_else(|| Self::infer_track_number_from_filename(file_path)),
|
.or_else(|| Self::infer_track_number_from_filename(file_path)),
|
||||||
disc_number: tag
|
disc_number: tag
|
||||||
.disk()
|
.disk()
|
||||||
@@ -443,7 +465,9 @@ impl MetadataExtractor {
|
|||||||
.or(inferred_disc),
|
.or(inferred_disc),
|
||||||
year: tag.year().map(|y| y as u32),
|
year: tag.year().map(|y| y as u32),
|
||||||
genre: tag.genre().map(|s| s.to_string()),
|
genre: tag.genre().map(|s| s.to_string()),
|
||||||
catalog_number: tag.get_string(&ItemKey::CatalogNumber).map(|s| s.to_string()),
|
catalog_number: tag
|
||||||
|
.get_string(&ItemKey::CatalogNumber)
|
||||||
|
.map(|s| s.to_string()),
|
||||||
duration_secs,
|
duration_secs,
|
||||||
format,
|
format,
|
||||||
bit_depth,
|
bit_depth,
|
||||||
@@ -474,8 +498,7 @@ impl MetadataExtractor {
|
|||||||
id: 0,
|
id: 0,
|
||||||
file_path: file_path.to_string_lossy().to_string(),
|
file_path: file_path.to_string_lossy().to_string(),
|
||||||
title: filename,
|
title: filename,
|
||||||
artist: fallback_artist
|
artist: fallback_artist.unwrap_or_else(|| "Unknown Artist".to_string()),
|
||||||
.unwrap_or_else(|| "Unknown Artist".to_string()),
|
|
||||||
album: album_title,
|
album: album_title,
|
||||||
album_artist: None,
|
album_artist: None,
|
||||||
album_group_key,
|
album_group_key,
|
||||||
@@ -546,7 +569,9 @@ impl MetadataExtractor {
|
|||||||
/// Extract and save artwork as thumbnail to cache directory
|
/// Extract and save artwork as thumbnail to cache directory
|
||||||
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
|
pub fn extract_artwork(file_path: &Path, _cache_dir: &Path) -> Option<String> {
|
||||||
let tagged_file = Probe::open(file_path).ok()?.read().ok()?;
|
let tagged_file = Probe::open(file_path).ok()?.read().ok()?;
|
||||||
let tag = tagged_file.primary_tag().or_else(|| tagged_file.first_tag())?;
|
let tag = tagged_file
|
||||||
|
.primary_tag()
|
||||||
|
.or_else(|| tagged_file.first_tag())?;
|
||||||
|
|
||||||
let picture = tag.pictures().first()?;
|
let picture = tag.pictures().first()?;
|
||||||
|
|
||||||
@@ -645,11 +670,8 @@ impl MetadataExtractor {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut score = Self::artwork_score(
|
let mut score =
|
||||||
&file_key,
|
Self::artwork_score(&file_key, album_key.as_deref(), folder_key.as_deref());
|
||||||
album_key.as_deref(),
|
|
||||||
folder_key.as_deref(),
|
|
||||||
);
|
|
||||||
if score == 0 {
|
if score == 0 {
|
||||||
score = 5;
|
score = 5;
|
||||||
}
|
}
|
||||||
@@ -692,11 +714,7 @@ impl MetadataExtractor {
|
|||||||
matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
|
matches!(ext, "jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn artwork_score(
|
fn artwork_score(file_key: &str, album_key: Option<&str>, folder_key: Option<&str>) -> i32 {
|
||||||
file_key: &str,
|
|
||||||
album_key: Option<&str>,
|
|
||||||
folder_key: Option<&str>,
|
|
||||||
) -> i32 {
|
|
||||||
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
|
const EXACT: &[&str] = &["cover", "folder", "front", "album", "artwork", "art"];
|
||||||
let mut score = 0;
|
let mut score = 0;
|
||||||
|
|
||||||
@@ -752,9 +770,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_is_encoding_folder() {
|
fn test_is_encoding_folder() {
|
||||||
// QBZ-generated quality folder names
|
// QBZ-generated quality folder names
|
||||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 16-bit - 44.1 kHz"));
|
assert!(MetadataExtractor::is_encoding_folder(
|
||||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 96 kHz"));
|
"FLAC 16-bit - 44.1 kHz"
|
||||||
assert!(MetadataExtractor::is_encoding_folder("FLAC 24-bit - 192 kHz"));
|
));
|
||||||
|
assert!(MetadataExtractor::is_encoding_folder(
|
||||||
|
"FLAC 24-bit - 96 kHz"
|
||||||
|
));
|
||||||
|
assert!(MetadataExtractor::is_encoding_folder(
|
||||||
|
"FLAC 24-bit - 192 kHz"
|
||||||
|
));
|
||||||
assert!(MetadataExtractor::is_encoding_folder("MP3 320 kbps"));
|
assert!(MetadataExtractor::is_encoding_folder("MP3 320 kbps"));
|
||||||
|
|
||||||
// Common encoding folder names from other tools
|
// Common encoding folder names from other tools
|
||||||
@@ -792,7 +816,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_album_root_dir_encoding_folder() {
|
fn test_album_root_dir_encoding_folder() {
|
||||||
// artist/album/quality/track.flac -> album/
|
// artist/album/quality/track.flac -> album/
|
||||||
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/01 - Novocaine.flac");
|
let path =
|
||||||
|
Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/01 - Novocaine.flac");
|
||||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||||
}
|
}
|
||||||
@@ -800,7 +825,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_album_root_dir_encoding_and_disc() {
|
fn test_album_root_dir_encoding_and_disc() {
|
||||||
// artist/album/quality/disc1/track.flac -> album/
|
// artist/album/quality/disc1/track.flac -> album/
|
||||||
let path = Path::new("/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/Disc 1/01 - Novocaine.flac");
|
let path = Path::new(
|
||||||
|
"/music/EELS/Beautiful Freak/FLAC 24-bit - 96 kHz/Disc 1/01 - Novocaine.flac",
|
||||||
|
);
|
||||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||||
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
assert_eq!(root, Path::new("/music/EELS/Beautiful Freak"));
|
||||||
}
|
}
|
||||||
@@ -809,16 +836,22 @@ mod tests {
|
|||||||
fn test_infer_track_number_from_filename() {
|
fn test_infer_track_number_from_filename() {
|
||||||
// Common patterns: "01 - Title"
|
// Common patterns: "01 - Title"
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/01 - Novocaine.flac")),
|
MetadataExtractor::infer_track_number_from_filename(Path::new(
|
||||||
|
"/music/01 - Novocaine.flac"
|
||||||
|
)),
|
||||||
Some(1)
|
Some(1)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/12 - Beautiful Freak.flac")),
|
MetadataExtractor::infer_track_number_from_filename(Path::new(
|
||||||
|
"/music/12 - Beautiful Freak.flac"
|
||||||
|
)),
|
||||||
Some(12)
|
Some(12)
|
||||||
);
|
);
|
||||||
// "01. Title"
|
// "01. Title"
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/03. Song Name.flac")),
|
MetadataExtractor::infer_track_number_from_filename(Path::new(
|
||||||
|
"/music/03. Song Name.flac"
|
||||||
|
)),
|
||||||
Some(3)
|
Some(3)
|
||||||
);
|
);
|
||||||
// "01_Title"
|
// "01_Title"
|
||||||
@@ -848,7 +881,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Zero is not a valid track number
|
// Zero is not a valid track number
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MetadataExtractor::infer_track_number_from_filename(Path::new("/music/00 - Intro.flac")),
|
MetadataExtractor::infer_track_number_from_filename(Path::new(
|
||||||
|
"/music/00 - Intro.flac"
|
||||||
|
)),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -873,11 +908,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_is_disc_folder_false_album_names() {
|
fn test_is_disc_folder_false_album_names() {
|
||||||
// Album names containing disc/cd keywords — NOT disc folders (issue #147)
|
// Album names containing disc/cd keywords — NOT disc folders (issue #147)
|
||||||
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics, Disc 1"));
|
assert!(!MetadataExtractor::is_disc_folder(
|
||||||
assert!(!MetadataExtractor::is_disc_folder("100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1"));
|
"100 Popular Classics, Disc 1"
|
||||||
|
));
|
||||||
|
assert!(!MetadataExtractor::is_disc_folder(
|
||||||
|
"100 Popular Classics_ Best Loved Works of the Great Composers, Disc 1"
|
||||||
|
));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("Relaxation Disc1"));
|
assert!(!MetadataExtractor::is_disc_folder("Relaxation Disc1"));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1"));
|
assert!(!MetadataExtractor::is_disc_folder("Now 75 - CD1"));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("Match of the Day - The Album CD1"));
|
assert!(!MetadataExtractor::is_disc_folder(
|
||||||
|
"Match of the Day - The Album CD1"
|
||||||
|
));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("20 Blues Greats"));
|
assert!(!MetadataExtractor::is_disc_folder("20 Blues Greats"));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("The Beatles"));
|
assert!(!MetadataExtractor::is_disc_folder("The Beatles"));
|
||||||
assert!(!MetadataExtractor::is_disc_folder("Abbey Road"));
|
assert!(!MetadataExtractor::is_disc_folder("Abbey Road"));
|
||||||
@@ -888,7 +929,10 @@ mod tests {
|
|||||||
// Issue #147: album names containing "Disc N" should NOT be treated as disc folders
|
// Issue #147: album names containing "Disc N" should NOT be treated as disc folders
|
||||||
let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac");
|
let path = Path::new("/music/Various Artists/100 Popular Classics, Disc 1/01 - Track.flac");
|
||||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||||
assert_eq!(root, Path::new("/music/Various Artists/100 Popular Classics, Disc 1"));
|
assert_eq!(
|
||||||
|
root,
|
||||||
|
Path::new("/music/Various Artists/100 Popular Classics, Disc 1")
|
||||||
|
);
|
||||||
|
|
||||||
let path = Path::new("/music/Various Artists/Relaxation Disc1/01 - Track.flac");
|
let path = Path::new("/music/Various Artists/Relaxation Disc1/01 - Track.flac");
|
||||||
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
let root = MetadataExtractor::album_root_dir(path).unwrap();
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub struct LocalTrack {
|
|||||||
pub duration_secs: u64,
|
pub duration_secs: u64,
|
||||||
pub format: AudioFormat,
|
pub format: AudioFormat,
|
||||||
pub bit_depth: Option<u32>,
|
pub bit_depth: Option<u32>,
|
||||||
pub sample_rate: f64, // Changed from u32 to f64 to support fractional rates (44.1kHz = 44100Hz)
|
pub sample_rate: f64, // Changed from u32 to f64 to support fractional rates (44.1kHz = 44100Hz)
|
||||||
pub channels: u8,
|
pub channels: u8,
|
||||||
pub file_size_bytes: u64,
|
pub file_size_bytes: u64,
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ impl Default for LocalTrack {
|
|||||||
duration_secs: 0,
|
duration_secs: 0,
|
||||||
format: AudioFormat::Unknown,
|
format: AudioFormat::Unknown,
|
||||||
bit_depth: None,
|
bit_depth: None,
|
||||||
sample_rate: 44100.0, // Now f64
|
sample_rate: 44100.0, // Now f64
|
||||||
channels: 2,
|
channels: 2,
|
||||||
file_size_bytes: 0,
|
file_size_bytes: 0,
|
||||||
cue_file_path: None,
|
cue_file_path: None,
|
||||||
@@ -138,7 +138,7 @@ pub struct LocalAlbum {
|
|||||||
pub total_duration_secs: u64,
|
pub total_duration_secs: u64,
|
||||||
pub format: AudioFormat,
|
pub format: AudioFormat,
|
||||||
pub bit_depth: Option<u32>,
|
pub bit_depth: Option<u32>,
|
||||||
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
|
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
|
||||||
pub directory_path: String,
|
pub directory_path: String,
|
||||||
/// Source of the album: "user" for local files, "qobuz_download" for offline cached
|
/// Source of the album: "user" for local files, "qobuz_download" for offline cached
|
||||||
#[serde(default = "default_source")]
|
#[serde(default = "default_source")]
|
||||||
@@ -201,7 +201,7 @@ pub struct ScanError {
|
|||||||
pub struct AudioProperties {
|
pub struct AudioProperties {
|
||||||
pub duration_secs: u64,
|
pub duration_secs: u64,
|
||||||
pub bit_depth: Option<u32>,
|
pub bit_depth: Option<u32>,
|
||||||
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
|
pub sample_rate: f64, // Changed from u32 to f64 for decimal precision
|
||||||
pub channels: u8,
|
pub channels: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, L
|
|||||||
Ok(Some(sidecar))
|
Ok(Some(sidecar))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn write_album_sidecar(album_dir: &Path, sidecar: &AlbumTagSidecar) -> Result<(), LibraryError> {
|
pub fn write_album_sidecar(
|
||||||
|
album_dir: &Path,
|
||||||
|
sidecar: &AlbumTagSidecar,
|
||||||
|
) -> Result<(), LibraryError> {
|
||||||
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
|
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
|
||||||
|
|
||||||
let target = sidecar_path(album_dir);
|
let target = sidecar_path(album_dir);
|
||||||
@@ -96,12 +99,22 @@ pub fn delete_album_sidecar(album_dir: &Path) -> Result<(), LibraryError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
|
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
|
||||||
if let Some(title) = sidecar.album.album_title.as_ref().and_then(|s| normalize(s)) {
|
if let Some(title) = sidecar
|
||||||
|
.album
|
||||||
|
.album_title
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| normalize(s))
|
||||||
|
{
|
||||||
track.album = title.clone();
|
track.album = title.clone();
|
||||||
track.album_group_title = title.clone();
|
track.album_group_title = title.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(album_artist) = sidecar.album.album_artist.as_ref().and_then(|s| normalize(s)) {
|
if let Some(album_artist) = sidecar
|
||||||
|
.album
|
||||||
|
.album_artist
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| normalize(s))
|
||||||
|
{
|
||||||
track.album_artist = Some(album_artist.clone());
|
track.album_artist = Some(album_artist.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ pub fn get_thumbnails_dir() -> Result<PathBuf, LibraryError> {
|
|||||||
|
|
||||||
// Create directory if it doesn't exist
|
// Create directory if it doesn't exist
|
||||||
if !thumbnails_dir.exists() {
|
if !thumbnails_dir.exists() {
|
||||||
fs::create_dir_all(&thumbnails_dir)
|
fs::create_dir_all(&thumbnails_dir).map_err(|e| {
|
||||||
.map_err(|e| LibraryError::Other(format!("Failed to create thumbnails directory: {}", e)))?;
|
LibraryError::Other(format!("Failed to create thumbnails directory: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(thumbnails_dir)
|
Ok(thumbnails_dir)
|
||||||
@@ -135,8 +136,9 @@ pub fn clear_thumbnails() -> Result<(), LibraryError> {
|
|||||||
if thumbnails_dir.exists() {
|
if thumbnails_dir.exists() {
|
||||||
fs::remove_dir_all(&thumbnails_dir)
|
fs::remove_dir_all(&thumbnails_dir)
|
||||||
.map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?;
|
.map_err(|e| LibraryError::Other(format!("Failed to clear thumbnails: {}", e)))?;
|
||||||
fs::create_dir_all(&thumbnails_dir)
|
fs::create_dir_all(&thumbnails_dir).map_err(|e| {
|
||||||
.map_err(|e| LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e)))?;
|
LibraryError::Other(format!("Failed to recreate thumbnails directory: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ pub enum CoreEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Playback state changed (play/pause/stop)
|
/// Playback state changed (play/pause/stop)
|
||||||
PlaybackStateChanged {
|
PlaybackStateChanged { state: PlaybackState },
|
||||||
state: PlaybackState,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Playback position updated (periodic, e.g., every second)
|
/// Playback position updated (periodic, e.g., every second)
|
||||||
PositionUpdated {
|
PositionUpdated {
|
||||||
@@ -31,41 +29,27 @@ pub enum CoreEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Track finished playing naturally
|
/// Track finished playing naturally
|
||||||
TrackEnded {
|
TrackEnded { track_id: u64 },
|
||||||
track_id: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Volume changed
|
/// Volume changed
|
||||||
VolumeChanged {
|
VolumeChanged { volume: f32 },
|
||||||
volume: f32,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Full playback status update
|
/// Full playback status update
|
||||||
PlaybackStatusUpdated {
|
PlaybackStatusUpdated { status: PlaybackStatus },
|
||||||
status: PlaybackStatus,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Queue Events ============
|
// ============ Queue Events ============
|
||||||
/// Queue state changed (tracks added/removed/reordered)
|
/// Queue state changed (tracks added/removed/reordered)
|
||||||
QueueUpdated {
|
QueueUpdated { state: QueueState },
|
||||||
state: QueueState,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Shuffle mode changed
|
/// Shuffle mode changed
|
||||||
ShuffleChanged {
|
ShuffleChanged { enabled: bool },
|
||||||
enabled: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Repeat mode changed
|
/// Repeat mode changed
|
||||||
RepeatModeChanged {
|
RepeatModeChanged { mode: crate::playback::RepeatMode },
|
||||||
mode: crate::playback::RepeatMode,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Authentication Events ============
|
// ============ Authentication Events ============
|
||||||
/// User logged in successfully
|
/// User logged in successfully
|
||||||
LoggedIn {
|
LoggedIn { session: UserSession },
|
||||||
session: UserSession,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// User logged out
|
/// User logged out
|
||||||
LoggedOut,
|
LoggedOut,
|
||||||
@@ -81,41 +65,26 @@ pub enum CoreEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Playlist created
|
/// Playlist created
|
||||||
PlaylistCreated {
|
PlaylistCreated { playlist: Playlist },
|
||||||
playlist: Playlist,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Playlist updated (tracks added/removed)
|
/// Playlist updated (tracks added/removed)
|
||||||
PlaylistUpdated {
|
PlaylistUpdated { playlist_id: u64 },
|
||||||
playlist_id: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Playlist deleted
|
/// Playlist deleted
|
||||||
PlaylistDeleted {
|
PlaylistDeleted { playlist_id: u64 },
|
||||||
playlist_id: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Loading/Progress Events ============
|
// ============ Loading/Progress Events ============
|
||||||
/// Loading started for an operation
|
/// Loading started for an operation
|
||||||
LoadingStarted {
|
LoadingStarted { operation: String },
|
||||||
operation: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Loading completed
|
/// Loading completed
|
||||||
LoadingCompleted {
|
LoadingCompleted { operation: String },
|
||||||
operation: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Download progress for offline content
|
/// Download progress for offline content
|
||||||
DownloadProgress {
|
DownloadProgress { track_id: u64, progress_percent: u8 },
|
||||||
track_id: u64,
|
|
||||||
progress_percent: u8,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Download completed
|
/// Download completed
|
||||||
DownloadCompleted {
|
DownloadCompleted { track_id: u64 },
|
||||||
track_id: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Error Events ============
|
// ============ Error Events ============
|
||||||
/// An error occurred
|
/// An error occurred
|
||||||
@@ -127,21 +96,14 @@ pub enum CoreEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Playback error (track couldn't play)
|
/// Playback error (track couldn't play)
|
||||||
PlaybackError {
|
PlaybackError { track_id: u64, message: String },
|
||||||
track_id: u64,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Network error
|
/// Network error
|
||||||
NetworkError {
|
NetworkError { message: String },
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Audio System Events ============
|
// ============ Audio System Events ============
|
||||||
/// Audio device changed
|
/// Audio device changed
|
||||||
AudioDeviceChanged {
|
AudioDeviceChanged { device_name: String },
|
||||||
device_name: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Audio backend changed
|
/// Audio backend changed
|
||||||
AudioBackendChanged {
|
AudioBackendChanged {
|
||||||
@@ -150,9 +112,7 @@ pub enum CoreEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Audio system diagnostic info
|
/// Audio system diagnostic info
|
||||||
AudioDiagnostic {
|
AudioDiagnostic { message: String },
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Search Events ============
|
// ============ Search Events ============
|
||||||
/// Search results received
|
/// Search results received
|
||||||
@@ -163,17 +123,11 @@ pub enum CoreEvent {
|
|||||||
|
|
||||||
// ============ Navigation Hints ============
|
// ============ Navigation Hints ============
|
||||||
/// Suggest navigation to album
|
/// Suggest navigation to album
|
||||||
NavigateToAlbum {
|
NavigateToAlbum { album_id: String },
|
||||||
album_id: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Suggest navigation to artist
|
/// Suggest navigation to artist
|
||||||
NavigateToArtist {
|
NavigateToArtist { artist_id: u64 },
|
||||||
artist_id: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Suggest navigation to playlist
|
/// Suggest navigation to playlist
|
||||||
NavigateToPlaylist {
|
NavigateToPlaylist { playlist_id: u64 },
|
||||||
playlist_id: u64,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
@@ -41,21 +41,72 @@ pub use events::CoreEvent;
|
|||||||
pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode};
|
pub use playback::{PlaybackState, PlaybackStatus, QueueState, QueueTrack, RepeatMode};
|
||||||
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
|
pub use traits::{FrontendAdapter, LoggingAdapter, NoOpAdapter};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
Album, AlbumSummary, Artist, ArtistAlbums, ArtistBiography, Favorites, Genre, GenreInfo,
|
Album,
|
||||||
GenreListContainer, GenreListResponse, ImageSet, Label, LabelDetail,
|
AlbumSummary,
|
||||||
LabelExploreResponse, LabelPageContainer, LabelPageData, LabelPageGenericList, Playlist,
|
Artist,
|
||||||
PlaylistDuplicateResult, PlaylistGenre, PlaylistOwner, PlaylistWithTrackIds, Quality,
|
ArtistAlbums,
|
||||||
SearchResults, SearchResultsPage, StreamRestriction, StreamUrl, Track, TracksContainer,
|
ArtistBiography,
|
||||||
UserSession,
|
|
||||||
// Discover types
|
// Discover types
|
||||||
DiscoverAlbum, DiscoverAlbumDates, DiscoverAlbumImage, DiscoverArtist, DiscoverAudioInfo,
|
DiscoverAlbum,
|
||||||
DiscoverContainer, DiscoverContainers, DiscoverData, DiscoverPlaylist, DiscoverPlaylistImage,
|
DiscoverAlbumDates,
|
||||||
DiscoverPlaylistsResponse, DiscoverResponse, PlaylistTag, PlaylistTagsResponse, RawPlaylistTag,
|
DiscoverAlbumImage,
|
||||||
|
DiscoverArtist,
|
||||||
|
DiscoverAudioInfo,
|
||||||
|
DiscoverContainer,
|
||||||
|
DiscoverContainers,
|
||||||
|
DiscoverData,
|
||||||
|
DiscoverPlaylist,
|
||||||
|
DiscoverPlaylistImage,
|
||||||
|
DiscoverPlaylistsResponse,
|
||||||
|
DiscoverResponse,
|
||||||
|
Favorites,
|
||||||
|
Genre,
|
||||||
|
GenreInfo,
|
||||||
|
GenreListContainer,
|
||||||
|
GenreListResponse,
|
||||||
|
ImageSet,
|
||||||
|
Label,
|
||||||
|
LabelDetail,
|
||||||
|
LabelExploreResponse,
|
||||||
|
LabelPageContainer,
|
||||||
|
LabelPageData,
|
||||||
|
LabelPageGenericList,
|
||||||
// Artist page types
|
// Artist page types
|
||||||
PageArtistAward, PageArtistBiography, PageArtistImages, PageArtistName,
|
PageArtistAward,
|
||||||
PageArtistPhysicalSupport, PageArtistPlaylist, PageArtistPlaylistImages,
|
PageArtistBiography,
|
||||||
PageArtistPlaylistOwner, PageArtistPlaylists, PageArtistPortrait, PageArtistRelease,
|
PageArtistImages,
|
||||||
PageArtistReleaseArtist, PageArtistReleaseContributor, PageArtistReleaseGroup,
|
PageArtistName,
|
||||||
PageArtistResponse, PageArtistRights, PageArtistSimilar, PageArtistSimilarItem,
|
PageArtistPhysicalSupport,
|
||||||
PageArtistTrack, PageArtistTrackAlbum, ReleasesGridResponse,
|
PageArtistPlaylist,
|
||||||
|
PageArtistPlaylistImages,
|
||||||
|
PageArtistPlaylistOwner,
|
||||||
|
PageArtistPlaylists,
|
||||||
|
PageArtistPortrait,
|
||||||
|
PageArtistRelease,
|
||||||
|
PageArtistReleaseArtist,
|
||||||
|
PageArtistReleaseContributor,
|
||||||
|
PageArtistReleaseGroup,
|
||||||
|
PageArtistResponse,
|
||||||
|
PageArtistRights,
|
||||||
|
PageArtistSimilar,
|
||||||
|
PageArtistSimilarItem,
|
||||||
|
PageArtistTrack,
|
||||||
|
PageArtistTrackAlbum,
|
||||||
|
Playlist,
|
||||||
|
PlaylistDuplicateResult,
|
||||||
|
PlaylistGenre,
|
||||||
|
PlaylistOwner,
|
||||||
|
PlaylistTag,
|
||||||
|
PlaylistTagsResponse,
|
||||||
|
PlaylistWithTrackIds,
|
||||||
|
Quality,
|
||||||
|
RawPlaylistTag,
|
||||||
|
ReleasesGridResponse,
|
||||||
|
SearchResults,
|
||||||
|
SearchResultsPage,
|
||||||
|
StreamRestriction,
|
||||||
|
StreamUrl,
|
||||||
|
Track,
|
||||||
|
TracksContainer,
|
||||||
|
UserSession,
|
||||||
};
|
};
|
||||||
@@ -31,18 +31,12 @@
|
|||||||
//! let queue = QueueManager::new();
|
//! let queue = QueueManager::new();
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod queue;
|
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod queue;
|
||||||
|
|
||||||
// Re-export main types
|
// Re-export main types
|
||||||
pub use queue::QueueManager;
|
|
||||||
pub use player::{
|
pub use player::{
|
||||||
Player,
|
BufferWriter, BufferedMediaSource, IncrementalStreamingSource, PlaybackEvent, PlaybackState,
|
||||||
SharedState,
|
Player, SharedState, StreamingConfig,
|
||||||
PlaybackState,
|
|
||||||
PlaybackEvent,
|
|
||||||
BufferedMediaSource,
|
|
||||||
BufferWriter,
|
|
||||||
StreamingConfig,
|
|
||||||
IncrementalStreamingSource,
|
|
||||||
};
|
};
|
||||||
|
pub use queue::QueueManager;
|
||||||
+1306
-957
File diff suppressed because it is too large.
Load diff
@@ -67,9 +67,7 @@ impl SourceQueue {
|
|||||||
/// Unified playback engine
|
/// Unified playback engine
|
||||||
pub enum PlaybackEngine {
|
pub enum PlaybackEngine {
|
||||||
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
|
/// Rodio-based (PipeWire, Pulse, ALSA via CPAL)
|
||||||
Rodio {
|
Rodio { sink: RodioPlayer },
|
||||||
sink: RodioPlayer,
|
|
||||||
},
|
|
||||||
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
|
/// Direct ALSA (hw: devices, bit-perfect) with gapless source queue
|
||||||
AlsaDirect {
|
AlsaDirect {
|
||||||
stream: Arc<AlsaDirectStream>,
|
stream: Arc<AlsaDirectStream>,
|
||||||
@@ -115,8 +113,14 @@ impl PlaybackEngine {
|
|||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
alsa_writer_thread(
|
alsa_writer_thread(
|
||||||
stream_c, playing_c, stop_c, pos_c, dur_c,
|
stream_c,
|
||||||
queue_c, transition_c, channels,
|
playing_c,
|
||||||
|
stop_c,
|
||||||
|
pos_c,
|
||||||
|
dur_c,
|
||||||
|
queue_c,
|
||||||
|
transition_c,
|
||||||
|
channels,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -154,8 +158,7 @@ impl PlaybackEngine {
|
|||||||
source_transition,
|
source_transition,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let is_first = source_queue.is_empty()
|
let is_first = source_queue.is_empty() && !is_playing.load(Ordering::SeqCst);
|
||||||
&& !is_playing.load(Ordering::SeqCst);
|
|
||||||
|
|
||||||
// Box the source iterator and push to queue
|
// Box the source iterator and push to queue
|
||||||
let boxed: BoxedSampleIter = Box::new(source.into_iter());
|
let boxed: BoxedSampleIter = Box::new(source.into_iter());
|
||||||
@@ -241,7 +244,11 @@ impl PlaybackEngine {
|
|||||||
pub fn set_volume(&self, volume: f32) {
|
pub fn set_volume(&self, volume: f32) {
|
||||||
match self {
|
match self {
|
||||||
Self::Rodio { sink } => sink.set_volume(volume),
|
Self::Rodio { sink } => sink.set_volume(volume),
|
||||||
Self::AlsaDirect { stream, hardware_volume, .. } => {
|
Self::AlsaDirect {
|
||||||
|
stream,
|
||||||
|
hardware_volume,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
if *hardware_volume {
|
if *hardware_volume {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
@@ -250,7 +257,9 @@ impl PlaybackEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::debug!("[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)");
|
log::debug!(
|
||||||
|
"[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,9 +273,7 @@ impl PlaybackEngine {
|
|||||||
is_playing,
|
is_playing,
|
||||||
source_queue,
|
source_queue,
|
||||||
..
|
..
|
||||||
} => {
|
} => !is_playing.load(Ordering::SeqCst) && source_queue.is_empty(),
|
||||||
!is_playing.load(Ordering::SeqCst) && source_queue.is_empty()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +282,11 @@ impl PlaybackEngine {
|
|||||||
pub fn take_source_transition(&self) -> bool {
|
pub fn take_source_transition(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Rodio { .. } => false,
|
Self::Rodio { .. } => false,
|
||||||
Self::AlsaDirect { source_transition, .. } => {
|
Self::AlsaDirect {
|
||||||
source_transition.compare_exchange(
|
source_transition, ..
|
||||||
true, false, Ordering::SeqCst, Ordering::SeqCst
|
} => source_transition
|
||||||
).is_ok()
|
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
}
|
.is_ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +295,11 @@ impl PlaybackEngine {
|
|||||||
pub fn position_secs(&self) -> Option<u64> {
|
pub fn position_secs(&self) -> Option<u64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Rodio { .. } => None,
|
Self::Rodio { .. } => None,
|
||||||
Self::AlsaDirect { position_frames, stream, .. } => {
|
Self::AlsaDirect {
|
||||||
|
position_frames,
|
||||||
|
stream,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let frames = position_frames.load(Ordering::SeqCst);
|
let frames = position_frames.load(Ordering::SeqCst);
|
||||||
let sample_rate = stream.sample_rate() as u64;
|
let sample_rate = stream.sample_rate() as u64;
|
||||||
Some(frames / sample_rate)
|
Some(frames / sample_rate)
|
||||||
@@ -301,7 +312,11 @@ impl PlaybackEngine {
|
|||||||
pub fn duration_secs(&self) -> Option<u64> {
|
pub fn duration_secs(&self) -> Option<u64> {
|
||||||
match self {
|
match self {
|
||||||
Self::Rodio { .. } => None,
|
Self::Rodio { .. } => None,
|
||||||
Self::AlsaDirect { duration_frames, stream, .. } => {
|
Self::AlsaDirect {
|
||||||
|
duration_frames,
|
||||||
|
stream,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
let frames = duration_frames.load(Ordering::SeqCst);
|
let frames = duration_frames.load(Ordering::SeqCst);
|
||||||
let sample_rate = stream.sample_rate() as u64;
|
let sample_rate = stream.sample_rate() as u64;
|
||||||
Some(frames / sample_rate)
|
Some(frames / sample_rate)
|
||||||
|
|||||||
@@ -185,17 +185,17 @@ impl BufferedMediaSource {
|
|||||||
/// Returns error if download fails before initial buffer is filled.
|
/// Returns error if download fails before initial buffer is filled.
|
||||||
pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
|
pub fn wait_for_initial_buffer(&self) -> IoResult<()> {
|
||||||
let (lock, cvar) = &*self.state;
|
let (lock, cvar) = &*self.state;
|
||||||
let mut state = lock.lock().map_err(|_| {
|
let mut state = lock
|
||||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
.lock()
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||||
|
|
||||||
while state.data.len() < self.config.initial_buffer_bytes
|
while state.data.len() < self.config.initial_buffer_bytes
|
||||||
&& !state.download_complete
|
&& !state.download_complete
|
||||||
&& state.download_error.is_none()
|
&& state.download_error.is_none()
|
||||||
{
|
{
|
||||||
state = cvar.wait(state).map_err(|_| {
|
state = cvar
|
||||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
.wait(state)
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref err) = state.download_error {
|
if let Some(ref err) = state.download_error {
|
||||||
@@ -297,9 +297,9 @@ impl Read for BufferedMediaSource {
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let (lock, cvar) = &*self.state;
|
let (lock, cvar) = &*self.state;
|
||||||
let mut state = lock.lock().map_err(|_| {
|
let mut state = lock
|
||||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
.lock()
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||||
|
|
||||||
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
|
let read_pos = self.read_pos.load(Ordering::SeqCst) as usize;
|
||||||
|
|
||||||
@@ -308,9 +308,9 @@ impl Read for BufferedMediaSource {
|
|||||||
&& !state.download_complete
|
&& !state.download_complete
|
||||||
&& state.download_error.is_none()
|
&& state.download_error.is_none()
|
||||||
{
|
{
|
||||||
state = cvar.wait(state).map_err(|_| {
|
state = cvar
|
||||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
.wait(state)
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
@@ -327,7 +327,8 @@ impl Read for BufferedMediaSource {
|
|||||||
let available = state.data.len() - read_pos;
|
let available = state.data.len() - read_pos;
|
||||||
let to_read = buf.len().min(available);
|
let to_read = buf.len().min(available);
|
||||||
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
buf[..to_read].copy_from_slice(&state.data[read_pos..read_pos + to_read]);
|
||||||
self.read_pos.store((read_pos + to_read) as u64, Ordering::SeqCst);
|
self.read_pos
|
||||||
|
.store((read_pos + to_read) as u64, Ordering::SeqCst);
|
||||||
|
|
||||||
Ok(to_read)
|
Ok(to_read)
|
||||||
}
|
}
|
||||||
@@ -338,9 +339,9 @@ impl Seek for BufferedMediaSource {
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
let (lock, cvar) = &*self.state;
|
let (lock, cvar) = &*self.state;
|
||||||
let mut state = lock.lock().map_err(|_| {
|
let mut state = lock
|
||||||
IoError::new(ErrorKind::Other, "Failed to acquire buffer lock")
|
.lock()
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Failed to acquire buffer lock"))?;
|
||||||
|
|
||||||
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
|
let current_pos = self.read_pos.load(Ordering::SeqCst) as i64;
|
||||||
|
|
||||||
@@ -377,9 +378,9 @@ impl Seek for BufferedMediaSource {
|
|||||||
&& !state.download_complete
|
&& !state.download_complete
|
||||||
&& state.download_error.is_none()
|
&& state.download_error.is_none()
|
||||||
{
|
{
|
||||||
state = cvar.wait(state).map_err(|_| {
|
state = cvar
|
||||||
IoError::new(ErrorKind::Other, "Condition variable wait failed")
|
.wait(state)
|
||||||
})?;
|
.map_err(|_| IoError::new(ErrorKind::Other, "Condition variable wait failed"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref err) = state.download_error {
|
if let Some(ref err) = state.download_error {
|
||||||
@@ -515,9 +516,7 @@ impl IncrementalStreamingSource {
|
|||||||
/// The BufferedMediaSource should already have its initial buffer filled.
|
/// The BufferedMediaSource should already have its initial buffer filled.
|
||||||
///
|
///
|
||||||
/// Returns the source along with detected sample_rate and channels.
|
/// Returns the source along with detected sample_rate and channels.
|
||||||
pub fn new(
|
pub fn new(buffered_source: Arc<BufferedMediaSource>) -> Result<Self, String> {
|
||||||
buffered_source: Arc<BufferedMediaSource>,
|
|
||||||
) -> Result<Self, String> {
|
|
||||||
// Create a reader from the buffered source
|
// Create a reader from the buffered source
|
||||||
let reader = buffered_source.create_reader();
|
let reader = buffered_source.create_reader();
|
||||||
let media_source = Box::new(reader) as Box<dyn MediaSource>;
|
let media_source = Box::new(reader) as Box<dyn MediaSource>;
|
||||||
@@ -548,10 +547,7 @@ impl IncrementalStreamingSource {
|
|||||||
let sample_rate = codec_params
|
let sample_rate = codec_params
|
||||||
.sample_rate
|
.sample_rate
|
||||||
.ok_or_else(|| "No sample rate in codec params".to_string())?;
|
.ok_or_else(|| "No sample rate in codec params".to_string())?;
|
||||||
let channels = codec_params
|
let channels = codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
|
||||||
.channels
|
|
||||||
.map(|c| c.count() as u16)
|
|
||||||
.unwrap_or(2);
|
|
||||||
|
|
||||||
let decoder = get_codecs()
|
let decoder = get_codecs()
|
||||||
.make(&codec_params, &DecoderOptions::default())
|
.make(&codec_params, &DecoderOptions::default())
|
||||||
@@ -603,7 +599,9 @@ impl IncrementalStreamingSource {
|
|||||||
while self.sample_queue.len() < min_samples {
|
while self.sample_queue.len() < min_samples {
|
||||||
let packet = match self.format.next_packet() {
|
let packet = match self.format.next_packet() {
|
||||||
Ok(packet) => packet,
|
Ok(packet) => packet,
|
||||||
Err(SymphoniaError::IoError(ref e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
Err(SymphoniaError::IoError(ref e))
|
||||||
|
if e.kind() == std::io::ErrorKind::WouldBlock =>
|
||||||
|
{
|
||||||
// Not enough data buffered yet - wait briefly and retry
|
// Not enough data buffered yet - wait briefly and retry
|
||||||
// This happens when playback catches up with download
|
// This happens when playback catches up with download
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
@@ -636,7 +634,8 @@ impl IncrementalStreamingSource {
|
|||||||
sample_buf.copy_interleaved_ref(audio_buf);
|
sample_buf.copy_interleaved_ref(audio_buf);
|
||||||
|
|
||||||
// Add samples to queue
|
// Add samples to queue
|
||||||
self.sample_queue.extend(sample_buf.samples().iter().copied());
|
self.sample_queue
|
||||||
|
.extend(sample_buf.samples().iter().copied());
|
||||||
self.packets_decoded += 1;
|
self.packets_decoded += 1;
|
||||||
}
|
}
|
||||||
Err(SymphoniaError::DecodeError(e)) => {
|
Err(SymphoniaError::DecodeError(e)) => {
|
||||||
|
|||||||
@@ -271,7 +271,10 @@ impl QueueManager {
|
|||||||
// In shuffle mode, DnD indices come from the visible upcoming list,
|
// In shuffle mode, DnD indices come from the visible upcoming list,
|
||||||
// so they must be applied to shuffle_order positions (not absolute
|
// so they must be applied to shuffle_order positions (not absolute
|
||||||
// track indices in state.tracks).
|
// track indices in state.tracks).
|
||||||
let base_pos = state.current_index.map(|_| state.shuffle_position + 1).unwrap_or(0);
|
let base_pos = state
|
||||||
|
.current_index
|
||||||
|
.map(|_| state.shuffle_position + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
let from_pos = base_pos + from_index;
|
let from_pos = base_pos + from_index;
|
||||||
let to_pos = base_pos + to_index;
|
let to_pos = base_pos + to_index;
|
||||||
|
|
||||||
@@ -717,12 +720,18 @@ impl QueueManager {
|
|||||||
|
|
||||||
/// Remove one absolute track index from shuffle order and rebase remaining indices.
|
/// Remove one absolute track index from shuffle order and rebase remaining indices.
|
||||||
fn remove_index_from_shuffle_internal(state: &mut InternalState, removed_idx: usize) {
|
fn remove_index_from_shuffle_internal(state: &mut InternalState, removed_idx: usize) {
|
||||||
if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == removed_idx) {
|
if let Some(pos) = state
|
||||||
|
.shuffle_order
|
||||||
|
.iter()
|
||||||
|
.position(|&idx| idx == removed_idx)
|
||||||
|
{
|
||||||
state.shuffle_order.remove(pos);
|
state.shuffle_order.remove(pos);
|
||||||
|
|
||||||
if pos < state.shuffle_position && state.shuffle_position > 0 {
|
if pos < state.shuffle_position && state.shuffle_position > 0 {
|
||||||
state.shuffle_position -= 1;
|
state.shuffle_position -= 1;
|
||||||
} else if pos == state.shuffle_position && state.shuffle_position >= state.shuffle_order.len() {
|
} else if pos == state.shuffle_position
|
||||||
|
&& state.shuffle_position >= state.shuffle_order.len()
|
||||||
|
{
|
||||||
state.shuffle_position = state.shuffle_order.len().saturating_sub(1);
|
state.shuffle_position = state.shuffle_order.len().saturating_sub(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &s
|
|||||||
|
|
||||||
/// Generate signature for track/getFileUrl endpoint
|
/// Generate signature for track/getFileUrl endpoint
|
||||||
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
|
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
|
||||||
let params = format!(
|
let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id);
|
||||||
"format_id{}intentstreamtrack_id{}",
|
|
||||||
format_id, track_id
|
|
||||||
);
|
|
||||||
generate_signature("trackgetFileUrl", ¶ms, timestamp, secret)
|
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).
|
// Try common timestamp fields (seconds).
|
||||||
let ts_keys = ["end_date_ts", "expires_at_ts", "expiration_ts", "valid_until_ts"];
|
let ts_keys = [
|
||||||
|
"end_date_ts",
|
||||||
|
"expires_at_ts",
|
||||||
|
"expiration_ts",
|
||||||
|
"valid_until_ts",
|
||||||
|
];
|
||||||
for key in ts_keys {
|
for key in ts_keys {
|
||||||
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
|
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
|
||||||
if ts > 0 {
|
if ts > 0 {
|
||||||
|
|||||||
@@ -24,23 +24,13 @@ pub struct BundleTokens {
|
|||||||
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
|
/// Extract app_id, secrets, and OAuth private_key from Qobuz bundle
|
||||||
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
||||||
// Step 1: Get login page to find bundle URL
|
// Step 1: Get login page to find bundle URL
|
||||||
let login_page = client
|
let login_page = client.get(LOGIN_PAGE_URL).send().await?.text().await?;
|
||||||
.get(LOGIN_PAGE_URL)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.text()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let bundle_url = extract_bundle_url(&login_page)?;
|
let bundle_url = extract_bundle_url(&login_page)?;
|
||||||
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
|
let full_bundle_url = format!("{}{}", BUNDLE_BASE_URL, bundle_url);
|
||||||
|
|
||||||
// Step 2: Fetch the bundle
|
// Step 2: Fetch the bundle
|
||||||
let bundle_content = client
|
let bundle_content = client.get(&full_bundle_url).send().await?.text().await?;
|
||||||
.get(&full_bundle_url)
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.text()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Step 3: Extract app_id
|
// Step 3: Extract app_id
|
||||||
let app_id = extract_app_id(&bundle_content)?;
|
let app_id = extract_app_id(&bundle_content)?;
|
||||||
@@ -62,13 +52,18 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
|||||||
log::debug!("OAuth private_key not found in bundle (older bundle version)");
|
log::debug!("OAuth private_key not found in bundle (older bundle version)");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BundleTokens { app_id, secrets, private_key })
|
Ok(BundleTokens {
|
||||||
|
app_id,
|
||||||
|
secrets,
|
||||||
|
private_key,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_bundle_url(html: &str) -> Result<String> {
|
fn extract_bundle_url(html: &str) -> Result<String> {
|
||||||
// Pattern: <script src="/resources/X.X.X-bXXX/bundle.js"></script>
|
// Pattern: <script src="/resources/X.X.X-bXXX/bundle.js"></script>
|
||||||
let re = Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)
|
let re =
|
||||||
.expect("Invalid regex");
|
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>"#)
|
||||||
|
.expect("Invalid regex");
|
||||||
|
|
||||||
re.captures(html)
|
re.captures(html)
|
||||||
.and_then(|caps| caps.get(1))
|
.and_then(|caps| caps.get(1))
|
||||||
@@ -78,8 +73,7 @@ fn extract_bundle_url(html: &str) -> Result<String> {
|
|||||||
|
|
||||||
fn extract_app_id(bundle: &str) -> Result<String> {
|
fn extract_app_id(bundle: &str) -> Result<String> {
|
||||||
// Pattern: production:{api:{appId:"XXXXXXXXX"
|
// Pattern: production:{api:{appId:"XXXXXXXXX"
|
||||||
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#)
|
let re = Regex::new(r#"production:\{api:\{appId:"(?P<app_id>\d{9})""#).expect("Invalid regex");
|
||||||
.expect("Invalid regex");
|
|
||||||
|
|
||||||
re.captures(bundle)
|
re.captures(bundle)
|
||||||
.and_then(|caps| caps.name("app_id"))
|
.and_then(|caps| caps.name("app_id"))
|
||||||
@@ -106,10 +100,16 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("Found {} seeds with timezones: {:?}", seeds.len(), timezones);
|
log::debug!(
|
||||||
|
"Found {} seeds with timezones: {:?}",
|
||||||
|
seeds.len(),
|
||||||
|
timezones
|
||||||
|
);
|
||||||
|
|
||||||
if seeds.is_empty() {
|
if seeds.is_empty() {
|
||||||
return Err(ApiError::BundleExtractionError("No seeds found".to_string()));
|
return Err(ApiError::BundleExtractionError(
|
||||||
|
"No seeds found".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dynamic regex with found timezones (capitalize first letter for matching)
|
// Build dynamic regex with found timezones (capitalize first letter for matching)
|
||||||
@@ -149,7 +149,11 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
|||||||
if let Some(seed) = seeds.get(&tz_lower) {
|
if let Some(seed) = seeds.get(&tz_lower) {
|
||||||
// Concatenate seed + info + extras, remove last 44 chars, base64 decode
|
// Concatenate seed + info + extras, remove last 44 chars, base64 decode
|
||||||
let combined = format!("{}{}{}", seed, info.as_str(), extras.as_str());
|
let combined = format!("{}{}{}", seed, info.as_str(), extras.as_str());
|
||||||
log::debug!("Combined length: {}, timezone: {}", combined.len(), tz_lower);
|
log::debug!(
|
||||||
|
"Combined length: {}, timezone: {}",
|
||||||
|
combined.len(),
|
||||||
|
tz_lower
|
||||||
|
);
|
||||||
|
|
||||||
if combined.len() > 44 {
|
if combined.len() > 44 {
|
||||||
let trimmed = &combined[..combined.len() - 44];
|
let trimmed = &combined[..combined.len() - 44];
|
||||||
@@ -159,7 +163,10 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
|||||||
) {
|
) {
|
||||||
Ok(decoded) => {
|
Ok(decoded) => {
|
||||||
if let Ok(secret) = String::from_utf8(decoded) {
|
if let Ok(secret) = String::from_utf8(decoded) {
|
||||||
log::info!("Successfully extracted secret for timezone: {}", tz_lower);
|
log::info!(
|
||||||
|
"Successfully extracted secret for timezone: {}",
|
||||||
|
tz_lower
|
||||||
|
);
|
||||||
secrets.push(secret);
|
secrets.push(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,8 +197,7 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
|||||||
|
|
||||||
fn extract_private_key(bundle: &str) -> Option<String> {
|
fn extract_private_key(bundle: &str) -> Option<String> {
|
||||||
// Pattern: privateKey:"VALUE" (the static OAuth key used in /oauth/callback)
|
// Pattern: privateKey:"VALUE" (the static OAuth key used in /oauth/callback)
|
||||||
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#)
|
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#).expect("Invalid regex");
|
||||||
.expect("Invalid regex");
|
|
||||||
|
|
||||||
re.captures(bundle)
|
re.captures(bundle)
|
||||||
.and_then(|caps| caps.name("key"))
|
.and_then(|caps| caps.name("key"))
|
||||||
|
|||||||
+288
-119
@@ -153,11 +153,14 @@ impl QobuzClient {
|
|||||||
*self.session.write().await = Some(session.clone());
|
*self.session.write().await = Some(session.clone());
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
StatusCode::UNAUTHORIZED => {
|
StatusCode::UNAUTHORIZED => Err(ApiError::AuthenticationError(
|
||||||
Err(ApiError::AuthenticationError("Invalid credentials".to_string()))
|
"Invalid credentials".to_string(),
|
||||||
}
|
)),
|
||||||
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
|
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppId),
|
||||||
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
|
status => Err(ApiError::ApiResponse(format!(
|
||||||
|
"Unexpected status: {}",
|
||||||
|
status
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +189,12 @@ impl QobuzClient {
|
|||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
let tokens = self.tokens.read().await;
|
let tokens = self.tokens.read().await;
|
||||||
let tokens = tokens.as_ref().ok_or_else(|| {
|
let tokens = tokens
|
||||||
ApiError::BundleExtractionError("Client not initialized".to_string())
|
.as_ref()
|
||||||
})?;
|
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
|
||||||
let app_id = tokens.app_id.clone();
|
let app_id = tokens.app_id.clone();
|
||||||
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
||||||
ApiError::BundleExtractionError(
|
ApiError::BundleExtractionError("OAuth private key not available in bundle".to_string())
|
||||||
"OAuth private key not available in bundle".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
let _ = tokens; // drop read lock
|
let _ = tokens; // drop read lock
|
||||||
|
|
||||||
@@ -210,7 +211,11 @@ impl QobuzClient {
|
|||||||
.http
|
.http
|
||||||
.get(&callback_url)
|
.get(&callback_url)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.query(&[("code", code), ("private_key", &private_key), ("app_id", &app_id)])
|
.query(&[
|
||||||
|
("code", code),
|
||||||
|
("private_key", &private_key),
|
||||||
|
("app_id", &app_id),
|
||||||
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -240,9 +245,8 @@ impl QobuzClient {
|
|||||||
);
|
);
|
||||||
auth_headers.insert(
|
auth_headers.insert(
|
||||||
"X-User-Auth-Token",
|
"X-User-Auth-Token",
|
||||||
HeaderValue::from_str(&token).map_err(|_| {
|
HeaderValue::from_str(&token)
|
||||||
ApiError::AuthenticationError("Invalid OAuth token format".into())
|
.map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
|
||||||
})?,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let login_response = self
|
let login_response = self
|
||||||
@@ -460,7 +464,12 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get similar artists for an artist ID
|
/// Get similar artists for an artist ID
|
||||||
pub async fn get_similar_artists(&self, artist_id: u64, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> {
|
pub async fn get_similar_artists(
|
||||||
|
&self,
|
||||||
|
artist_id: u64,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<SearchResultsPage<Artist>> {
|
||||||
let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR);
|
let url = endpoints::build_url(paths::ARTIST_GET_SIMILAR);
|
||||||
let http_response = self
|
let http_response = self
|
||||||
.http
|
.http
|
||||||
@@ -473,7 +482,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_similar_artists({}) status={}", artist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_similar_artists({}) status={}",
|
||||||
|
artist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
let artists = response
|
let artists = response
|
||||||
@@ -484,7 +497,12 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get an artist's tracks (public endpoint via artist/get?extra=tracks)
|
/// Get an artist's tracks (public endpoint via artist/get?extra=tracks)
|
||||||
pub async fn get_artist_tracks(&self, artist_id: u64, limit: u32, offset: u32) -> Result<TracksContainer> {
|
pub async fn get_artist_tracks(
|
||||||
|
&self,
|
||||||
|
artist_id: u64,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<TracksContainer> {
|
||||||
let url = endpoints::build_url(paths::ARTIST_GET);
|
let url = endpoints::build_url(paths::ARTIST_GET);
|
||||||
let locale = self.locale().await;
|
let locale = self.locale().await;
|
||||||
|
|
||||||
@@ -501,7 +519,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_artist_tracks({}) status={}", artist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_artist_tracks({}) status={}",
|
||||||
|
artist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
let tracks = response
|
let tracks = response
|
||||||
@@ -527,12 +549,21 @@ impl QobuzClient {
|
|||||||
log::debug!("[API] get_album({}) status={}", album_id, status);
|
log::debug!("[API] get_album({}) status={}", album_id, status);
|
||||||
|
|
||||||
if status == StatusCode::NOT_FOUND {
|
if status == StatusCode::NOT_FOUND {
|
||||||
log::warn!("[API] get_album({}) returned 404 — album not found", album_id);
|
log::warn!(
|
||||||
return Err(ApiError::ApiResponse(format!("Album {} not found (404)", album_id)));
|
"[API] get_album({}) returned 404 — album not found",
|
||||||
|
album_id
|
||||||
|
);
|
||||||
|
return Err(ApiError::ApiResponse(format!(
|
||||||
|
"Album {} not found (404)",
|
||||||
|
album_id
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
log::error!("[API] get_album({}) unexpected status={}", album_id, status);
|
log::error!("[API] get_album({}) unexpected status={}", album_id, status);
|
||||||
return Err(ApiError::ApiResponse(format!("get_album({}) status {}", album_id, status)));
|
return Err(ApiError::ApiResponse(format!(
|
||||||
|
"get_album({}) status {}",
|
||||||
|
album_id, status
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
@@ -565,7 +596,11 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_featured_albums({}) status={}", featured_type, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_featured_albums({}) status={}",
|
||||||
|
featured_type,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
let albums = response
|
let albums = response
|
||||||
@@ -579,9 +614,7 @@ impl QobuzClient {
|
|||||||
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
|
pub async fn get_genres(&self, parent_id: Option<u64>) -> Result<Vec<GenreInfo>> {
|
||||||
let url = endpoints::build_url(paths::GENRE_LIST);
|
let url = endpoints::build_url(paths::GENRE_LIST);
|
||||||
// Force English for consistent genre names across all user regions
|
// Force English for consistent genre names across all user regions
|
||||||
let mut query: Vec<(&str, String)> = vec![
|
let mut query: Vec<(&str, String)> = vec![("lang", "en".to_string())];
|
||||||
("lang", "en".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
if let Some(pid) = parent_id {
|
||||||
query.push(("parent_id", pid.to_string()));
|
query.push(("parent_id", pid.to_string()));
|
||||||
@@ -594,7 +627,11 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_genres(parent={:?}) status={}", parent_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_genres(parent={:?}) status={}",
|
||||||
|
parent_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
let genres = response
|
let genres = response
|
||||||
@@ -616,7 +653,11 @@ impl QobuzClient {
|
|||||||
// Add genre_ids as comma-separated list if provided
|
// Add genre_ids as comma-separated list if provided
|
||||||
if let Some(gids) = genre_ids {
|
if let Some(gids) = genre_ids {
|
||||||
if !gids.is_empty() {
|
if !gids.is_empty() {
|
||||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
let ids_str = gids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
query.push(("genre_ids", ids_str));
|
query.push(("genre_ids", ids_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,7 +674,10 @@ impl QobuzClient {
|
|||||||
|
|
||||||
// Debug: log the response structure
|
// Debug: log the response structure
|
||||||
if let Some(obj) = response.as_object() {
|
if let Some(obj) = response.as_object() {
|
||||||
log::info!("Discover API response keys: {:?}", obj.keys().collect::<Vec<_>>());
|
log::info!(
|
||||||
|
"Discover API response keys: {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
if let Some(err) = obj.get("message") {
|
if let Some(err) = obj.get("message") {
|
||||||
log::error!("Discover API error: {:?}", err);
|
log::error!("Discover API error: {:?}", err);
|
||||||
}
|
}
|
||||||
@@ -658,7 +702,11 @@ impl QobuzClient {
|
|||||||
|
|
||||||
if let Some(gids) = genre_ids {
|
if let Some(gids) = genre_ids {
|
||||||
if !gids.is_empty() {
|
if !gids.is_empty() {
|
||||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
let ids_str = gids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
query.push(("genre_ids", ids_str));
|
query.push(("genre_ids", ids_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,7 +721,11 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::info!("[API] get_discover_albums({}) status={}", endpoint, http_response.status());
|
log::info!(
|
||||||
|
"[API] get_discover_albums({}) status={}",
|
||||||
|
endpoint,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: serde_json::Value = http_response.json().await?;
|
let response: serde_json::Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(serde_json::from_value(response)?)
|
Ok(serde_json::from_value(response)?)
|
||||||
@@ -699,7 +751,11 @@ impl QobuzClient {
|
|||||||
// Add genre_ids as comma-separated list if provided
|
// Add genre_ids as comma-separated list if provided
|
||||||
if let Some(gids) = genre_ids {
|
if let Some(gids) = genre_ids {
|
||||||
if !gids.is_empty() {
|
if !gids.is_empty() {
|
||||||
let ids_str = gids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
let ids_str = gids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
query.push(("genre_ids", ids_str));
|
query.push(("genre_ids", ids_str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,7 +768,11 @@ impl QobuzClient {
|
|||||||
let off = offset.unwrap_or(0);
|
let off = offset.unwrap_or(0);
|
||||||
query.push(("offset", off.to_string()));
|
query.push(("offset", off.to_string()));
|
||||||
|
|
||||||
log::debug!("[API] get_discover_playlists URL: {} query: {:?}", url, query);
|
log::debug!(
|
||||||
|
"[API] get_discover_playlists URL: {} query: {:?}",
|
||||||
|
url,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
|
||||||
// First get raw JSON to debug structure
|
// First get raw JSON to debug structure
|
||||||
let raw_response: serde_json::Value = self
|
let raw_response: serde_json::Value = self
|
||||||
@@ -725,17 +785,28 @@ impl QobuzClient {
|
|||||||
.json()
|
.json()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
log::debug!("[API] get_discover_playlists raw response keys: {:?}", raw_response.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
log::debug!(
|
||||||
|
"[API] get_discover_playlists raw response keys: {:?}",
|
||||||
|
raw_response
|
||||||
|
.as_object()
|
||||||
|
.map(|o| o.keys().collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
|
||||||
// Try to parse as expected structure
|
// Try to parse as expected structure
|
||||||
let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone())
|
let response: DiscoverPlaylistsResponse = serde_json::from_value(raw_response.clone())
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
log::error!("[API] Failed to parse discover playlists response: {}", e);
|
log::error!("[API] Failed to parse discover playlists response: {}", e);
|
||||||
log::error!("[API] Raw response: {}", serde_json::to_string_pretty(&raw_response).unwrap_or_default());
|
log::error!(
|
||||||
|
"[API] Raw response: {}",
|
||||||
|
serde_json::to_string_pretty(&raw_response).unwrap_or_default()
|
||||||
|
);
|
||||||
e
|
e
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
log::debug!("[API] get_discover_playlists response: {} playlists", response.items.len());
|
log::debug!(
|
||||||
|
"[API] get_discover_playlists response: {} playlists",
|
||||||
|
response.items.len()
|
||||||
|
);
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
@@ -765,18 +836,22 @@ impl QobuzClient {
|
|||||||
.filter(|tag| tag.is_discover.as_deref() == Some("true"))
|
.filter(|tag| tag.is_discover.as_deref() == Some("true"))
|
||||||
.filter_map(|tag| {
|
.filter_map(|tag| {
|
||||||
// Parse name_json to get localized name
|
// Parse name_json to get localized name
|
||||||
let name_map: serde_json::Value =
|
let name_map: serde_json::Value = serde_json::from_str(&tag.name_json).ok()?;
|
||||||
serde_json::from_str(&tag.name_json).ok()?;
|
|
||||||
let name = name_map
|
let name = name_map
|
||||||
.get(lang)
|
.get(lang)
|
||||||
.or_else(|| name_map.get("en"))
|
.or_else(|| name_map.get("en"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string())?;
|
.map(|s| s.to_string())?;
|
||||||
let id = tag.featured_tag_id
|
let id = tag
|
||||||
|
.featured_tag_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
Some(PlaylistTag { id, slug: tag.slug, name })
|
Some(PlaylistTag {
|
||||||
|
id,
|
||||||
|
slug: tag.slug,
|
||||||
|
name,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -798,12 +873,18 @@ impl QobuzClient {
|
|||||||
log::debug!("[API] get_track({}) status={}", track_id, status);
|
log::debug!("[API] get_track({}) status={}", track_id, status);
|
||||||
|
|
||||||
if status == StatusCode::NOT_FOUND {
|
if status == StatusCode::NOT_FOUND {
|
||||||
log::warn!("[API] get_track({}) returned 404 — track no longer available", track_id);
|
log::warn!(
|
||||||
|
"[API] get_track({}) returned 404 — track no longer available",
|
||||||
|
track_id
|
||||||
|
);
|
||||||
return Err(ApiError::TrackUnavailable(track_id));
|
return Err(ApiError::TrackUnavailable(track_id));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
log::error!("[API] get_track({}) unexpected status={}", track_id, status);
|
log::error!("[API] get_track({}) unexpected status={}", track_id, status);
|
||||||
return Err(ApiError::ApiResponse(format!("get_track({}) status {}", track_id, status)));
|
return Err(ApiError::ApiResponse(format!(
|
||||||
|
"get_track({}) status {}",
|
||||||
|
track_id, status
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
@@ -827,19 +908,20 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_artist_basic({}) status={}", artist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_artist_basic({}) status={}",
|
||||||
|
artist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(serde_json::from_value(response)?)
|
Ok(serde_json::from_value(response)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get artist by ID
|
/// Get artist by ID
|
||||||
pub async fn get_artist(
|
pub async fn get_artist(&self, artist_id: u64, with_albums: bool) -> Result<Artist> {
|
||||||
&self,
|
self.get_artist_with_pagination(artist_id, with_albums, None, None)
|
||||||
artist_id: u64,
|
.await
|
||||||
with_albums: bool,
|
|
||||||
) -> Result<Artist> {
|
|
||||||
self.get_artist_with_pagination(artist_id, with_albums, None, None).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get artist detail by ID with albums, playlists, and appears-on tracks
|
/// Get artist detail by ID with albums, playlists, and appears-on tracks
|
||||||
@@ -870,7 +952,11 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_artist_detail({}) status={}", artist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_artist_detail({}) status={}",
|
||||||
|
artist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(serde_json::from_value(response)?)
|
Ok(serde_json::from_value(response)?)
|
||||||
@@ -884,7 +970,8 @@ impl QobuzClient {
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
offset: Option<u32>,
|
offset: Option<u32>,
|
||||||
) -> Result<Artist> {
|
) -> Result<Artist> {
|
||||||
self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None).await
|
self.get_artist_with_pagination_and_locale(artist_id, with_albums, limit, offset, None)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get artist by ID with album pagination and optional locale override
|
/// Get artist by ID with album pagination and optional locale override
|
||||||
@@ -902,10 +989,7 @@ impl QobuzClient {
|
|||||||
Some(l) => l.to_string(),
|
Some(l) => l.to_string(),
|
||||||
None => self.locale().await,
|
None => self.locale().await,
|
||||||
};
|
};
|
||||||
let mut query = vec![
|
let mut query = vec![("artist_id", artist_id.to_string()), ("lang", locale)];
|
||||||
("artist_id", artist_id.to_string()),
|
|
||||||
("lang", locale),
|
|
||||||
];
|
|
||||||
if with_albums {
|
if with_albums {
|
||||||
query.push(("extra", "albums".to_string()));
|
query.push(("extra", "albums".to_string()));
|
||||||
}
|
}
|
||||||
@@ -923,7 +1007,12 @@ impl QobuzClient {
|
|||||||
.query(&query)
|
.query(&query)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_artist({}, albums={}) status={}", artist_id, with_albums, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_artist({}, albums={}) status={}",
|
||||||
|
artist_id,
|
||||||
|
with_albums,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(serde_json::from_value(response)?)
|
Ok(serde_json::from_value(response)?)
|
||||||
@@ -944,7 +1033,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_playlist_track_ids({}) status={}", playlist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_playlist_track_ids({}) status={}",
|
||||||
|
playlist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
let result: PlaylistWithTrackIds = serde_json::from_value(response)?;
|
let result: PlaylistWithTrackIds = serde_json::from_value(response)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1025,7 +1118,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_playlist({}) status={}", playlist_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_playlist({}) status={}",
|
||||||
|
playlist_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
let mut playlist: Playlist = serde_json::from_value(response)?;
|
let mut playlist: Playlist = serde_json::from_value(response)?;
|
||||||
|
|
||||||
@@ -1039,37 +1136,43 @@ impl QobuzClient {
|
|||||||
let offsets: Vec<u32> = (fetched..total).step_by(PAGE_SIZE as usize).collect();
|
let offsets: Vec<u32> = (fetched..total).step_by(PAGE_SIZE as usize).collect();
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[API] get_playlist({}) fetching {} remaining pages concurrently ({}/{})",
|
"[API] get_playlist({}) fetching {} remaining pages concurrently ({}/{})",
|
||||||
playlist_id, offsets.len(), fetched, total
|
playlist_id,
|
||||||
|
offsets.len(),
|
||||||
|
fetched,
|
||||||
|
total
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prepare headers once for all concurrent requests
|
// Prepare headers once for all concurrent requests
|
||||||
let headers = self.api_headers().await?;
|
let headers = self.api_headers().await?;
|
||||||
|
|
||||||
// Launch all page requests concurrently
|
// Launch all page requests concurrently
|
||||||
let futures: Vec<_> = offsets.iter().map(|&offset| {
|
let futures: Vec<_> = offsets
|
||||||
let http = &self.http;
|
.iter()
|
||||||
let url = &url;
|
.map(|&offset| {
|
||||||
let headers = headers.clone();
|
let http = &self.http;
|
||||||
let pid = playlist_id.to_string();
|
let url = &url;
|
||||||
let limit = PAGE_SIZE.to_string();
|
let headers = headers.clone();
|
||||||
let offset_str = offset.to_string();
|
let pid = playlist_id.to_string();
|
||||||
async move {
|
let limit = PAGE_SIZE.to_string();
|
||||||
let resp = http
|
let offset_str = offset.to_string();
|
||||||
.get(url)
|
async move {
|
||||||
.headers(headers)
|
let resp = http
|
||||||
.query(&[
|
.get(url)
|
||||||
("playlist_id", pid.as_str()),
|
.headers(headers)
|
||||||
("limit", limit.as_str()),
|
.query(&[
|
||||||
("offset", offset_str.as_str()),
|
("playlist_id", pid.as_str()),
|
||||||
("extra", "tracks"),
|
("limit", limit.as_str()),
|
||||||
])
|
("offset", offset_str.as_str()),
|
||||||
.send()
|
("extra", "tracks"),
|
||||||
.await?;
|
])
|
||||||
let value: Value = resp.json().await?;
|
.send()
|
||||||
let page: Playlist = serde_json::from_value(value)?;
|
.await?;
|
||||||
Ok::<_, anyhow::Error>((offset, page))
|
let value: Value = resp.json().await?;
|
||||||
}
|
let page: Playlist = serde_json::from_value(value)?;
|
||||||
}).collect();
|
Ok::<_, anyhow::Error>((offset, page))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let results = futures_util::future::join_all(futures).await;
|
let results = futures_util::future::join_all(futures).await;
|
||||||
|
|
||||||
@@ -1079,7 +1182,11 @@ impl QobuzClient {
|
|||||||
match result {
|
match result {
|
||||||
Ok(page) => pages.push(page),
|
Ok(page) => pages.push(page),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[API] get_playlist({}) page fetch failed: {}", playlist_id, e);
|
log::warn!(
|
||||||
|
"[API] get_playlist({}) page fetch failed: {}",
|
||||||
|
playlist_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
// Continue with what we have
|
// Continue with what we have
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1127,22 +1234,19 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get label explore (discover more labels)
|
/// Get label explore (discover more labels)
|
||||||
pub async fn get_label_explore(
|
pub async fn get_label_explore(&self, limit: u32, offset: u32) -> Result<LabelExploreResponse> {
|
||||||
&self,
|
|
||||||
limit: u32,
|
|
||||||
offset: u32,
|
|
||||||
) -> Result<LabelExploreResponse> {
|
|
||||||
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
let url = endpoints::build_url(paths::LABEL_EXPLORE);
|
||||||
|
|
||||||
log::debug!("[API] get_label_explore(limit={}, offset={})", limit, offset);
|
log::debug!(
|
||||||
|
"[API] get_label_explore(limit={}, offset={})",
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
);
|
||||||
let response: serde_json::Value = self
|
let response: serde_json::Value = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.headers(self.api_headers().await?)
|
.headers(self.api_headers().await?)
|
||||||
.query(&[
|
.query(&[("limit", limit.to_string()), ("offset", offset.to_string())])
|
||||||
("limit", limit.to_string()),
|
|
||||||
("offset", offset.to_string()),
|
|
||||||
])
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.json()
|
.json()
|
||||||
@@ -1169,7 +1273,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_label({}) status={}", label_id, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_label({}) status={}",
|
||||||
|
label_id,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(serde_json::from_value(response)?)
|
Ok(serde_json::from_value(response)?)
|
||||||
@@ -1179,7 +1287,11 @@ impl QobuzClient {
|
|||||||
|
|
||||||
/// Get stream URL for a track (requires auth + signature)
|
/// Get stream URL for a track (requires auth + signature)
|
||||||
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
|
pub async fn get_stream_url(&self, track_id: u64, quality: Quality) -> Result<StreamUrl> {
|
||||||
log::info!("Getting stream URL for track {} with quality {:?}", track_id, quality);
|
log::info!(
|
||||||
|
"Getting stream URL for track {} with quality {:?}",
|
||||||
|
track_id,
|
||||||
|
quality
|
||||||
|
);
|
||||||
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
|
let url = endpoints::build_url(paths::TRACK_GET_FILE_URL);
|
||||||
let timestamp = get_timestamp();
|
let timestamp = get_timestamp();
|
||||||
log::debug!("Getting secret for signing...");
|
log::debug!("Getting secret for signing...");
|
||||||
@@ -1206,7 +1318,10 @@ impl QobuzClient {
|
|||||||
match response.status() {
|
match response.status() {
|
||||||
StatusCode::OK => {
|
StatusCode::OK => {
|
||||||
let json: Value = response.json().await?;
|
let json: Value = response.json().await?;
|
||||||
log::debug!("Stream URL response JSON keys: {:?}", json.as_object().map(|o| o.keys().collect::<Vec<_>>()));
|
log::debug!(
|
||||||
|
"Stream URL response JSON keys: {:?}",
|
||||||
|
json.as_object().map(|o| o.keys().collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
|
||||||
// Check for restrictions
|
// Check for restrictions
|
||||||
let restrictions: Vec<StreamRestriction> = json
|
let restrictions: Vec<StreamRestriction> = json
|
||||||
@@ -1214,12 +1329,17 @@ impl QobuzClient {
|
|||||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Validate that we got an actual URL (track may be unavailable)
|
// Validate that we got an actual URL (track may be unavailable)
|
||||||
let url = json["url"].as_str().unwrap_or("").to_string();
|
let url = json["url"].as_str().unwrap_or("").to_string();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
// Log the restriction codes for debugging
|
// Log the restriction codes for debugging
|
||||||
let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect();
|
let restriction_codes: Vec<&str> =
|
||||||
log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes);
|
restrictions.iter().map(|r| r.code.as_str()).collect();
|
||||||
|
log::warn!(
|
||||||
|
"Stream URL missing for track {} - restrictions: {:?}",
|
||||||
|
track_id,
|
||||||
|
restriction_codes
|
||||||
|
);
|
||||||
return Err(ApiError::TrackUnavailable(track_id));
|
return Err(ApiError::TrackUnavailable(track_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,7 +1354,10 @@ impl QobuzClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
|
StatusCode::BAD_REQUEST => Err(ApiError::InvalidAppSecret),
|
||||||
status => Err(ApiError::ApiResponse(format!("Unexpected status: {}", status))),
|
status => Err(ApiError::ApiResponse(format!(
|
||||||
|
"Unexpected status: {}",
|
||||||
|
status
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,7 +1367,11 @@ impl QobuzClient {
|
|||||||
track_id: u64,
|
track_id: u64,
|
||||||
preferred: Quality,
|
preferred: Quality,
|
||||||
) -> Result<StreamUrl> {
|
) -> Result<StreamUrl> {
|
||||||
log::info!("Getting stream URL with fallback for track {}, preferred quality: {:?}", track_id, preferred);
|
log::info!(
|
||||||
|
"Getting stream URL with fallback for track {}, preferred quality: {:?}",
|
||||||
|
track_id,
|
||||||
|
preferred
|
||||||
|
);
|
||||||
let qualities = Quality::fallback_order();
|
let qualities = Quality::fallback_order();
|
||||||
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
|
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
|
||||||
|
|
||||||
@@ -1259,24 +1386,24 @@ impl QobuzClient {
|
|||||||
quality.id()
|
quality.id()
|
||||||
);
|
);
|
||||||
return Ok(url);
|
return Ok(url);
|
||||||
},
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log::info!("Quality {:?} has restrictions, trying next", quality);
|
log::info!("Quality {:?} has restrictions, trying next", quality);
|
||||||
continue;
|
continue;
|
||||||
},
|
}
|
||||||
Err(ApiError::InvalidAppSecret) => {
|
Err(ApiError::InvalidAppSecret) => {
|
||||||
log::error!("Invalid app secret");
|
log::error!("Invalid app secret");
|
||||||
return Err(ApiError::InvalidAppSecret);
|
return Err(ApiError::InvalidAppSecret);
|
||||||
},
|
}
|
||||||
Err(ApiError::TrackUnavailable(_)) => {
|
Err(ApiError::TrackUnavailable(_)) => {
|
||||||
// Track is completely unavailable on Qobuz
|
// Track is completely unavailable on Qobuz
|
||||||
track_unavailable = true;
|
track_unavailable = true;
|
||||||
continue;
|
continue;
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
|
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
|
||||||
continue;
|
continue;
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,7 +1437,11 @@ impl QobuzClient {
|
|||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
log::debug!("[API] get_favorites({}) status={}", fav_type, http_response.status());
|
log::debug!(
|
||||||
|
"[API] get_favorites({}) status={}",
|
||||||
|
fav_type,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
Ok(response)
|
Ok(response)
|
||||||
@@ -1337,7 +1468,12 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search playlists
|
/// Search playlists
|
||||||
pub async fn search_playlists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Playlist>> {
|
pub async fn search_playlists(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<SearchResultsPage<Playlist>> {
|
||||||
let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
|
let url = endpoints::build_url(paths::PLAYLIST_SEARCH);
|
||||||
let http_response = self
|
let http_response = self
|
||||||
.http
|
.http
|
||||||
@@ -1361,7 +1497,12 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new playlist
|
/// Create a new playlist
|
||||||
pub async fn create_playlist(&self, name: &str, description: Option<&str>, is_public: bool) -> Result<Playlist> {
|
pub async fn create_playlist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
is_public: bool,
|
||||||
|
) -> Result<Playlist> {
|
||||||
let url = endpoints::build_url(paths::PLAYLIST_CREATE);
|
let url = endpoints::build_url(paths::PLAYLIST_CREATE);
|
||||||
|
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
@@ -1402,7 +1543,11 @@ impl QobuzClient {
|
|||||||
/// Add tracks to a playlist
|
/// Add tracks to a playlist
|
||||||
pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> {
|
pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> {
|
||||||
let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS);
|
let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS);
|
||||||
let track_ids_str = track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
let track_ids_str = track_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
|
||||||
self.http
|
self.http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
@@ -1418,9 +1563,17 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove tracks from a playlist
|
/// Remove tracks from a playlist
|
||||||
pub async fn remove_tracks_from_playlist(&self, playlist_id: u64, playlist_track_ids: &[u64]) -> Result<()> {
|
pub async fn remove_tracks_from_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: u64,
|
||||||
|
playlist_track_ids: &[u64],
|
||||||
|
) -> Result<()> {
|
||||||
let url = endpoints::build_url(paths::PLAYLIST_DELETE_TRACKS);
|
let url = endpoints::build_url(paths::PLAYLIST_DELETE_TRACKS);
|
||||||
let track_ids_str = playlist_track_ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
|
let track_ids_str = playlist_track_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
|
||||||
self.http
|
self.http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
@@ -1436,7 +1589,13 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update playlist metadata
|
/// Update playlist metadata
|
||||||
pub async fn update_playlist(&self, playlist_id: u64, name: Option<&str>, description: Option<&str>, is_public: Option<bool>) -> Result<Playlist> {
|
pub async fn update_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: u64,
|
||||||
|
name: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
is_public: Option<bool>,
|
||||||
|
) -> Result<Playlist> {
|
||||||
let url = endpoints::build_url(paths::PLAYLIST_UPDATE);
|
let url = endpoints::build_url(paths::PLAYLIST_UPDATE);
|
||||||
|
|
||||||
let mut params = vec![("playlist_id", playlist_id.to_string())];
|
let mut params = vec![("playlist_id", playlist_id.to_string())];
|
||||||
@@ -1479,7 +1638,10 @@ impl QobuzClient {
|
|||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::ApiResponse(format!("Failed to add favorite: {}", response.status())))
|
Err(ApiError::ApiResponse(format!(
|
||||||
|
"Failed to add favorite: {}",
|
||||||
|
response.status()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1499,7 +1661,10 @@ impl QobuzClient {
|
|||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::ApiResponse(format!("Failed to remove favorite: {}", response.status())))
|
Err(ApiError::ApiResponse(format!(
|
||||||
|
"Failed to remove favorite: {}",
|
||||||
|
response.status()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,9 +1677,7 @@ impl QobuzClient {
|
|||||||
sort: Option<&str>,
|
sort: Option<&str>,
|
||||||
) -> Result<PageArtistResponse> {
|
) -> Result<PageArtistResponse> {
|
||||||
let url = endpoints::build_url(paths::ARTIST_PAGE);
|
let url = endpoints::build_url(paths::ARTIST_PAGE);
|
||||||
let mut query = vec![
|
let mut query = vec![("artist_id", artist_id.to_string())];
|
||||||
("artist_id", artist_id.to_string()),
|
|
||||||
];
|
|
||||||
if let Some(s) = sort {
|
if let Some(s) = sort {
|
||||||
query.push(("sort", s.to_string()));
|
query.push(("sort", s.to_string()));
|
||||||
}
|
}
|
||||||
@@ -1553,7 +1716,13 @@ impl QobuzClient {
|
|||||||
query.push(("sort", s.to_string()));
|
query.push(("sort", s.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("[API] get_releases_grid({}) type={} limit={} offset={}", artist_id, release_type, limit, offset);
|
log::debug!(
|
||||||
|
"[API] get_releases_grid({}) type={} limit={} offset={}",
|
||||||
|
artist_id,
|
||||||
|
release_type,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
);
|
||||||
let response: serde_json::Value = self
|
let response: serde_json::Value = self
|
||||||
.http
|
.http
|
||||||
.get(&url)
|
.get(&url)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ pub mod link_resolver;
|
|||||||
pub mod performers;
|
pub mod performers;
|
||||||
|
|
||||||
// Re-export main types
|
// Re-export main types
|
||||||
|
pub use bundle::BundleTokens;
|
||||||
pub use client::QobuzClient;
|
pub use client::QobuzClient;
|
||||||
pub use error::{ApiError, Result};
|
pub use error::{ApiError, Result};
|
||||||
pub use bundle::BundleTokens;
|
pub use link_resolver::{resolve_link, LinkResolverError, ResolvedLink};
|
||||||
pub use link_resolver::{resolve_link, ResolvedLink, LinkResolverError};
|
|
||||||
@@ -135,7 +135,9 @@ fn build_resolved_link(entity_type: &str, raw_id: &str) -> Result<ResolvedLink,
|
|||||||
.map_err(|_| LinkResolverError::InvalidId(raw_id.to_string()))?;
|
.map_err(|_| LinkResolverError::InvalidId(raw_id.to_string()))?;
|
||||||
Ok(ResolvedLink::OpenPlaylist(id))
|
Ok(ResolvedLink::OpenPlaylist(id))
|
||||||
}
|
}
|
||||||
_ => Err(LinkResolverError::UnknownEntityType(entity_type.to_string())),
|
_ => Err(LinkResolverError::UnknownEntityType(
|
||||||
|
entity_type.to_string(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-139
@@ -1,139 +1,143 @@
|
|||||||
//! Performers string parsing utilities
|
//! Performers string parsing utilities
|
||||||
//!
|
//!
|
||||||
//! Qobuz provides performer credits as a formatted string like:
|
//! Qobuz provides performer credits as a formatted string like:
|
||||||
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
|
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
|
||||||
//!
|
//!
|
||||||
//! This module parses that string into structured data.
|
//! This module parses that string into structured data.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// A performer with their name and roles
|
/// A performer with their name and roles
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Performer {
|
pub struct Performer {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub roles: Vec<String>,
|
pub roles: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Performer {
|
impl Performer {
|
||||||
pub fn new(name: String, roles: Vec<String>) -> Self {
|
pub fn new(name: String, roles: Vec<String>) -> Self {
|
||||||
Self { name, roles }
|
Self { name, roles }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a Qobuz performers string into structured data
|
/// Parse a Qobuz performers string into structured data
|
||||||
///
|
///
|
||||||
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
|
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use crate::api::performers::parse_performers;
|
/// use crate::api::performers::parse_performers;
|
||||||
///
|
///
|
||||||
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
|
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
|
||||||
/// assert_eq!(performers.len(), 2);
|
/// assert_eq!(performers.len(), 2);
|
||||||
/// assert_eq!(performers[0].name, "John Coltrane");
|
/// assert_eq!(performers[0].name, "John Coltrane");
|
||||||
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
|
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
|
||||||
/// ```
|
/// ```
|
||||||
pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
|
pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
|
||||||
if performers_str.is_empty() {
|
if performers_str.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
performers_str
|
performers_str
|
||||||
.split(" - ")
|
.split(" - ")
|
||||||
.filter_map(|segment| {
|
.filter_map(|segment| {
|
||||||
let segment = segment.trim();
|
let segment = segment.trim();
|
||||||
if segment.is_empty() {
|
if segment.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parts: Vec<&str> = segment.split(", ").collect();
|
let parts: Vec<&str> = segment.split(", ").collect();
|
||||||
if parts.is_empty() {
|
if parts.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = parts[0].trim().to_string();
|
let name = parts[0].trim().to_string();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let roles: Vec<String> = parts[1..]
|
let roles: Vec<String> = parts[1..]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.trim().to_string())
|
.map(|r| r.trim().to_string())
|
||||||
.filter(|r| !r.is_empty())
|
.filter(|r| !r.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(Performer::new(name, roles))
|
Some(Performer::new(name, roles))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Group performers by their roles
|
/// Group performers by their roles
|
||||||
///
|
///
|
||||||
/// Returns a map where keys are role names and values are lists of performer names
|
/// Returns a map where keys are role names and values are lists of performer names
|
||||||
pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> {
|
pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> {
|
||||||
let mut grouped: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
let mut grouped: std::collections::HashMap<String, Vec<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
for performer in performers {
|
|
||||||
for role in &performer.roles {
|
for performer in performers {
|
||||||
grouped
|
for role in &performer.roles {
|
||||||
.entry(role.clone())
|
grouped
|
||||||
.or_default()
|
.entry(role.clone())
|
||||||
.push(performer.name.clone());
|
.or_default()
|
||||||
}
|
.push(performer.name.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
grouped
|
|
||||||
}
|
grouped
|
||||||
|
}
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
#[cfg(test)]
|
||||||
use super::*;
|
mod tests {
|
||||||
|
use super::*;
|
||||||
#[test]
|
|
||||||
fn test_parse_single_performer() {
|
#[test]
|
||||||
let result = parse_performers("John Coltrane, Saxophone");
|
fn test_parse_single_performer() {
|
||||||
assert_eq!(result.len(), 1);
|
let result = parse_performers("John Coltrane, Saxophone");
|
||||||
assert_eq!(result[0].name, "John Coltrane");
|
assert_eq!(result.len(), 1);
|
||||||
assert_eq!(result[0].roles, vec!["Saxophone"]);
|
assert_eq!(result[0].name, "John Coltrane");
|
||||||
}
|
assert_eq!(result[0].roles, vec!["Saxophone"]);
|
||||||
|
}
|
||||||
#[test]
|
|
||||||
fn test_parse_multiple_performers() {
|
#[test]
|
||||||
let result = parse_performers(
|
fn test_parse_multiple_performers() {
|
||||||
"John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums",
|
let result = parse_performers(
|
||||||
);
|
"John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums",
|
||||||
assert_eq!(result.len(), 3);
|
);
|
||||||
assert_eq!(result[0].name, "John Coltrane");
|
assert_eq!(result.len(), 3);
|
||||||
assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]);
|
assert_eq!(result[0].name, "John Coltrane");
|
||||||
assert_eq!(result[1].name, "McCoy Tyner");
|
assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]);
|
||||||
assert_eq!(result[1].roles, vec!["Piano"]);
|
assert_eq!(result[1].name, "McCoy Tyner");
|
||||||
assert_eq!(result[2].name, "Elvin Jones");
|
assert_eq!(result[1].roles, vec!["Piano"]);
|
||||||
assert_eq!(result[2].roles, vec!["Drums"]);
|
assert_eq!(result[2].name, "Elvin Jones");
|
||||||
}
|
assert_eq!(result[2].roles, vec!["Drums"]);
|
||||||
|
}
|
||||||
#[test]
|
|
||||||
fn test_parse_empty_string() {
|
#[test]
|
||||||
let result = parse_performers("");
|
fn test_parse_empty_string() {
|
||||||
assert!(result.is_empty());
|
let result = parse_performers("");
|
||||||
}
|
assert!(result.is_empty());
|
||||||
|
}
|
||||||
#[test]
|
|
||||||
fn test_parse_performer_no_roles() {
|
#[test]
|
||||||
let result = parse_performers("John Coltrane");
|
fn test_parse_performer_no_roles() {
|
||||||
assert_eq!(result.len(), 1);
|
let result = parse_performers("John Coltrane");
|
||||||
assert_eq!(result[0].name, "John Coltrane");
|
assert_eq!(result.len(), 1);
|
||||||
assert!(result[0].roles.is_empty());
|
assert_eq!(result[0].name, "John Coltrane");
|
||||||
}
|
assert!(result[0].roles.is_empty());
|
||||||
|
}
|
||||||
#[test]
|
|
||||||
fn test_group_by_role() {
|
#[test]
|
||||||
let performers = vec![
|
fn test_group_by_role() {
|
||||||
Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
|
let performers = vec![
|
||||||
Performer::new("Jane".to_string(), vec!["Saxophone".to_string(), "Vocals".to_string()]),
|
Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
|
||||||
];
|
Performer::new(
|
||||||
let grouped = group_by_role(&performers);
|
"Jane".to_string(),
|
||||||
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2);
|
vec!["Saxophone".to_string(), "Vocals".to_string()],
|
||||||
assert_eq!(grouped.get("Vocals").unwrap().len(), 1);
|
),
|
||||||
}
|
];
|
||||||
}
|
let grouped = group_by_role(&performers);
|
||||||
|
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2);
|
||||||
|
assert_eq!(grouped.get("Vocals").unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1294,9 +1294,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() {
|
fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() {
|
||||||
let raw_message = vec![
|
let raw_message = vec![
|
||||||
0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d,
|
0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d, 0x09,
|
||||||
0x09, 0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01,
|
0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01, 0x20, 0xa8,
|
||||||
0x20, 0xa8, 0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c,
|
0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c,
|
||||||
];
|
];
|
||||||
|
|
||||||
let message = QConnectMessage::decode(raw_message.as_slice()).expect("decode message");
|
let message = QConnectMessage::decode(raw_message.as_slice()).expect("decode message");
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ impl QueueEventType {
|
|||||||
Self::SrvrCtrlAddRenderer => "MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER",
|
Self::SrvrCtrlAddRenderer => "MESSAGE_TYPE_SRVR_CTRL_ADD_RENDERER",
|
||||||
Self::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER",
|
Self::SrvrCtrlUpdateRenderer => "MESSAGE_TYPE_SRVR_CTRL_UPDATE_RENDERER",
|
||||||
Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER",
|
Self::SrvrCtrlRemoveRenderer => "MESSAGE_TYPE_SRVR_CTRL_REMOVE_RENDERER",
|
||||||
Self::SrvrCtrlActiveRendererChanged => {
|
Self::SrvrCtrlActiveRendererChanged => "MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED",
|
||||||
"MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED"
|
|
||||||
}
|
|
||||||
Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED",
|
Self::SrvrCtrlVolumeChanged => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_CHANGED",
|
||||||
Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET",
|
Self::SrvrCtrlLoopModeSet => "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET",
|
||||||
Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED",
|
Self::SrvrCtrlVolumeMuted => "MESSAGE_TYPE_SRVR_CTRL_VOLUME_MUTED",
|
||||||
|
|||||||
@@ -619,7 +619,10 @@ fn handle_incoming_binary(
|
|||||||
match decode_renderer_server_commands(&inner_payload) {
|
match decode_renderer_server_commands(&inner_payload) {
|
||||||
Ok(commands) => {
|
Ok(commands) => {
|
||||||
if !commands.is_empty() {
|
if !commands.is_empty() {
|
||||||
log::info!("[QConnect/Decode] Renderer commands decoded: {}", commands.len());
|
log::info!(
|
||||||
|
"[QConnect/Decode] Renderer commands decoded: {}",
|
||||||
|
commands.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
for command in commands {
|
for command in commands {
|
||||||
emit(
|
emit(
|
||||||
|
|||||||
Generated
+105
-12
@@ -194,7 +194,7 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -1581,7 +1581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2508,7 +2508,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2544,7 +2544,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -3916,7 +3916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4413,6 +4413,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf"
|
name = "protobuf"
|
||||||
version = "3.2.0"
|
version = "3.2.0"
|
||||||
@@ -4629,6 +4652,9 @@ dependencies = [
|
|||||||
"qbz-models",
|
"qbz-models",
|
||||||
"qbz-player",
|
"qbz-player",
|
||||||
"qbz-qobuz",
|
"qbz-qobuz",
|
||||||
|
"qconnect-app",
|
||||||
|
"qconnect-core",
|
||||||
|
"qconnect-transport-ws",
|
||||||
"qrcode-generator",
|
"qrcode-generator",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -4698,6 +4724,58 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qconnect-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"log",
|
||||||
|
"qconnect-core",
|
||||||
|
"qconnect-protocol",
|
||||||
|
"qconnect-transport-ws",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qconnect-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qconnect-protocol"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"prost",
|
||||||
|
"qconnect-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qconnect-transport-ws"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"prost",
|
||||||
|
"qconnect-protocol",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-generator"
|
name = "qrcode-generator"
|
||||||
version = "4.1.9"
|
version = "4.1.9"
|
||||||
@@ -4743,7 +4821,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -4780,9 +4858,9 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5098,7 +5176,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams 0.4.2",
|
"wasm-streams 0.4.2",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5303,7 +5381,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6577,7 +6655,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6782,8 +6860,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7051,6 +7133,8 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -7567,6 +7651,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -7652,7 +7745,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
|
|||||||
// Step 5: Extract OAuth private_key (optional)
|
// Step 5: Extract OAuth private_key (optional)
|
||||||
let private_key = extract_private_key(&bundle_content);
|
let private_key = extract_private_key(&bundle_content);
|
||||||
|
|
||||||
Ok(BundleTokens { app_id, secrets, private_key })
|
Ok(BundleTokens {
|
||||||
|
app_id,
|
||||||
|
secrets,
|
||||||
|
private_key,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_bundle_url(html: &str) -> Result<String> {
|
fn extract_bundle_url(html: &str) -> Result<String> {
|
||||||
@@ -186,8 +190,7 @@ fn extract_secrets(bundle: &str) -> Result<Vec<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn extract_private_key(bundle: &str) -> Option<String> {
|
fn extract_private_key(bundle: &str) -> Option<String> {
|
||||||
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#)
|
let re = Regex::new(r#"privateKey:\s*"(?P<key>[A-Za-z0-9]{6,30})""#).expect("Invalid regex");
|
||||||
.expect("Invalid regex");
|
|
||||||
re.captures(bundle)
|
re.captures(bundle)
|
||||||
.and_then(|caps| caps.name("key"))
|
.and_then(|caps| caps.name("key"))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
|
|||||||
@@ -187,9 +187,9 @@ impl QobuzClient {
|
|||||||
use reqwest::header::{HeaderMap, HeaderValue};
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
let tokens = self.tokens.read().await;
|
let tokens = self.tokens.read().await;
|
||||||
let tokens = tokens.as_ref().ok_or_else(|| {
|
let tokens = tokens
|
||||||
ApiError::BundleExtractionError("Client not initialized".to_string())
|
.as_ref()
|
||||||
})?;
|
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string()))?;
|
||||||
let app_id = tokens.app_id.clone();
|
let app_id = tokens.app_id.clone();
|
||||||
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
let private_key = tokens.private_key.clone().ok_or_else(|| {
|
||||||
ApiError::BundleExtractionError(
|
ApiError::BundleExtractionError(
|
||||||
@@ -248,9 +248,8 @@ impl QobuzClient {
|
|||||||
);
|
);
|
||||||
auth_headers.insert(
|
auth_headers.insert(
|
||||||
"X-User-Auth-Token",
|
"X-User-Auth-Token",
|
||||||
HeaderValue::from_str(&token).map_err(|_| {
|
HeaderValue::from_str(&token)
|
||||||
ApiError::AuthenticationError("Invalid OAuth token format".into())
|
.map_err(|_| ApiError::AuthenticationError("Invalid OAuth token format".into()))?,
|
||||||
})?,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let login_resp = self
|
let login_resp = self
|
||||||
@@ -304,9 +303,8 @@ impl QobuzClient {
|
|||||||
);
|
);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"X-User-Auth-Token",
|
"X-User-Auth-Token",
|
||||||
HeaderValue::from_str(token).map_err(|_| {
|
HeaderValue::from_str(token)
|
||||||
ApiError::AuthenticationError("Invalid token format".into())
|
.map_err(|_| ApiError::AuthenticationError("Invalid token format".into()))?,
|
||||||
})?,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
log::info!("[OAuth] Restoring session from saved token");
|
log::info!("[OAuth] Restoring session from saved token");
|
||||||
@@ -632,10 +630,7 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get album suggestions (similar albums) from /album/suggest
|
/// Get album suggestions (similar albums) from /album/suggest
|
||||||
pub async fn get_album_suggest(
|
pub async fn get_album_suggest(&self, album_id: &str) -> Result<AlbumSuggestResponse> {
|
||||||
&self,
|
|
||||||
album_id: &str,
|
|
||||||
) -> Result<AlbumSuggestResponse> {
|
|
||||||
let url = endpoints::build_url(paths::ALBUM_SUGGEST);
|
let url = endpoints::build_url(paths::ALBUM_SUGGEST);
|
||||||
let http_response = self
|
let http_response = self
|
||||||
.http
|
.http
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use tokio::sync::{Mutex, RwLock};
|
|||||||
|
|
||||||
use crate::api::QobuzClient;
|
use crate::api::QobuzClient;
|
||||||
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
|
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
|
||||||
use qbz_integrations::musicbrainz::{ArtistFullResponse, ArtistRelationships, Period, RelatedArtist};
|
use qbz_integrations::musicbrainz::{
|
||||||
|
ArtistFullResponse, ArtistRelationships, Period, RelatedArtist,
|
||||||
|
};
|
||||||
use qbz_integrations::MusicBrainzClient;
|
use qbz_integrations::MusicBrainzClient;
|
||||||
|
|
||||||
use super::sparse_vector::SparseVector;
|
use super::sparse_vector::SparseVector;
|
||||||
|
|||||||
@@ -151,9 +151,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
let mut vars = HashMap::new();
|
let mut vars = HashMap::new();
|
||||||
|
|
||||||
// Determine dark/light from the window background
|
// Determine dark/light from the window background
|
||||||
let window_bg = scheme
|
let window_bg = scheme.window_bg.unwrap_or(PaletteColor::new(40, 40, 40));
|
||||||
.window_bg
|
|
||||||
.unwrap_or(PaletteColor::new(40, 40, 40));
|
|
||||||
let is_dark = window_bg.luminance() < 0.5;
|
let is_dark = window_bg.luminance() < 0.5;
|
||||||
|
|
||||||
// ── Backgrounds ──────────────────────────────────────────────────────
|
// ── Backgrounds ──────────────────────────────────────────────────────
|
||||||
@@ -188,25 +186,23 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
vars.insert("--bg-hover".into(), bg_hover.to_hex());
|
vars.insert("--bg-hover".into(), bg_hover.to_hex());
|
||||||
|
|
||||||
// ── Text ─────────────────────────────────────────────────────────────
|
// ── Text ─────────────────────────────────────────────────────────────
|
||||||
let text_primary = scheme
|
let text_primary = scheme.window_fg.unwrap_or(if is_dark {
|
||||||
.window_fg
|
PaletteColor::new(223, 223, 223)
|
||||||
.unwrap_or(if is_dark {
|
} else {
|
||||||
PaletteColor::new(223, 223, 223)
|
PaletteColor::new(36, 36, 36)
|
||||||
} else {
|
});
|
||||||
PaletteColor::new(36, 36, 36)
|
|
||||||
});
|
|
||||||
let text_primary = ensure_text_contrast(text_primary, &window_bg, is_dark);
|
let text_primary = ensure_text_contrast(text_primary, &window_bg, is_dark);
|
||||||
vars.insert("--text-primary".into(), text_primary.to_hex());
|
vars.insert("--text-primary".into(), text_primary.to_hex());
|
||||||
|
|
||||||
// Secondary: slightly dimmed from primary
|
// Secondary: slightly dimmed from primary
|
||||||
let text_secondary = scheme.view_fg.unwrap_or_else(|| {
|
let text_secondary = scheme
|
||||||
text_primary.shift_lightness(if is_dark { -0.10 } else { 0.10 })
|
.view_fg
|
||||||
});
|
.unwrap_or_else(|| text_primary.shift_lightness(if is_dark { -0.10 } else { 0.10 }));
|
||||||
vars.insert("--text-secondary".into(), text_secondary.to_hex());
|
vars.insert("--text-secondary".into(), text_secondary.to_hex());
|
||||||
|
|
||||||
let text_muted = scheme.window_fg_inactive.unwrap_or_else(|| {
|
let text_muted = scheme
|
||||||
text_primary.shift_lightness(if is_dark { -0.25 } else { 0.25 })
|
.window_fg_inactive
|
||||||
});
|
.unwrap_or_else(|| text_primary.shift_lightness(if is_dark { -0.25 } else { 0.25 }));
|
||||||
vars.insert("--text-muted".into(), text_muted.to_hex());
|
vars.insert("--text-muted".into(), text_muted.to_hex());
|
||||||
|
|
||||||
let text_disabled = text_muted.shift_lightness(if is_dark { -0.10 } else { 0.10 });
|
let text_disabled = text_muted.shift_lightness(if is_dark { -0.10 } else { 0.10 });
|
||||||
@@ -229,24 +225,20 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Button text on accent
|
// Button text on accent
|
||||||
let btn_text = scheme.selection_fg.unwrap_or(
|
let btn_text = scheme.selection_fg.unwrap_or(if accent.luminance() < 0.5 {
|
||||||
if accent.luminance() < 0.5 {
|
PaletteColor::new(255, 255, 255)
|
||||||
PaletteColor::new(255, 255, 255)
|
} else {
|
||||||
} else {
|
PaletteColor::new(0, 0, 0)
|
||||||
PaletteColor::new(0, 0, 0)
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
vars.insert("--btn-primary-text".into(), btn_text.to_hex());
|
vars.insert("--btn-primary-text".into(), btn_text.to_hex());
|
||||||
|
|
||||||
// ── Status colors ────────────────────────────────────────────────────
|
// ── Status colors ────────────────────────────────────────────────────
|
||||||
// Use system negative/neutral if available, else fallback to safe defaults
|
// Use system negative/neutral if available, else fallback to safe defaults
|
||||||
let danger = scheme
|
let danger = scheme.fg_negative.unwrap_or(if is_dark {
|
||||||
.fg_negative
|
PaletteColor::new(239, 68, 68)
|
||||||
.unwrap_or(if is_dark {
|
} else {
|
||||||
PaletteColor::new(239, 68, 68)
|
PaletteColor::new(220, 38, 38)
|
||||||
} else {
|
});
|
||||||
PaletteColor::new(220, 38, 38)
|
|
||||||
});
|
|
||||||
vars.insert("--danger".into(), danger.to_hex());
|
vars.insert("--danger".into(), danger.to_hex());
|
||||||
vars.insert("--danger-bg".into(), danger.to_rgba(0.1));
|
vars.insert("--danger-bg".into(), danger.to_rgba(0.1));
|
||||||
vars.insert("--danger-border".into(), danger.to_rgba(0.3));
|
vars.insert("--danger-border".into(), danger.to_rgba(0.3));
|
||||||
@@ -255,13 +247,11 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
danger.to_rgba(if is_dark { 0.2 } else { 0.15 }),
|
danger.to_rgba(if is_dark { 0.2 } else { 0.15 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
let warning = scheme
|
let warning = scheme.fg_neutral.unwrap_or(if is_dark {
|
||||||
.fg_neutral
|
PaletteColor::new(251, 191, 36)
|
||||||
.unwrap_or(if is_dark {
|
} else {
|
||||||
PaletteColor::new(251, 191, 36)
|
PaletteColor::new(217, 119, 6)
|
||||||
} else {
|
});
|
||||||
PaletteColor::new(217, 119, 6)
|
|
||||||
});
|
|
||||||
vars.insert("--warning".into(), warning.to_hex());
|
vars.insert("--warning".into(), warning.to_hex());
|
||||||
vars.insert("--warning-bg".into(), warning.to_rgba(0.1));
|
vars.insert("--warning-bg".into(), warning.to_rgba(0.1));
|
||||||
vars.insert("--warning-border".into(), warning.to_rgba(0.3));
|
vars.insert("--warning-border".into(), warning.to_rgba(0.3));
|
||||||
@@ -285,11 +275,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
vars.insert("--border-strong".into(), border_strong.to_hex());
|
vars.insert("--border-strong".into(), border_strong.to_hex());
|
||||||
|
|
||||||
// ── Alpha tokens ─────────────────────────────────────────────────────
|
// ── Alpha tokens ─────────────────────────────────────────────────────
|
||||||
let alpha_base = if is_dark {
|
let alpha_base = if is_dark { (255, 255, 255) } else { (0, 0, 0) };
|
||||||
(255, 255, 255)
|
|
||||||
} else {
|
|
||||||
(0, 0, 0)
|
|
||||||
};
|
|
||||||
let alpha_levels: &[(f64, &str)] = &[
|
let alpha_levels: &[(f64, &str)] = &[
|
||||||
(0.04, "--alpha-4"),
|
(0.04, "--alpha-4"),
|
||||||
(0.05, "--alpha-5"),
|
(0.05, "--alpha-5"),
|
||||||
@@ -330,11 +316,7 @@ pub fn generate_theme_from_scheme(scheme: &SystemColorScheme) -> GeneratedTheme
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure text has at least WCAG AA contrast (4.5:1) against the background.
|
/// Ensure text has at least WCAG AA contrast (4.5:1) against the background.
|
||||||
fn ensure_text_contrast(
|
fn ensure_text_contrast(text: PaletteColor, bg: &PaletteColor, is_dark: bool) -> PaletteColor {
|
||||||
text: PaletteColor,
|
|
||||||
bg: &PaletteColor,
|
|
||||||
is_dark: bool,
|
|
||||||
) -> PaletteColor {
|
|
||||||
if text.contrast_ratio(bg) >= 4.5 {
|
if text.contrast_ratio(bg) >= 4.5 {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ impl PaletteColor {
|
|||||||
|
|
||||||
/// HSL saturation in [0.0, 1.0].
|
/// HSL saturation in [0.0, 1.0].
|
||||||
pub fn saturation(&self) -> f64 {
|
pub fn saturation(&self) -> f64 {
|
||||||
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0);
|
let (r, g, b) = (
|
||||||
|
self.r as f64 / 255.0,
|
||||||
|
self.g as f64 / 255.0,
|
||||||
|
self.b as f64 / 255.0,
|
||||||
|
);
|
||||||
let max = r.max(g).max(b);
|
let max = r.max(g).max(b);
|
||||||
let min = r.min(g).min(b);
|
let min = r.min(g).min(b);
|
||||||
let delta = max - min;
|
let delta = max - min;
|
||||||
@@ -70,7 +74,11 @@ impl PaletteColor {
|
|||||||
|
|
||||||
/// Convert to HSL (h in [0, 360), s and l in [0, 1]).
|
/// Convert to HSL (h in [0, 360), s and l in [0, 1]).
|
||||||
pub fn to_hsl(&self) -> (f64, f64, f64) {
|
pub fn to_hsl(&self) -> (f64, f64, f64) {
|
||||||
let (r, g, b) = (self.r as f64 / 255.0, self.g as f64 / 255.0, self.b as f64 / 255.0);
|
let (r, g, b) = (
|
||||||
|
self.r as f64 / 255.0,
|
||||||
|
self.g as f64 / 255.0,
|
||||||
|
self.b as f64 / 255.0,
|
||||||
|
);
|
||||||
let max = r.max(g).max(b);
|
let max = r.max(g).max(b);
|
||||||
let min = r.min(g).min(b);
|
let min = r.min(g).min(b);
|
||||||
let l = (max + min) / 2.0;
|
let l = (max + min) / 2.0;
|
||||||
|
|||||||
@@ -210,11 +210,7 @@ fn build_palette(mut clusters: Vec<Cluster>) -> Result<ThemePalette, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the best accent color: prioritize saturation, then ensure WCAG AA contrast.
|
/// Find the best accent color: prioritize saturation, then ensure WCAG AA contrast.
|
||||||
fn find_best_accent(
|
fn find_best_accent(colors: &[PaletteColor], bg: &PaletteColor, is_dark: bool) -> PaletteColor {
|
||||||
colors: &[PaletteColor],
|
|
||||||
bg: &PaletteColor,
|
|
||||||
is_dark: bool,
|
|
||||||
) -> PaletteColor {
|
|
||||||
// Sort candidates by saturation (highest first), skip the dominant (index 0)
|
// Sort candidates by saturation (highest first), skip the dominant (index 0)
|
||||||
let mut candidates: Vec<(usize, f64)> = colors
|
let mut candidates: Vec<(usize, f64)> = colors
|
||||||
.iter()
|
.iter()
|
||||||
@@ -252,11 +248,7 @@ fn find_best_accent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adjust a color's lightness to achieve WCAG AA contrast against a background.
|
/// Adjust a color's lightness to achieve WCAG AA contrast against a background.
|
||||||
fn adjust_for_contrast(
|
fn adjust_for_contrast(color: &PaletteColor, bg: &PaletteColor, is_dark: bool) -> PaletteColor {
|
||||||
color: &PaletteColor,
|
|
||||||
bg: &PaletteColor,
|
|
||||||
is_dark: bool,
|
|
||||||
) -> PaletteColor {
|
|
||||||
let (h, s, l) = color.to_hsl();
|
let (h, s, l) = color.to_hsl();
|
||||||
let direction = if is_dark { 0.05 } else { -0.05 };
|
let direction = if is_dark { 0.05 } else { -0.05 };
|
||||||
|
|
||||||
|
|||||||
@@ -166,13 +166,10 @@ fn get_gnome_accent() -> Result<PaletteColor, String> {
|
|||||||
|
|
||||||
fn get_kde_wallpaper() -> Result<String, String> {
|
fn get_kde_wallpaper() -> Result<String, String> {
|
||||||
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
let home = env::var("HOME").map_err(|_| "HOME not set".to_string())?;
|
||||||
let config_path = format!(
|
let config_path = format!("{}/.config/plasma-org.kde.plasma.desktop-appletsrc", home);
|
||||||
"{}/.config/plasma-org.kde.plasma.desktop-appletsrc",
|
|
||||||
home
|
|
||||||
);
|
|
||||||
|
|
||||||
let content =
|
let content = fs::read_to_string(&config_path)
|
||||||
fs::read_to_string(&config_path).map_err(|e| format!("Cannot read Plasma config: {}", e))?;
|
.map_err(|e| format!("Cannot read Plasma config: {}", e))?;
|
||||||
|
|
||||||
// Look for Image= under [Wallpaper][org.kde.image][General]
|
// Look for Image= under [Wallpaper][org.kde.image][General]
|
||||||
let mut in_wallpaper_section = false;
|
let mut in_wallpaper_section = false;
|
||||||
@@ -375,12 +372,7 @@ fn get_xfce_wallpaper() -> Result<String, String> {
|
|||||||
|
|
||||||
// Fallback: try generic monitor path
|
// Fallback: try generic monitor path
|
||||||
let output = Command::new("xfconf-query")
|
let output = Command::new("xfconf-query")
|
||||||
.args([
|
.args(["-c", "xfce4-desktop", "-l", "-v"])
|
||||||
"-c",
|
|
||||||
"xfce4-desktop",
|
|
||||||
"-l",
|
|
||||||
"-v",
|
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to list xfce4-desktop properties: {}", e))?;
|
.map_err(|e| format!("Failed to list xfce4-desktop properties: {}", e))?;
|
||||||
|
|
||||||
@@ -542,7 +534,11 @@ fn get_kde_color_scheme() -> Result<SystemColorScheme, String> {
|
|||||||
view_bg: read_kde_color_key(&content, "[Colors:View]", "BackgroundNormal"),
|
view_bg: read_kde_color_key(&content, "[Colors:View]", "BackgroundNormal"),
|
||||||
button_bg: read_kde_color_key(&content, "[Colors:Button]", "BackgroundNormal"),
|
button_bg: read_kde_color_key(&content, "[Colors:Button]", "BackgroundNormal"),
|
||||||
header_bg: read_kde_color_key(&content, "[Colors:Header]", "BackgroundNormal"),
|
header_bg: read_kde_color_key(&content, "[Colors:Header]", "BackgroundNormal"),
|
||||||
header_bg_inactive: read_kde_color_key(&content, "[Colors:Header][Inactive]", "BackgroundNormal"),
|
header_bg_inactive: read_kde_color_key(
|
||||||
|
&content,
|
||||||
|
"[Colors:Header][Inactive]",
|
||||||
|
"BackgroundNormal",
|
||||||
|
),
|
||||||
tooltip_bg: read_kde_color_key(&content, "[Colors:Tooltip]", "BackgroundNormal"),
|
tooltip_bg: read_kde_color_key(&content, "[Colors:Tooltip]", "BackgroundNormal"),
|
||||||
|
|
||||||
// Foregrounds
|
// Foregrounds
|
||||||
@@ -555,9 +551,8 @@ fn get_kde_color_scheme() -> Result<SystemColorScheme, String> {
|
|||||||
selection_bg: read_kde_color_key(&content, "[Colors:Selection]", "BackgroundNormal"),
|
selection_bg: read_kde_color_key(&content, "[Colors:Selection]", "BackgroundNormal"),
|
||||||
selection_fg: read_kde_color_key(&content, "[Colors:Selection]", "ForegroundNormal"),
|
selection_fg: read_kde_color_key(&content, "[Colors:Selection]", "ForegroundNormal"),
|
||||||
selection_hover: read_kde_color_key(&content, "[Colors:Selection]", "DecorationHover"),
|
selection_hover: read_kde_color_key(&content, "[Colors:Selection]", "DecorationHover"),
|
||||||
accent: accent_explicit.or_else(|| {
|
accent: accent_explicit
|
||||||
read_kde_color_key(&content, "[Colors:Selection]", "DecorationFocus")
|
.or_else(|| read_kde_color_key(&content, "[Colors:Selection]", "DecorationFocus")),
|
||||||
}),
|
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
fg_link: read_kde_color_key(&content, "[Colors:Window]", "ForegroundLink"),
|
fg_link: read_kde_color_key(&content, "[Colors:Window]", "ForegroundLink"),
|
||||||
@@ -602,7 +597,7 @@ fn get_gnome_color_scheme() -> Result<SystemColorScheme, String> {
|
|||||||
// Build a minimal scheme with GNOME defaults
|
// Build a minimal scheme with GNOME defaults
|
||||||
let (bg, bg_alt, view_bg, btn_bg, fg, fg_inactive) = if is_dark {
|
let (bg, bg_alt, view_bg, btn_bg, fg, fg_inactive) = if is_dark {
|
||||||
(
|
(
|
||||||
PaletteColor::new(36, 36, 36), // Adwaita Dark
|
PaletteColor::new(36, 36, 36), // Adwaita Dark
|
||||||
PaletteColor::new(48, 48, 48),
|
PaletteColor::new(48, 48, 48),
|
||||||
PaletteColor::new(30, 30, 30),
|
PaletteColor::new(30, 30, 30),
|
||||||
PaletteColor::new(60, 60, 60),
|
PaletteColor::new(60, 60, 60),
|
||||||
|
|||||||
+343
-236
File diff suppressed because it is too large.
Load diff
@@ -64,9 +64,18 @@ impl WindowSettingsStore {
|
|||||||
.map_err(|e| format!("Failed to insert default window settings: {}", e))?;
|
.map_err(|e| format!("Failed to insert default window settings: {}", e))?;
|
||||||
|
|
||||||
// Migrations: add window size columns on existing DBs (errors ignored when column exists)
|
// Migrations: add window size columns on existing DBs (errors ignored when column exists)
|
||||||
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN window_width REAL NOT NULL DEFAULT 1280.0", []);
|
let _ = conn.execute(
|
||||||
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN window_height REAL NOT NULL DEFAULT 800.0", []);
|
"ALTER TABLE window_settings ADD COLUMN window_width REAL NOT NULL DEFAULT 1280.0",
|
||||||
let _ = conn.execute("ALTER TABLE window_settings ADD COLUMN is_maximized INTEGER NOT NULL DEFAULT 0", []);
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE window_settings ADD COLUMN window_height REAL NOT NULL DEFAULT 800.0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE window_settings ADD COLUMN is_maximized INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
info!("[WindowSettings] Database initialized");
|
info!("[WindowSettings] Database initialized");
|
||||||
|
|
||||||
@@ -101,7 +110,8 @@ impl WindowSettingsStore {
|
|||||||
} else {
|
} else {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[WindowSettings] Corrupt size in DB: {}x{}, using defaults",
|
"[WindowSettings] Corrupt size in DB: {}x{}, using defaults",
|
||||||
window_width, window_height
|
window_width,
|
||||||
|
window_height
|
||||||
);
|
);
|
||||||
(defaults.window_width, defaults.window_height)
|
(defaults.window_width, defaults.window_height)
|
||||||
};
|
};
|
||||||
@@ -131,7 +141,8 @@ impl WindowSettingsStore {
|
|||||||
if !is_valid_window_size(width, height) {
|
if !is_valid_window_size(width, height) {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[WindowSettings] Ignoring invalid window size: {}x{}",
|
"[WindowSettings] Ignoring invalid window size: {}x{}",
|
||||||
width, height
|
width,
|
||||||
|
height
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -225,9 +236,12 @@ impl WindowSettingsState {
|
|||||||
fn is_valid_window_size(width: f64, height: f64) -> bool {
|
fn is_valid_window_size(width: f64, height: f64) -> bool {
|
||||||
const MIN: f64 = 200.0;
|
const MIN: f64 = 200.0;
|
||||||
const MAX: f64 = 32767.0;
|
const MAX: f64 = 32767.0;
|
||||||
width.is_finite() && height.is_finite()
|
width.is_finite()
|
||||||
&& width >= MIN && width <= MAX
|
&& height.is_finite()
|
||||||
&& height >= MIN && height <= MAX
|
&& width >= MIN
|
||||||
|
&& width <= MAX
|
||||||
|
&& height >= MIN
|
||||||
|
&& height <= MAX
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tauri commands
|
// Tauri commands
|
||||||
|
|||||||
@@ -586,6 +586,10 @@ impl CoreBridge {
|
|||||||
pub fn player(&self) -> Arc<Player> {
|
pub fn player(&self) -> Arc<Player> {
|
||||||
self.core.player()
|
self.core.player()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_loaded_audio(&self) -> bool {
|
||||||
|
self.core.player().has_loaded_audio()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State wrapper for Tauri's managed state
|
/// State wrapper for Tauri's managed state
|
||||||
|
|||||||
@@ -629,7 +629,10 @@ mod tests {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Skipping: cannot load credentials in this environment: {}", e);
|
eprintln!(
|
||||||
|
"Skipping: cannot load credentials in this environment: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
let _ = clear_qobuz_credentials();
|
let _ = clear_qobuz_credentials();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ impl ImageCacheService {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs() as i64;
|
.as_secs() as i64;
|
||||||
|
|
||||||
std::fs::write(&path, bytes)
|
std::fs::write(&path, bytes).map_err(|e| format!("Failed to write cached image: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to write cached image: {}", e))?;
|
|
||||||
|
|
||||||
let file_size = bytes.len() as i64;
|
let file_size = bytes.len() as i64;
|
||||||
self.conn
|
self.conn
|
||||||
|
|||||||
@@ -291,8 +291,13 @@ impl LastFmClient {
|
|||||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
// Parse the Last.fm response format
|
// Parse the Last.fm response format
|
||||||
let data: serde_json::Value = serde_json::from_str(&text)
|
let data: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
|
||||||
.map_err(|e| format!("Failed to parse response: {} - Raw: {}", e, &text[..text.len().min(200)]))?;
|
format!(
|
||||||
|
"Failed to parse response: {} - Raw: {}",
|
||||||
|
e,
|
||||||
|
&text[..text.len().min(200)]
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Handle Last.fm error responses
|
// Handle Last.fm error responses
|
||||||
if let Some(error) = data.get("error") {
|
if let Some(error) = data.get("error") {
|
||||||
@@ -313,13 +318,15 @@ impl LastFmClient {
|
|||||||
let name = item.get("name")?.as_str()?.to_string();
|
let name = item.get("name")?.as_str()?.to_string();
|
||||||
let match_score: f64 = item
|
let match_score: f64 = item
|
||||||
.get("match")
|
.get("match")
|
||||||
.and_then(|m| m.as_str().or_else(|| m.as_f64().map(|_| "")).and_then(|s| {
|
.and_then(|m| {
|
||||||
if s.is_empty() {
|
m.as_str().or_else(|| m.as_f64().map(|_| "")).and_then(|s| {
|
||||||
item.get("match").and_then(|m| m.as_f64())
|
if s.is_empty() {
|
||||||
} else {
|
item.get("match").and_then(|m| m.as_f64())
|
||||||
s.parse().ok()
|
} else {
|
||||||
}
|
s.parse().ok()
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let mbid = item
|
let mbid = item
|
||||||
.get("mbid")
|
.get("mbid")
|
||||||
|
|||||||
+78
-37
@@ -28,7 +28,6 @@ pub mod credentials;
|
|||||||
pub mod discogs;
|
pub mod discogs;
|
||||||
pub mod flatpak;
|
pub mod flatpak;
|
||||||
pub mod image_cache;
|
pub mod image_cache;
|
||||||
pub mod snap;
|
|
||||||
pub mod lastfm;
|
pub mod lastfm;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod listenbrainz;
|
pub mod listenbrainz;
|
||||||
@@ -40,6 +39,7 @@ pub mod musicbrainz;
|
|||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod offline_cache;
|
pub mod offline_cache;
|
||||||
|
pub mod pdf_viewer;
|
||||||
pub mod playback_context;
|
pub mod playback_context;
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
pub mod player;
|
pub mod player;
|
||||||
@@ -50,14 +50,14 @@ pub mod radio_engine;
|
|||||||
pub mod reco_store;
|
pub mod reco_store;
|
||||||
pub mod session_store;
|
pub mod session_store;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
pub mod snap;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
pub mod updates;
|
pub mod updates;
|
||||||
pub mod user_data;
|
pub mod user_data;
|
||||||
pub mod visualizer;
|
pub mod visualizer;
|
||||||
pub mod pdf_viewer;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use rustls::crypto::{aws_lc_rs, CryptoProvider};
|
use rustls::crypto::{aws_lc_rs, CryptoProvider};
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
@@ -214,7 +214,10 @@ fn setup_kwin_window_rule() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rule_num = if let Some(num) = qbz_rule_group {
|
let rule_num = if let Some(num) = qbz_rule_group {
|
||||||
log::info!("KWin window rule for QBZ already exists (group {}), updating", num);
|
log::info!(
|
||||||
|
"KWin window rule for QBZ already exists (group {}), updating",
|
||||||
|
num
|
||||||
|
);
|
||||||
num
|
num
|
||||||
} else {
|
} else {
|
||||||
existing_count + 1
|
existing_count + 1
|
||||||
@@ -225,16 +228,24 @@ fn setup_kwin_window_rule() -> Result<(), String> {
|
|||||||
let rules: &[(&str, &str)] = &[
|
let rules: &[(&str, &str)] = &[
|
||||||
("Description", "QBZ Native Title Bar"),
|
("Description", "QBZ Native Title Bar"),
|
||||||
("noborder", "false"),
|
("noborder", "false"),
|
||||||
("noborderrule", "2"), // 2 = Force
|
("noborderrule", "2"), // 2 = Force
|
||||||
("wmclass", "qbz"),
|
("wmclass", "qbz"),
|
||||||
("wmclasscomplete", "false"),
|
("wmclasscomplete", "false"),
|
||||||
("wmclassmatch", "1"), // 1 = Exact match
|
("wmclassmatch", "1"), // 1 = Exact match
|
||||||
("types", "1"), // 1 = Normal windows
|
("types", "1"), // 1 = Normal windows
|
||||||
];
|
];
|
||||||
|
|
||||||
for (key, value) in rules {
|
for (key, value) in rules {
|
||||||
let output = std::process::Command::new("kwriteconfig6")
|
let output = std::process::Command::new("kwriteconfig6")
|
||||||
.args(["--file", "kwinrulesrc", "--group", &group, "--key", key, value])
|
.args([
|
||||||
|
"--file",
|
||||||
|
"kwinrulesrc",
|
||||||
|
"--group",
|
||||||
|
&group,
|
||||||
|
"--key",
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("kwriteconfig6 failed: {}", e))?;
|
.map_err(|e| format!("kwriteconfig6 failed: {}", e))?;
|
||||||
|
|
||||||
@@ -251,7 +262,15 @@ fn setup_kwin_window_rule() -> Result<(), String> {
|
|||||||
if qbz_rule_group.is_none() {
|
if qbz_rule_group.is_none() {
|
||||||
let new_count = (existing_count + 1).to_string();
|
let new_count = (existing_count + 1).to_string();
|
||||||
let output = std::process::Command::new("kwriteconfig6")
|
let output = std::process::Command::new("kwriteconfig6")
|
||||||
.args(["--file", "kwinrulesrc", "--group", "General", "--key", "count", &new_count])
|
.args([
|
||||||
|
"--file",
|
||||||
|
"kwinrulesrc",
|
||||||
|
"--group",
|
||||||
|
"General",
|
||||||
|
"--key",
|
||||||
|
"count",
|
||||||
|
&new_count,
|
||||||
|
])
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("kwriteconfig6 count update failed: {}", e))?;
|
.map_err(|e| format!("kwriteconfig6 count update failed: {}", e))?;
|
||||||
|
|
||||||
@@ -276,7 +295,10 @@ fn setup_kwin_window_rule() -> Result<(), String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("KWin window rule set for native title bar (group {})", rule_num);
|
log::info!(
|
||||||
|
"KWin window rule set for native title bar (group {})",
|
||||||
|
rule_num
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +346,15 @@ fn remove_kwin_window_rule() {
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
let new_count = (count - 1).to_string();
|
let new_count = (count - 1).to_string();
|
||||||
let _ = std::process::Command::new("kwriteconfig6")
|
let _ = std::process::Command::new("kwriteconfig6")
|
||||||
.args(["--file", "kwinrulesrc", "--group", "General", "--key", "count", &new_count])
|
.args([
|
||||||
|
"--file",
|
||||||
|
"kwinrulesrc",
|
||||||
|
"--group",
|
||||||
|
"General",
|
||||||
|
"--key",
|
||||||
|
"count",
|
||||||
|
&new_count,
|
||||||
|
])
|
||||||
.output();
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,8 +386,14 @@ fn get_screen_resolution() -> Option<(f64, f64)> {
|
|||||||
if let Some(dims) = trimmed.split_whitespace().nth(1) {
|
if let Some(dims) = trimmed.split_whitespace().nth(1) {
|
||||||
let parts: Vec<&str> = dims.split('x').collect();
|
let parts: Vec<&str> = dims.split('x').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
if let (Ok(w), Ok(h)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
|
if let (Ok(w), Ok(h)) =
|
||||||
log::info!("Screen resolution detected via xdpyinfo: {}x{}", w as u32, h as u32);
|
(parts[0].parse::<f64>(), parts[1].parse::<f64>())
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Screen resolution detected via xdpyinfo: {}x{}",
|
||||||
|
w as u32,
|
||||||
|
h as u32
|
||||||
|
);
|
||||||
return Some((w, h));
|
return Some((w, h));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,8 +417,14 @@ fn get_screen_resolution() -> Option<(f64, f64)> {
|
|||||||
let res_part = token.split('+').next().unwrap_or("");
|
let res_part = token.split('+').next().unwrap_or("");
|
||||||
let parts: Vec<&str> = res_part.split('x').collect();
|
let parts: Vec<&str> = res_part.split('x').collect();
|
||||||
if parts.len() == 2 {
|
if parts.len() == 2 {
|
||||||
if let (Ok(w), Ok(h)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
|
if let (Ok(w), Ok(h)) =
|
||||||
log::info!("Screen resolution detected via xrandr: {}x{}", w as u32, h as u32);
|
(parts[0].parse::<f64>(), parts[1].parse::<f64>())
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Screen resolution detected via xrandr: {}x{}",
|
||||||
|
w as u32,
|
||||||
|
h as u32
|
||||||
|
);
|
||||||
return Some((w, h));
|
return Some((w, h));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,9 +449,7 @@ fn apply_linux_webkit_workarounds() {
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if force_gpu {
|
if force_gpu {
|
||||||
log::warn!(
|
log::warn!("QBZ_WEBKIT_FORCE_GPU is enabled; skipping Linux WebKit safety workarounds");
|
||||||
"QBZ_WEBKIT_FORCE_GPU is enabled; skipping Linux WebKit safety workarounds"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,9 +614,12 @@ pub fn run() {
|
|||||||
if saved_win_width > max_w || saved_win_height > max_h {
|
if saved_win_width > max_w || saved_win_height > max_h {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Window size {}x{} exceeds screen {}x{}, clamping to {}x{}",
|
"Window size {}x{} exceeds screen {}x{}, clamping to {}x{}",
|
||||||
saved_win_width as u32, saved_win_height as u32,
|
saved_win_width as u32,
|
||||||
screen_w as u32, screen_h as u32,
|
saved_win_height as u32,
|
||||||
max_w as u32, max_h as u32
|
screen_w as u32,
|
||||||
|
screen_h as u32,
|
||||||
|
max_w as u32,
|
||||||
|
max_h as u32
|
||||||
);
|
);
|
||||||
saved_win_width = saved_win_width.min(max_w);
|
saved_win_width = saved_win_width.min(max_w);
|
||||||
saved_win_height = saved_win_height.min(max_h);
|
saved_win_height = saved_win_height.min(max_h);
|
||||||
@@ -675,22 +718,20 @@ pub fn run() {
|
|||||||
let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new();
|
let listenbrainz_v2_state = integrations_v2::ListenBrainzV2State::new();
|
||||||
let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new();
|
let musicbrainz_v2_state = integrations_v2::MusicBrainzV2State::new();
|
||||||
let lastfm_v2_state = integrations_v2::LastFmV2State::new();
|
let lastfm_v2_state = integrations_v2::LastFmV2State::new();
|
||||||
let image_cache_settings_state = config::ImageCacheSettingsState::new()
|
let image_cache_settings_state = config::ImageCacheSettingsState::new().unwrap_or_else(|e| {
|
||||||
.unwrap_or_else(|e| {
|
log::warn!(
|
||||||
log::warn!(
|
"Failed to initialize image cache settings: {}. Using empty state.",
|
||||||
"Failed to initialize image cache settings: {}. Using empty state.",
|
e
|
||||||
e
|
);
|
||||||
);
|
config::ImageCacheSettingsState::new_empty()
|
||||||
config::ImageCacheSettingsState::new_empty()
|
});
|
||||||
});
|
let image_cache_state = image_cache::ImageCacheState::new().unwrap_or_else(|e| {
|
||||||
let image_cache_state = image_cache::ImageCacheState::new()
|
log::warn!(
|
||||||
.unwrap_or_else(|e| {
|
"Failed to initialize image cache: {}. Using empty state.",
|
||||||
log::warn!(
|
e
|
||||||
"Failed to initialize image cache: {}. Using empty state.",
|
);
|
||||||
e
|
image_cache::ImageCacheState::new_empty()
|
||||||
);
|
});
|
||||||
image_cache::ImageCacheState::new_empty()
|
|
||||||
});
|
|
||||||
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
|
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
|
|||||||
@@ -2627,13 +2627,14 @@ pub async fn library_backfill_downloads(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track doesn't exist - extract track/disc number from file tags
|
// Track doesn't exist - extract track/disc number from file tags
|
||||||
let (track_num, disc_num) = match MetadataExtractor::extract(std::path::Path::new(&file_path)) {
|
let (track_num, disc_num) =
|
||||||
Ok(meta) => (meta.track_number, meta.disc_number),
|
match MetadataExtractor::extract(std::path::Path::new(&file_path)) {
|
||||||
Err(e) => {
|
Ok(meta) => (meta.track_number, meta.disc_number),
|
||||||
log::warn!("Could not extract metadata from {}: {}", file_path, e);
|
Err(e) => {
|
||||||
(None, None)
|
log::warn!("Could not extract metadata from {}: {}", file_path, e);
|
||||||
}
|
(None, None)
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Insert as new
|
// Insert as new
|
||||||
match library_db.insert_qobuz_cached_track_direct(
|
match library_db.insert_qobuz_cached_track_direct(
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ fn main() {
|
|||||||
eprintln!("[QBZ] - gsk_renderer: auto");
|
eprintln!("[QBZ] - gsk_renderer: auto");
|
||||||
eprintln!("[QBZ] - force_dmabuf: false");
|
eprintln!("[QBZ] - force_dmabuf: false");
|
||||||
eprintln!("[QBZ] You can now start QBZ normally.");
|
eprintln!("[QBZ] You can now start QBZ normally.");
|
||||||
eprintln!("[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings.");
|
eprintln!(
|
||||||
|
"[QBZ] Tip: Run 'qbz --autoconfig-graphics' to auto-detect optimal settings."
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
eprintln!("[QBZ] Some settings could not be reset:");
|
eprintln!("[QBZ] Some settings could not be reset:");
|
||||||
for e in &errors {
|
for e in &errors {
|
||||||
@@ -254,7 +256,10 @@ fn main() {
|
|||||||
// Hardware acceleration: DB value is the default, env var overrides.
|
// Hardware acceleration: DB value is the default, env var overrides.
|
||||||
// QBZ_HARDWARE_ACCEL=0 is the nuclear opt-out that disables all GPU
|
// QBZ_HARDWARE_ACCEL=0 is the nuclear opt-out that disables all GPU
|
||||||
// compositing and DMA-BUF everywhere.
|
// compositing and DMA-BUF everywhere.
|
||||||
let hw_accel_db = graphics_db.as_ref().map(|s| s.hardware_acceleration).unwrap_or(true);
|
let hw_accel_db = graphics_db
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.hardware_acceleration)
|
||||||
|
.unwrap_or(true);
|
||||||
let hardware_accel = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
|
let hardware_accel = match std::env::var("QBZ_HARDWARE_ACCEL").as_deref() {
|
||||||
Ok("0") => {
|
Ok("0") => {
|
||||||
qbz_nix_lib::logging::log_startup(
|
qbz_nix_lib::logging::log_startup(
|
||||||
@@ -481,9 +486,7 @@ fn main() {
|
|||||||
"[QBZ] Wayland+NVIDIA: compositing mode disabled (prevents protocol errors)",
|
"[QBZ] Wayland+NVIDIA: compositing mode disabled (prevents protocol errors)",
|
||||||
);
|
);
|
||||||
} else if is_wayland && !force_x11 {
|
} else if is_wayland && !force_x11 {
|
||||||
qbz_nix_lib::logging::log_startup(
|
qbz_nix_lib::logging::log_startup("[QBZ] Wayland: compositing mode enabled");
|
||||||
"[QBZ] Wayland: compositing mode enabled",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DMA-BUF renderer control ---
|
// --- DMA-BUF renderer control ---
|
||||||
@@ -534,9 +537,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Catch GTK initialization panics and show a recovery message
|
// Catch GTK initialization panics and show a recovery message
|
||||||
let result = std::panic::catch_unwind(|| {
|
let result = std::panic::catch_unwind(|| qbz_nix_lib::run());
|
||||||
qbz_nix_lib::run()
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(panic_info) = result {
|
if let Err(panic_info) = result {
|
||||||
let msg = if let Some(s) = panic_info.downcast_ref::<String>() {
|
let msg = if let Some(s) = panic_info.downcast_ref::<String>() {
|
||||||
|
|||||||
@@ -390,7 +390,10 @@ impl MusicBrainzCache {
|
|||||||
// ============ Scene Discovery Cache ============
|
// ============ Scene Discovery Cache ============
|
||||||
|
|
||||||
/// Get cached scene discovery results
|
/// Get cached scene discovery results
|
||||||
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> {
|
pub fn get_scene_cache(
|
||||||
|
&self,
|
||||||
|
cache_key: &str,
|
||||||
|
) -> Result<Option<LocationDiscoveryResponse>, String> {
|
||||||
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
|
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
|
||||||
|
|
||||||
let result: Option<String> = self
|
let result: Option<String> = self
|
||||||
@@ -413,10 +416,14 @@ impl MusicBrainzCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cache scene discovery results
|
/// Cache scene discovery results
|
||||||
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> {
|
pub fn set_scene_cache(
|
||||||
|
&self,
|
||||||
|
cache_key: &str,
|
||||||
|
data: &LocationDiscoveryResponse,
|
||||||
|
) -> Result<(), String> {
|
||||||
let fetched_at = Self::current_timestamp();
|
let fetched_at = Self::current_timestamp();
|
||||||
let json = serde_json::to_string(data)
|
let json =
|
||||||
.map_err(|e| format!("Failed to serialize scene: {}", e))?;
|
serde_json::to_string(data).map_err(|e| format!("Failed to serialize scene: {}", e))?;
|
||||||
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
|
|||||||
@@ -296,10 +296,7 @@ impl MusicBrainzClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch artist tags only (lightweight, no relations)
|
/// Fetch artist tags only (lightweight, no relations)
|
||||||
pub async fn get_artist_tags(
|
pub async fn get_artist_tags(&self, mbid: &str) -> Result<Vec<String>, String> {
|
||||||
&self,
|
|
||||||
mbid: &str,
|
|
||||||
) -> Result<Vec<String>, String> {
|
|
||||||
if !self.is_enabled().await {
|
if !self.is_enabled().await {
|
||||||
return Err("MusicBrainz integration is disabled".to_string());
|
return Err("MusicBrainz integration is disabled".to_string());
|
||||||
}
|
}
|
||||||
@@ -335,7 +332,10 @@ impl MusicBrainzClient {
|
|||||||
// Sort by vote count descending — highest voted tag = primary genre
|
// Sort by vote count descending — highest voted tag = primary genre
|
||||||
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
|
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
|
||||||
|
|
||||||
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect())
|
Ok(tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|tag| tag.name.to_lowercase())
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Search artists by tag (genre)
|
/// Search artists by tag (genre)
|
||||||
@@ -363,7 +363,11 @@ impl MusicBrainzClient {
|
|||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
log::debug!("MusicBrainz artist search by tag '{}' (limit {})", tag, limit);
|
log::debug!(
|
||||||
|
"MusicBrainz artist search by tag '{}' (limit {})",
|
||||||
|
tag,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@@ -414,10 +418,7 @@ impl MusicBrainzClient {
|
|||||||
let search_area = country.unwrap_or(area_name);
|
let search_area = country.unwrap_or(area_name);
|
||||||
let escaped_area = Self::escape_query(search_area);
|
let escaped_area = Self::escape_query(search_area);
|
||||||
|
|
||||||
let query = format!(
|
let query = format!("tag:\"{}\" AND area:\"{}\"", escaped_tag, escaped_area);
|
||||||
"tag:\"{}\" AND area:\"{}\"",
|
|
||||||
escaped_tag, escaped_area
|
|
||||||
);
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/artist?query={}&fmt=json&limit={}&offset={}",
|
"{}/artist?query={}&fmt=json&limit={}&offset={}",
|
||||||
base_url,
|
base_url,
|
||||||
@@ -426,10 +427,7 @@ impl MusicBrainzClient {
|
|||||||
offset
|
offset
|
||||||
);
|
);
|
||||||
|
|
||||||
log::debug!(
|
log::debug!("MusicBrainz artist search: {}", query);
|
||||||
"MusicBrainz artist search: {}",
|
|
||||||
query
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@@ -633,7 +631,9 @@ impl MusicBrainzClient {
|
|||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"MusicBrainz browse artists by area {} (limit {}, offset {})",
|
"MusicBrainz browse artists by area {} (limit {}, offset {})",
|
||||||
area_id, limit, offset
|
area_id,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
);
|
);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
@@ -720,10 +720,7 @@ impl MusicBrainzClient {
|
|||||||
self.rate_limiter.wait().await;
|
self.rate_limiter.wait().await;
|
||||||
|
|
||||||
let base_url = self.base_url().await;
|
let base_url = self.base_url().await;
|
||||||
let url = format!(
|
let url = format!("{}/area/{}?inc=area-rels&fmt=json", base_url, area_id);
|
||||||
"{}/area/{}?inc=area-rels&fmt=json",
|
|
||||||
base_url, area_id
|
|
||||||
);
|
|
||||||
|
|
||||||
log::debug!("MusicBrainz area lookup with relations: {}", area_id);
|
log::debug!("MusicBrainz area lookup with relations: {}", area_id);
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,13 @@ pub fn genre_summary(seeds: &AffinitySeeds) -> String {
|
|||||||
if seeds.genres.is_empty() {
|
if seeds.genres.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
seeds.genres.iter().take(3).cloned().collect::<Vec<_>>().join(" / ")
|
seeds
|
||||||
|
.genres
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" / ")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -276,10 +282,22 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_noisy_tags_filtered() {
|
fn test_noisy_tags_filtered() {
|
||||||
let tags = vec![
|
let tags = vec![
|
||||||
Tag { name: "rock".to_string(), count: Some(10) },
|
Tag {
|
||||||
Tag { name: "seen live".to_string(), count: Some(8) },
|
name: "rock".to_string(),
|
||||||
Tag { name: "awesome".to_string(), count: Some(5) },
|
count: Some(10),
|
||||||
Tag { name: "grunge".to_string(), count: Some(4) },
|
},
|
||||||
|
Tag {
|
||||||
|
name: "seen live".to_string(),
|
||||||
|
count: Some(8),
|
||||||
|
},
|
||||||
|
Tag {
|
||||||
|
name: "awesome".to_string(),
|
||||||
|
count: Some(5),
|
||||||
|
},
|
||||||
|
Tag {
|
||||||
|
name: "grunge".to_string(),
|
||||||
|
count: Some(4),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let seeds = extract_affinity_seeds(&tags);
|
let seeds = extract_affinity_seeds(&tags);
|
||||||
|
|||||||
@@ -246,8 +246,10 @@ pub fn compute_affinity_score(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalize candidate tags for comparison
|
// Normalize candidate tags for comparison
|
||||||
let candidate_normalized: HashSet<String> =
|
let candidate_normalized: HashSet<String> = candidate_tags
|
||||||
candidate_tags.iter().map(|tag| normalize_genre(tag)).collect();
|
.iter()
|
||||||
|
.map(|tag| normalize_genre(tag))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Core genre overlap
|
// Core genre overlap
|
||||||
let core_overlap = source_seeds
|
let core_overlap = source_seeds
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ use crate::api::models::Quality;
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
use super::{CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, TrackCacheInfo};
|
use super::{CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, TrackCacheInfo};
|
||||||
|
use crate::library::MetadataExtractor;
|
||||||
use crate::offline_cache::metadata::{
|
use crate::offline_cache::metadata::{
|
||||||
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
|
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
|
||||||
write_flac_tags,
|
write_flac_tags,
|
||||||
};
|
};
|
||||||
use crate::library::MetadataExtractor;
|
|
||||||
use crate::offline_cache::OfflineCacheState;
|
use crate::offline_cache::OfflineCacheState;
|
||||||
|
|
||||||
/// Post-process a cached track: fetch metadata, tag FLAC, embed artwork, organize files
|
/// Post-process a cached track: fetch metadata, tag FLAC, embed artwork, organize files
|
||||||
@@ -314,7 +314,11 @@ pub async fn cache_track_for_offline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Caching failed for track {} (attempt 1): {}. Retrying with fresh URL...", track_id, e);
|
log::warn!(
|
||||||
|
"Caching failed for track {} (attempt 1): {}. Retrying with fresh URL...",
|
||||||
|
track_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
|
||||||
// Retry with a fresh stream URL — CDN edge may have returned premature EOF
|
// Retry with a fresh stream URL — CDN edge may have returned premature EOF
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
@@ -336,7 +340,11 @@ pub async fn cache_track_for_offline(
|
|||||||
|
|
||||||
match retry_result {
|
match retry_result {
|
||||||
Ok(size) => {
|
Ok(size) => {
|
||||||
log::info!("Caching complete for track {} (retry succeeded): {} bytes", track_id, size);
|
log::info!(
|
||||||
|
"Caching complete for track {} (retry succeeded): {} bytes",
|
||||||
|
track_id,
|
||||||
|
size
|
||||||
|
);
|
||||||
if let Some(db_guard) = db.lock().await.as_ref() {
|
if let Some(db_guard) = db.lock().await.as_ref() {
|
||||||
let _ = db_guard.mark_complete(track_id, size);
|
let _ = db_guard.mark_complete(track_id, size);
|
||||||
}
|
}
|
||||||
@@ -351,9 +359,14 @@ pub async fn cache_track_for_offline(
|
|||||||
let file_path_str = file_path.to_string_lossy().to_string();
|
let file_path_str = file_path.to_string_lossy().to_string();
|
||||||
let qobuz_client = client.read().await;
|
let qobuz_client = client.read().await;
|
||||||
if let Ok(new_path) = post_process_cached_track(
|
if let Ok(new_path) = post_process_cached_track(
|
||||||
track_id, &file_path_str, &offline_root,
|
track_id,
|
||||||
&*qobuz_client, library_db.clone(),
|
&file_path_str,
|
||||||
).await {
|
&offline_root,
|
||||||
|
&*qobuz_client,
|
||||||
|
library_db.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
if let Some(db_guard) = db.lock().await.as_ref() {
|
if let Some(db_guard) = db.lock().await.as_ref() {
|
||||||
let _ = db_guard.update_file_path(track_id, &new_path);
|
let _ = db_guard.update_file_path(track_id, &new_path);
|
||||||
}
|
}
|
||||||
@@ -369,7 +382,11 @@ pub async fn cache_track_for_offline(
|
|||||||
Err(e2) => {
|
Err(e2) => {
|
||||||
log::error!("Caching failed for track {} (attempt 2): {}", track_id, e2);
|
log::error!("Caching failed for track {} (attempt 2): {}", track_id, e2);
|
||||||
if let Some(db_guard) = db.lock().await.as_ref() {
|
if let Some(db_guard) = db.lock().await.as_ref() {
|
||||||
let _ = db_guard.update_status(track_id, OfflineCacheStatus::Failed, Some(&e2));
|
let _ = db_guard.update_status(
|
||||||
|
track_id,
|
||||||
|
OfflineCacheStatus::Failed,
|
||||||
|
Some(&e2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"offline:caching_failed",
|
"offline:caching_failed",
|
||||||
@@ -933,13 +950,18 @@ pub async fn sync_offline_cache_to_library(
|
|||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
// Extract track/disc number from file tags
|
// Extract track/disc number from file tags
|
||||||
let (track_num, disc_num) = match MetadataExtractor::extract(std::path::Path::new(&track.file_path)) {
|
let (track_num, disc_num) =
|
||||||
Ok(meta) => (meta.track_number, meta.disc_number),
|
match MetadataExtractor::extract(std::path::Path::new(&track.file_path)) {
|
||||||
Err(e) => {
|
Ok(meta) => (meta.track_number, meta.disc_number),
|
||||||
log::warn!("Could not extract metadata from {}: {}", track.file_path, e);
|
Err(e) => {
|
||||||
(None, None)
|
log::warn!(
|
||||||
}
|
"Could not extract metadata from {}: {}",
|
||||||
};
|
track.file_path,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Insert into library with metadata from file tags
|
// Insert into library with metadata from file tags
|
||||||
match library_db.insert_qobuz_cached_track_direct(
|
match library_db.insert_qobuz_cached_track_direct(
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ impl StreamFetcher {
|
|||||||
msg.push_str(&format!(" | caused by: {}", cause));
|
msg.push_str(&format!(" | caused by: {}", cause));
|
||||||
source = cause.source();
|
source = cause.source();
|
||||||
}
|
}
|
||||||
log::error!("[Offline] Download error for track {} after {} bytes: {}", track_id, cached, msg);
|
log::error!(
|
||||||
|
"[Offline] Download error for track {} after {} bytes: {}",
|
||||||
|
track_id,
|
||||||
|
cached,
|
||||||
|
msg
|
||||||
|
);
|
||||||
msg
|
msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -91,24 +91,23 @@ pub async fn v2_booklet_open(
|
|||||||
|
|
||||||
// Save to temp file
|
// Save to temp file
|
||||||
let temp_dir = booklet_temp_dir();
|
let temp_dir = booklet_temp_dir();
|
||||||
std::fs::create_dir_all(&temp_dir)
|
std::fs::create_dir_all(&temp_dir).map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
|
||||||
|
|
||||||
let file_name = format!("{}.pdf", uuid::Uuid::new_v4());
|
let file_name = format!("{}.pdf", uuid::Uuid::new_v4());
|
||||||
let temp_path = temp_dir.join(&file_name);
|
let temp_path = temp_dir.join(&file_name);
|
||||||
|
|
||||||
std::fs::write(&temp_path, &bytes)
|
std::fs::write(&temp_path, &bytes).map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
|
||||||
|
|
||||||
// Open with MuPDF and get page info (blocking operation)
|
// Open with MuPDF and get page info (blocking operation)
|
||||||
let path_str = temp_path.to_string_lossy().to_string();
|
let path_str = temp_path.to_string_lossy().to_string();
|
||||||
let info = tokio::task::spawn_blocking(move || -> Result<BookletInfo, String> {
|
let info = tokio::task::spawn_blocking(move || -> Result<BookletInfo, String> {
|
||||||
let document = Document::open(&path_str)
|
let document =
|
||||||
.map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
Document::open(&path_str).map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
||||||
|
|
||||||
let num_pages = document
|
let num_pages = document
|
||||||
.page_count()
|
.page_count()
|
||||||
.map_err(|e| format!("Failed to get page count: {:?}", e))? as u32;
|
.map_err(|e| format!("Failed to get page count: {:?}", e))?
|
||||||
|
as u32;
|
||||||
|
|
||||||
let mut page_sizes = Vec::with_capacity(num_pages as usize);
|
let mut page_sizes = Vec::with_capacity(num_pages as usize);
|
||||||
for i in 0..num_pages {
|
for i in 0..num_pages {
|
||||||
@@ -170,8 +169,7 @@ pub async fn v2_booklet_render_page(
|
|||||||
let dpi = dpi.max(36).min(600); // Clamp DPI to sane range
|
let dpi = dpi.max(36).min(600); // Clamp DPI to sane range
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<RenderedPage, String> {
|
tokio::task::spawn_blocking(move || -> Result<RenderedPage, String> {
|
||||||
let document = Document::open(&path)
|
let document = Document::open(&path).map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
||||||
.map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
|
||||||
|
|
||||||
let page_index = (page - 1) as i32;
|
let page_index = (page - 1) as i32;
|
||||||
let pdf_page = document
|
let pdf_page = document
|
||||||
@@ -196,9 +194,15 @@ pub async fn v2_booklet_render_page(
|
|||||||
let mut m = Matrix::new_scale(scale, scale);
|
let mut m = Matrix::new_scale(scale, scale);
|
||||||
m.concat(Matrix::new_rotate(rotation as f32));
|
m.concat(Matrix::new_rotate(rotation as f32));
|
||||||
match rotation % 360 {
|
match rotation % 360 {
|
||||||
90 => { m.concat(Matrix::new_translate(h, 0.0)); }
|
90 => {
|
||||||
180 => { m.concat(Matrix::new_translate(w, h)); }
|
m.concat(Matrix::new_translate(h, 0.0));
|
||||||
270 => { m.concat(Matrix::new_translate(0.0, w)); }
|
}
|
||||||
|
180 => {
|
||||||
|
m.concat(Matrix::new_translate(w, h));
|
||||||
|
}
|
||||||
|
270 => {
|
||||||
|
m.concat(Matrix::new_translate(0.0, w));
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
};
|
};
|
||||||
m
|
m
|
||||||
@@ -246,17 +250,14 @@ pub async fn v2_booklet_save(
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::copy(&src, &dest)
|
std::fs::copy(&src, &dest).map_err(|e| format!("Failed to save booklet: {}", e))?;
|
||||||
.map_err(|e| format!("Failed to save booklet: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up the current booklet temp file.
|
/// Clean up the current booklet temp file.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn v2_booklet_close(
|
pub async fn v2_booklet_close(state: tauri::State<'_, BookletState>) -> Result<(), String> {
|
||||||
state: tauri::State<'_, BookletState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
||||||
if let Some(old_path) = path_lock.take() {
|
if let Some(old_path) = path_lock.take() {
|
||||||
let _ = std::fs::remove_file(&old_path);
|
let _ = std::fs::remove_file(&old_path);
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ fn create_output_stream_with_config(
|
|||||||
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
// MixerDeviceSink has zero internal buffering, so CPAL's buffer is the
|
||||||
// ONLY buffer between the mixer and audio hardware.
|
// ONLY buffer between the mixer and audio hardware.
|
||||||
let cpal_buffer_size = if exclusive_mode {
|
let cpal_buffer_size = if exclusive_mode {
|
||||||
BufferSize::Fixed(512) // Low latency for exclusive mode
|
BufferSize::Fixed(512) // Low latency for exclusive mode
|
||||||
} else {
|
} else {
|
||||||
// ~100ms buffer, matching old vendored cpal period size.
|
// ~100ms buffer, matching old vendored cpal period size.
|
||||||
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
// Prevents underruns at high sample rates (192kHz = 19200 frames).
|
||||||
@@ -440,9 +440,11 @@ fn create_output_stream_with_config(
|
|||||||
// Create MixerDeviceSink with custom config
|
// Create MixerDeviceSink with custom config
|
||||||
match DeviceSinkBuilder::from_device(device) {
|
match DeviceSinkBuilder::from_device(device) {
|
||||||
Ok(builder) => {
|
Ok(builder) => {
|
||||||
match builder.with_supported_config(&supported_config)
|
match builder
|
||||||
|
.with_supported_config(&supported_config)
|
||||||
.with_buffer_size(cpal_buffer_size)
|
.with_buffer_size(cpal_buffer_size)
|
||||||
.open_stream() {
|
.open_stream()
|
||||||
|
{
|
||||||
Ok(mixer_sink) => {
|
Ok(mixer_sink) => {
|
||||||
log::info!("MixerDeviceSink created successfully at {}Hz", sample_rate);
|
log::info!("MixerDeviceSink created successfully at {}Hz", sample_rate);
|
||||||
Ok(mixer_sink)
|
Ok(mixer_sink)
|
||||||
@@ -976,8 +978,7 @@ impl Player {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match DeviceSinkBuilder::from_device(device)
|
match DeviceSinkBuilder::from_device(device).and_then(|b| b.open_sink_or_fallback())
|
||||||
.and_then(|b| b.open_sink_or_fallback())
|
|
||||||
{
|
{
|
||||||
Ok(mixer_sink) => {
|
Ok(mixer_sink) => {
|
||||||
log::info!("Audio output initialized successfully");
|
log::info!("Audio output initialized successfully");
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ impl PlaybackEngine {
|
|||||||
"ALSA Direct gapless: next source channel full or closed".to_string()
|
"ALSA Direct gapless: next source channel full or closed".to_string()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Err("ALSA Direct: playback thread running but no gapless channel".to_string());
|
return Err(
|
||||||
|
"ALSA Direct: playback thread running but no gapless channel".to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First source — spawn the playback thread with a gapless channel
|
// First source — spawn the playback thread with a gapless channel
|
||||||
@@ -104,7 +106,9 @@ impl PlaybackEngine {
|
|||||||
should_stop.store(false, Ordering::SeqCst);
|
should_stop.store(false, Ordering::SeqCst);
|
||||||
position_clone.store(0, Ordering::SeqCst);
|
position_clone.store(0, Ordering::SeqCst);
|
||||||
|
|
||||||
log::info!("[ALSA Direct Engine] Starting streaming playback thread (gapless-capable)");
|
log::info!(
|
||||||
|
"[ALSA Direct Engine] Starting streaming playback thread (gapless-capable)"
|
||||||
|
);
|
||||||
|
|
||||||
let initial_source: BoxedSourceIter = Box::new(source.into_iter());
|
let initial_source: BoxedSourceIter = Box::new(source.into_iter());
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ pub fn parse_playlist_id(url: &str) -> Option<String> {
|
|||||||
/// Fetch track or album metadata from Spotify embed page.
|
/// Fetch track or album metadata from Spotify embed page.
|
||||||
/// Returns (title, artist) if successful.
|
/// Returns (title, artist) if successful.
|
||||||
pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> {
|
pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<(String, String)> {
|
||||||
let url = format!("https://open.spotify.com/embed/{}/{}", entity_type, entity_id);
|
let url = format!(
|
||||||
|
"https://open.spotify.com/embed/{}/{}",
|
||||||
|
entity_type, entity_id
|
||||||
|
);
|
||||||
let html = reqwest::get(&url).await.ok()?.text().await.ok()?;
|
let html = reqwest::get(&url).await.ok()?.text().await.ok()?;
|
||||||
let json_text = extract_script(&html, "__NEXT_DATA__")?;
|
let json_text = extract_script(&html, "__NEXT_DATA__")?;
|
||||||
let data: Value = serde_json::from_str(&json_text).ok()?;
|
let data: Value = serde_json::from_str(&json_text).ok()?;
|
||||||
@@ -99,7 +102,12 @@ pub async fn fetch_embed_metadata(entity_type: &str, entity_id: &str) -> Option<
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
})
|
})
|
||||||
.or_else(|| entity.get("subtitle").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
.or_else(|| {
|
||||||
|
entity
|
||||||
|
.get("subtitle")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if title.is_empty() {
|
if title.is_empty() {
|
||||||
|
|||||||
@@ -2629,8 +2629,17 @@ async fn align_corebridge_queue_cursor(bridge: &CoreBridge, track_id: u64) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_reload_remote_track(
|
||||||
|
playback_state: &qbz_player::PlaybackState,
|
||||||
|
has_loaded_audio: bool,
|
||||||
|
track_id: u64,
|
||||||
|
) -> bool {
|
||||||
|
playback_state.track_id != track_id || !has_loaded_audio
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
|
async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
|
||||||
if bridge.get_playback_state().track_id == track_id {
|
let playback_state = bridge.get_playback_state();
|
||||||
|
if !should_reload_remote_track(&playback_state, bridge.has_loaded_audio(), track_id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4259,6 +4268,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reloads_remote_track_when_same_track_id_has_no_loaded_audio() {
|
||||||
|
let playback_state = qbz_player::PlaybackState {
|
||||||
|
is_playing: false,
|
||||||
|
position: 0,
|
||||||
|
duration: 279,
|
||||||
|
track_id: 193849747,
|
||||||
|
volume: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(super::should_reload_remote_track(
|
||||||
|
&playback_state,
|
||||||
|
false,
|
||||||
|
193849747,
|
||||||
|
));
|
||||||
|
assert!(!super::should_reload_remote_track(
|
||||||
|
&playback_state,
|
||||||
|
true,
|
||||||
|
193849747,
|
||||||
|
));
|
||||||
|
assert!(super::should_reload_remote_track(
|
||||||
|
&playback_state,
|
||||||
|
true,
|
||||||
|
126886862,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_next_queue_item_id_from_shuffle_order() {
|
fn resolves_next_queue_item_id_from_shuffle_order() {
|
||||||
let queue: QConnectQueueState = serde_json::from_value(json!({
|
let queue: QConnectQueueState = serde_json::from_value(json!({
|
||||||
|
|||||||
@@ -949,13 +949,19 @@ mod tests {
|
|||||||
let state = queue.get_state();
|
let state = queue.get_state();
|
||||||
assert_eq!(state.current_track.as_ref().map(|track| track.id), Some(3));
|
assert_eq!(state.current_track.as_ref().map(|track| track.id), Some(3));
|
||||||
|
|
||||||
let mut upcoming_sorted = state.upcoming.iter().map(|track| track.id).collect::<Vec<u64>>();
|
let mut upcoming_sorted = state
|
||||||
|
.upcoming
|
||||||
|
.iter()
|
||||||
|
.map(|track| track.id)
|
||||||
|
.collect::<Vec<u64>>();
|
||||||
upcoming_sorted.sort_unstable();
|
upcoming_sorted.sort_unstable();
|
||||||
assert_eq!(upcoming_sorted, vec![4, 5, 6]);
|
assert_eq!(upcoming_sorted, vec![4, 5, 6]);
|
||||||
|
|
||||||
let mut advanced = Vec::new();
|
let mut advanced = Vec::new();
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
let track = queue.next().expect("expected next track while remanent queue exists");
|
let track = queue
|
||||||
|
.next()
|
||||||
|
.expect("expected next track while remanent queue exists");
|
||||||
advanced.push(track.id);
|
advanced.push(track.id);
|
||||||
}
|
}
|
||||||
advanced.sort_unstable();
|
advanced.sort_unstable();
|
||||||
|
|||||||
@@ -677,7 +677,8 @@ fn get_home_seeds_internal(
|
|||||||
let recently_played_album_ids = if has_scores {
|
let recently_played_album_ids = if has_scores {
|
||||||
let recent_fresh = db.get_recent_album_ids(4)?;
|
let recent_fresh = db.get_recent_album_ids(4)?;
|
||||||
let scored = db.get_scored_album_ids("all", limit_recent_albums + 4)?;
|
let scored = db.get_scored_album_ids("all", limit_recent_albums + 4)?;
|
||||||
let merged = merge_unique_preserve_order(recent_fresh, scored, limit_recent_albums as usize);
|
let merged =
|
||||||
|
merge_unique_preserve_order(recent_fresh, scored, limit_recent_albums as usize);
|
||||||
if merged.is_empty() {
|
if merged.is_empty() {
|
||||||
db.get_recent_album_ids(limit_recent_albums)?
|
db.get_recent_album_ids(limit_recent_albums)?
|
||||||
} else {
|
} else {
|
||||||
@@ -690,7 +691,8 @@ fn get_home_seeds_internal(
|
|||||||
let continue_listening_track_ids = if has_scores {
|
let continue_listening_track_ids = if has_scores {
|
||||||
let recent_fresh = db.get_recent_track_ids(4)?;
|
let recent_fresh = db.get_recent_track_ids(4)?;
|
||||||
let scored = db.get_scored_track_ids("all", limit_continue_tracks + 4)?;
|
let scored = db.get_scored_track_ids("all", limit_continue_tracks + 4)?;
|
||||||
let merged = merge_unique_preserve_order(recent_fresh, scored, limit_continue_tracks as usize);
|
let merged =
|
||||||
|
merge_unique_preserve_order(recent_fresh, scored, limit_continue_tracks as usize);
|
||||||
if merged.is_empty() {
|
if merged.is_empty() {
|
||||||
db.get_recent_track_ids(limit_continue_tracks)?
|
db.get_recent_track_ids(limit_continue_tracks)?
|
||||||
} else {
|
} else {
|
||||||
@@ -701,7 +703,8 @@ fn get_home_seeds_internal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let top_artist_ids = if has_scores {
|
let top_artist_ids = if has_scores {
|
||||||
let scored: Vec<TopArtistSeed> = db.get_scored_artist_scores("all", limit_top_artists)?
|
let scored: Vec<TopArtistSeed> = db
|
||||||
|
.get_scored_artist_scores("all", limit_top_artists)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(artist_id, score)| TopArtistSeed {
|
.map(|(artist_id, score)| TopArtistSeed {
|
||||||
artist_id,
|
artist_id,
|
||||||
@@ -827,8 +830,12 @@ pub async fn reco_get_home_resolved(
|
|||||||
&cache_state,
|
&cache_state,
|
||||||
);
|
);
|
||||||
|
|
||||||
let (recently_played_albums, resolved_favorites, all_tracks, top_artists) =
|
let (recently_played_albums, resolved_favorites, all_tracks, top_artists) = tokio::try_join!(
|
||||||
tokio::try_join!(recent_albums_fut, favorite_albums_fut, tracks_fut, artists_fut)?;
|
recent_albums_fut,
|
||||||
|
favorite_albums_fut,
|
||||||
|
tracks_fut,
|
||||||
|
artists_fut
|
||||||
|
)?;
|
||||||
|
|
||||||
// Build track lookup
|
// Build track lookup
|
||||||
let track_map: HashMap<u64, &TrackDisplayMeta> =
|
let track_map: HashMap<u64, &TrackDisplayMeta> =
|
||||||
@@ -914,11 +921,9 @@ pub async fn reco_get_home_resolved(
|
|||||||
let _permit = sem.acquire().await.ok()?;
|
let _permit = sem.acquire().await.ok()?;
|
||||||
let artist = {
|
let artist = {
|
||||||
let c = client.read().await;
|
let c = client.read().await;
|
||||||
c.get_artist_with_pagination_and_locale(
|
c.get_artist_with_pagination_and_locale(artist_id, true, Some(25), None, None)
|
||||||
artist_id, true, Some(25), None, None,
|
.await
|
||||||
)
|
.ok()?
|
||||||
.await
|
|
||||||
.ok()?
|
|
||||||
};
|
};
|
||||||
let albums = artist.albums?;
|
let albums = artist.albums?;
|
||||||
let mut results: Vec<AlbumCardMeta> = Vec::new();
|
let mut results: Vec<AlbumCardMeta> = Vec::new();
|
||||||
|
|||||||
@@ -946,8 +946,7 @@ impl RecoStoreDb {
|
|||||||
|
|
||||||
let mut artists = Vec::new();
|
let mut artists = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
artists
|
artists.push(row.map_err(|e| format!("Failed to read known artist name row: {}", e))?);
|
||||||
.push(row.map_err(|e| format!("Failed to read known artist name row: {}", e))?);
|
|
||||||
}
|
}
|
||||||
Ok(artists)
|
Ok(artists)
|
||||||
}
|
}
|
||||||
@@ -975,7 +974,11 @@ impl RecoStoreDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dismiss an artist for a specific tag (genre context)
|
/// Dismiss an artist for a specific tag (genre context)
|
||||||
pub fn dismiss_discovery_artist(&self, tag: &str, artist_name_normalized: &str) -> Result<(), String> {
|
pub fn dismiss_discovery_artist(
|
||||||
|
&self,
|
||||||
|
tag: &str,
|
||||||
|
artist_name_normalized: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
let created_at = SystemTime::now()
|
let created_at = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
|
|||||||
@@ -104,10 +104,13 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
|
|||||||
listenbrainz.init_at(&data_dir).await?;
|
listenbrainz.init_at(&data_dir).await?;
|
||||||
|
|
||||||
// Initialize V2 integration caches at user data directory
|
// Initialize V2 integration caches at user data directory
|
||||||
listenbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| {
|
listenbrainz_v2
|
||||||
log::error!("[SessionLifecycle] LB V2 cache init failed: {}", e);
|
.init_cache_at(&data_dir)
|
||||||
e
|
.await
|
||||||
})?;
|
.map_err(|e| {
|
||||||
|
log::error!("[SessionLifecycle] LB V2 cache init failed: {}", e);
|
||||||
|
e
|
||||||
|
})?;
|
||||||
musicbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| {
|
musicbrainz_v2.init_cache_at(&data_dir).await.map_err(|e| {
|
||||||
log::error!("[SessionLifecycle] MB V2 cache init failed: {}", e);
|
log::error!("[SessionLifecycle] MB V2 cache init failed: {}", e);
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ fn load_tray_icon() -> Image<'static> {
|
|||||||
// In Flatpak, try to use the installed icon file first
|
// In Flatpak, try to use the installed icon file first
|
||||||
// This works better with StatusNotifierItem/libayatana-appindicator
|
// This works better with StatusNotifierItem/libayatana-appindicator
|
||||||
if is_flatpak() {
|
if is_flatpak() {
|
||||||
let icon_path =
|
let icon_path = PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzfc.qbz.png");
|
||||||
PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzfc.qbz.png");
|
|
||||||
if icon_path.exists() {
|
if icon_path.exists() {
|
||||||
log::info!("Flatpak detected, loading tray icon from: {:?}", icon_path);
|
log::info!("Flatpak detected, loading tray icon from: {:?}", icon_path);
|
||||||
if let Ok(icon_data) = std::fs::read(&icon_path) {
|
if let Ok(icon_data) = std::fs::read(&icon_path) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
evaluateQconnectSessionPersistence,
|
evaluateQconnectSessionPersistence,
|
||||||
isQconnectPeerRendererActive,
|
isQconnectPeerRendererActive,
|
||||||
isQconnectRemoteModeActive,
|
isQconnectRemoteModeActive,
|
||||||
|
resolveQconnectPlayNextAuthoritativeTrackId,
|
||||||
type QconnectConnectionStatus
|
type QconnectConnectionStatus
|
||||||
} from './qconnectRuntime';
|
} from './qconnectRuntime';
|
||||||
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
|
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
|
||||||
@@ -180,4 +181,41 @@ describe('QConnect runtime state helpers', () => {
|
|||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not trust the local current track for play next when a peer renderer is active', () => {
|
||||||
|
expect(
|
||||||
|
resolveQconnectPlayNextAuthoritativeTrackId({
|
||||||
|
sessionSnapshot: {
|
||||||
|
session_uuid: 'session-1',
|
||||||
|
active_renderer_id: 4,
|
||||||
|
local_renderer_id: 11,
|
||||||
|
renderers: []
|
||||||
|
},
|
||||||
|
localCurrentTrackId: 48425288
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the local current track authoritative when the local renderer is active', () => {
|
||||||
|
expect(
|
||||||
|
resolveQconnectPlayNextAuthoritativeTrackId({
|
||||||
|
sessionSnapshot: {
|
||||||
|
session_uuid: 'session-1',
|
||||||
|
active_renderer_id: 11,
|
||||||
|
local_renderer_id: 11,
|
||||||
|
renderers: []
|
||||||
|
},
|
||||||
|
localCurrentTrackId: 48425288
|
||||||
|
})
|
||||||
|
).toBe(48425288);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not trust the local current track when the session snapshot is unavailable', () => {
|
||||||
|
expect(
|
||||||
|
resolveQconnectPlayNextAuthoritativeTrackId({
|
||||||
|
sessionSnapshot: null,
|
||||||
|
localCurrentTrackId: 48425288
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -194,6 +194,30 @@ export function isQconnectPeerRendererActive(
|
|||||||
return activeRendererId !== localRendererId;
|
return activeRendererId !== localRendererId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveQconnectPlayNextAuthoritativeTrackId(params: {
|
||||||
|
sessionSnapshot: QconnectSessionSnapshot | null | undefined;
|
||||||
|
localCurrentTrackId: number | null | undefined;
|
||||||
|
}): number | null {
|
||||||
|
const { sessionSnapshot, localCurrentTrackId } = params;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof localCurrentTrackId !== 'number' ||
|
||||||
|
!Number.isFinite(localCurrentTrackId) ||
|
||||||
|
localCurrentTrackId <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeRendererId = sessionSnapshot?.active_renderer_id ?? null;
|
||||||
|
const localRendererId = sessionSnapshot?.local_renderer_id ?? null;
|
||||||
|
|
||||||
|
if (activeRendererId == null || localRendererId == null || activeRendererId < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isQconnectPeerRendererActive(sessionSnapshot) ? null : localCurrentTrackId;
|
||||||
|
}
|
||||||
|
|
||||||
export function evaluateQconnectSessionPersistence(
|
export function evaluateQconnectSessionPersistence(
|
||||||
remoteModeActive: boolean,
|
remoteModeActive: boolean,
|
||||||
skipLogged: boolean
|
skipLogged: boolean
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ import { showToast as storeShowToast, type ToastType } from '$lib/stores/toastSt
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import type { QobuzTrack, Track, PlaylistTrack, LocalLibraryTrack, DisplayTrack } from '$lib/types';
|
import type { QobuzTrack, Track, PlaylistTrack, LocalLibraryTrack, DisplayTrack } from '$lib/types';
|
||||||
import {
|
import {
|
||||||
qconnectAdmissionReasonKey
|
qconnectAdmissionReasonKey,
|
||||||
|
resolveQconnectPlayNextAuthoritativeTrackId
|
||||||
|
} from '$lib/services/qconnectRuntime';
|
||||||
|
import type {
|
||||||
|
QconnectConnectionStatus,
|
||||||
|
QconnectSessionSnapshot
|
||||||
} from '$lib/services/qconnectRuntime';
|
} from '$lib/services/qconnectRuntime';
|
||||||
import type { QconnectConnectionStatus } from '$lib/services/qconnectRuntime';
|
|
||||||
import {
|
import {
|
||||||
resolveQconnectPlayNextInsertAfter,
|
resolveQconnectPlayNextInsertAfter,
|
||||||
type QconnectDiagnosticsPayload,
|
type QconnectDiagnosticsPayload,
|
||||||
@@ -117,14 +121,19 @@ type QconnectPlayNextResolution = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<QconnectPlayNextResolution> {
|
async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<QconnectPlayNextResolution> {
|
||||||
const [queueSnapshotResult, rendererSnapshotResult] = await Promise.allSettled([
|
const [queueSnapshotResult, rendererSnapshotResult, sessionSnapshotResult] = await Promise.allSettled([
|
||||||
invoke<QconnectQueueSnapshot>('v2_qconnect_queue_snapshot'),
|
invoke<QconnectQueueSnapshot>('v2_qconnect_queue_snapshot'),
|
||||||
invoke<QconnectRendererSnapshot>('v2_qconnect_renderer_snapshot')
|
invoke<QconnectRendererSnapshot>('v2_qconnect_renderer_snapshot'),
|
||||||
|
invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const queueSnapshot = queueSnapshotResult.status === 'fulfilled' ? queueSnapshotResult.value : null;
|
const queueSnapshot = queueSnapshotResult.status === 'fulfilled' ? queueSnapshotResult.value : null;
|
||||||
const rendererSnapshot = rendererSnapshotResult.status === 'fulfilled' ? rendererSnapshotResult.value : null;
|
const rendererSnapshot = rendererSnapshotResult.status === 'fulfilled' ? rendererSnapshotResult.value : null;
|
||||||
const localCurrentTrackId = getPlayerState().currentTrack?.id ?? null;
|
const sessionSnapshot = sessionSnapshotResult.status === 'fulfilled' ? sessionSnapshotResult.value : null;
|
||||||
|
const localCurrentTrackId = resolveQconnectPlayNextAuthoritativeTrackId({
|
||||||
|
sessionSnapshot,
|
||||||
|
localCurrentTrackId: getPlayerState().currentTrack?.id ?? null
|
||||||
|
});
|
||||||
const resolution = resolveQconnectPlayNextInsertAfter(queueSnapshot, rendererSnapshot, {
|
const resolution = resolveQconnectPlayNextInsertAfter(queueSnapshot, rendererSnapshot, {
|
||||||
authoritativeCurrentTrackId: localCurrentTrackId
|
authoritativeCurrentTrackId: localCurrentTrackId
|
||||||
});
|
});
|
||||||
@@ -135,6 +144,9 @@ async function resolveQconnectPlayNextInsertAfterFromSnapshots(): Promise<Qconne
|
|||||||
: null,
|
: null,
|
||||||
rendererSnapshotResult.status === 'rejected'
|
rendererSnapshotResult.status === 'rejected'
|
||||||
? `renderer_snapshot=${String(rendererSnapshotResult.reason)}`
|
? `renderer_snapshot=${String(rendererSnapshotResult.reason)}`
|
||||||
|
: null,
|
||||||
|
sessionSnapshotResult.status === 'rejected'
|
||||||
|
? `session_snapshot=${String(sessionSnapshotResult.reason)}`
|
||||||
: null
|
: null
|
||||||
].filter((value): value is string => value !== null).join('; ');
|
].filter((value): value is string => value !== null).join('; ');
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user