style: apply cargo fmt across codebase

Automated formatting pass covering 79 Rust source files.
No logic changes -- only line wrapping, import ordering,
and match/closure reformatting.
This commit is contained in:
vicrodh committed 2026-02-20 00:14:58 -06:00
1 parent f9313c2d80
commit 3a297c0b2b
78 files changed
+4593 -2697

No files matched your search

+7 -5
View File
@@ -19,10 +19,7 @@ pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &s
/// Generate signature for track/getFileUrl endpoint /// Generate signature for track/getFileUrl endpoint
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String { pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
let params = format!( let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id);
"format_id{}intentstreamtrack_id{}",
format_id, track_id
);
generate_signature("trackgetFileUrl", &params, timestamp, secret) generate_signature("trackgetFileUrl", &params, timestamp, secret)
} }
@@ -97,7 +94,12 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
} }
// Try common timestamp fields (seconds). // Try common timestamp fields (seconds).
let ts_keys = ["end_date_ts", "expires_at_ts", "expiration_ts", "valid_until_ts"]; let ts_keys = [
"end_date_ts",
"expires_at_ts",
"expiration_ts",
"valid_until_ts",
];
for key in ts_keys { for key in ts_keys {
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) { if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
if ts > 0 { if ts > 0 {
+23 -20
View File
@@ -21,23 +21,13 @@ pub struct BundleTokens {
/// Extract app_id and secrets from Qobuz bundle /// Extract app_id and secrets 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)?;
@@ -56,8 +46,9 @@ pub async fn extract_bundle_tokens(client: &Client) -> Result<BundleTokens> {
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))
@@ -67,8 +58,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"))
@@ -95,10 +85,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)
@@ -138,7 +134,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];
@@ -148,7 +148,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);
} }
} }
+143 -139
View File
@@ -1,139 +1,143 @@
//! Performers string parsing utilities //! Performers string parsing utilities
//! //!
//! Qobuz provides performer credits as a formatted string like: //! Qobuz provides performer credits as a formatted string like:
//! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass" //! "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Jimmy Garrison, Double Bass"
//! //!
//! This module parses that string into structured data. //! This module parses that string into structured data.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A performer with their name and roles /// A performer with their name and roles
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Performer { pub struct Performer {
pub name: String, pub name: String,
pub roles: Vec<String>, pub roles: Vec<String>,
} }
impl Performer { impl Performer {
pub fn new(name: String, roles: Vec<String>) -> Self { pub fn new(name: String, roles: Vec<String>) -> Self {
Self { name, roles } Self { name, roles }
} }
} }
/// Parse a Qobuz performers string into structured data /// Parse a Qobuz performers string into structured data
/// ///
/// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2" /// Format: "Name, Role1, Role2 - Name, Role1 - Name, Role1, Role2"
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use crate::api::performers::parse_performers; /// use crate::api::performers::parse_performers;
/// ///
/// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano"); /// let performers = parse_performers("John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano");
/// assert_eq!(performers.len(), 2); /// assert_eq!(performers.len(), 2);
/// assert_eq!(performers[0].name, "John Coltrane"); /// assert_eq!(performers[0].name, "John Coltrane");
/// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]); /// assert_eq!(performers[0].roles, vec!["Saxophone", "MainArtist"]);
/// ``` /// ```
pub fn parse_performers(performers_str: &str) -> Vec<Performer> { pub fn parse_performers(performers_str: &str) -> Vec<Performer> {
if performers_str.is_empty() { if performers_str.is_empty() {
return Vec::new(); return Vec::new();
} }
performers_str performers_str
.split(" - ") .split(" - ")
.filter_map(|segment| { .filter_map(|segment| {
let segment = segment.trim(); let segment = segment.trim();
if segment.is_empty() { if segment.is_empty() {
return None; return None;
} }
let parts: Vec<&str> = segment.split(", ").collect(); let parts: Vec<&str> = segment.split(", ").collect();
if parts.is_empty() { if parts.is_empty() {
return None; return None;
} }
let name = parts[0].trim().to_string(); let name = parts[0].trim().to_string();
if name.is_empty() { if name.is_empty() {
return None; return None;
} }
let roles: Vec<String> = parts[1..] let roles: Vec<String> = parts[1..]
.iter() .iter()
.map(|r| r.trim().to_string()) .map(|r| r.trim().to_string())
.filter(|r| !r.is_empty()) .filter(|r| !r.is_empty())
.collect(); .collect();
Some(Performer::new(name, roles)) Some(Performer::new(name, roles))
}) })
.collect() .collect()
} }
/// Group performers by their roles /// Group performers by their roles
/// ///
/// Returns a map where keys are role names and values are lists of performer names /// Returns a map where keys are role names and values are lists of performer names
pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> { pub fn group_by_role(performers: &[Performer]) -> std::collections::HashMap<String, Vec<String>> {
let mut grouped: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new(); let mut grouped: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for performer in performers {
for role in &performer.roles { for performer in performers {
grouped for role in &performer.roles {
.entry(role.clone()) grouped
.or_default() .entry(role.clone())
.push(performer.name.clone()); .or_default()
} .push(performer.name.clone());
} }
}
grouped
} grouped
}
#[cfg(test)]
mod tests { #[cfg(test)]
use super::*; mod tests {
use super::*;
#[test]
fn test_parse_single_performer() { #[test]
let result = parse_performers("John Coltrane, Saxophone"); fn test_parse_single_performer() {
assert_eq!(result.len(), 1); let result = parse_performers("John Coltrane, Saxophone");
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 1);
assert_eq!(result[0].roles, vec!["Saxophone"]); assert_eq!(result[0].name, "John Coltrane");
} assert_eq!(result[0].roles, vec!["Saxophone"]);
}
#[test]
fn test_parse_multiple_performers() { #[test]
let result = parse_performers( fn test_parse_multiple_performers() {
"John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums", let result = parse_performers(
); "John Coltrane, Saxophone, MainArtist - McCoy Tyner, Piano - Elvin Jones, Drums",
assert_eq!(result.len(), 3); );
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 3);
assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]); assert_eq!(result[0].name, "John Coltrane");
assert_eq!(result[1].name, "McCoy Tyner"); assert_eq!(result[0].roles, vec!["Saxophone", "MainArtist"]);
assert_eq!(result[1].roles, vec!["Piano"]); assert_eq!(result[1].name, "McCoy Tyner");
assert_eq!(result[2].name, "Elvin Jones"); assert_eq!(result[1].roles, vec!["Piano"]);
assert_eq!(result[2].roles, vec!["Drums"]); assert_eq!(result[2].name, "Elvin Jones");
} assert_eq!(result[2].roles, vec!["Drums"]);
}
#[test]
fn test_parse_empty_string() { #[test]
let result = parse_performers(""); fn test_parse_empty_string() {
assert!(result.is_empty()); let result = parse_performers("");
} assert!(result.is_empty());
}
#[test]
fn test_parse_performer_no_roles() { #[test]
let result = parse_performers("John Coltrane"); fn test_parse_performer_no_roles() {
assert_eq!(result.len(), 1); let result = parse_performers("John Coltrane");
assert_eq!(result[0].name, "John Coltrane"); assert_eq!(result.len(), 1);
assert!(result[0].roles.is_empty()); assert_eq!(result[0].name, "John Coltrane");
} assert!(result[0].roles.is_empty());
}
#[test]
fn test_group_by_role() { #[test]
let performers = vec![ fn test_group_by_role() {
Performer::new("John".to_string(), vec!["Saxophone".to_string()]), let performers = vec![
Performer::new("Jane".to_string(), vec!["Saxophone".to_string(), "Vocals".to_string()]), Performer::new("John".to_string(), vec!["Saxophone".to_string()]),
]; Performer::new(
let grouped = group_by_role(&performers); "Jane".to_string(),
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2); vec!["Saxophone".to_string(), "Vocals".to_string()],
assert_eq!(grouped.get("Vocals").unwrap().len(), 1); ),
} ];
} let grouped = group_by_role(&performers);
assert_eq!(grouped.get("Saxophone").unwrap().len(), 2);
assert_eq!(grouped.get("Vocals").unwrap().len(), 1);
}
}
+45 -12
View File
@@ -171,7 +171,11 @@ impl ApiCache {
// ============ Album Cache ============ // ============ Album Cache ============
/// Get a cached album if it exists and hasn't expired /// Get a cached album if it exists and hasn't expired
pub fn get_album(&self, album_id: &str, ttl_secs: Option<i64>) -> Result<Option<String>, String> { pub fn get_album(
&self,
album_id: &str,
ttl_secs: Option<i64>,
) -> Result<Option<String>, String> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let min_fetched_at = Self::current_timestamp() - ttl; let min_fetched_at = Self::current_timestamp() - ttl;
@@ -201,7 +205,11 @@ impl ApiCache {
} }
/// Get multiple cached albums at once /// Get multiple cached albums at once
pub fn get_albums(&self, album_ids: &[String], ttl_secs: Option<i64>) -> Result<Vec<(String, String)>, String> { pub fn get_albums(
&self,
album_ids: &[String],
ttl_secs: Option<i64>,
) -> Result<Vec<(String, String)>, String> {
if album_ids.is_empty() { if album_ids.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -227,7 +235,8 @@ impl ApiCache {
.collect(); .collect();
params_vec.push(Box::new(min_fetched_at)); params_vec.push(Box::new(min_fetched_at));
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt let rows = stmt
.query_map(params_refs.as_slice(), |row| { .query_map(params_refs.as_slice(), |row| {
@@ -243,7 +252,11 @@ impl ApiCache {
} }
/// Get multiple cached tracks at once /// Get multiple cached tracks at once
pub fn get_tracks(&self, track_ids: &[u64], ttl_secs: Option<i64>) -> Result<Vec<(u64, String)>, String> { pub fn get_tracks(
&self,
track_ids: &[u64],
ttl_secs: Option<i64>,
) -> Result<Vec<(u64, String)>, String> {
if track_ids.is_empty() { if track_ids.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -268,7 +281,8 @@ impl ApiCache {
.collect(); .collect();
params_vec.push(Box::new(min_fetched_at)); params_vec.push(Box::new(min_fetched_at));
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt let rows = stmt
.query_map(params_refs.as_slice(), |row| { .query_map(params_refs.as_slice(), |row| {
@@ -284,7 +298,12 @@ impl ApiCache {
} }
/// Get multiple cached artists at once (for a specific locale) /// Get multiple cached artists at once (for a specific locale)
pub fn get_artists(&self, artist_ids: &[u64], locale: &str, ttl_secs: Option<i64>) -> Result<Vec<(u64, String)>, String> { pub fn get_artists(
&self,
artist_ids: &[u64],
locale: &str,
ttl_secs: Option<i64>,
) -> Result<Vec<(u64, String)>, String> {
if artist_ids.is_empty() { if artist_ids.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
} }
@@ -310,7 +329,8 @@ impl ApiCache {
params_vec.push(Box::new(locale.to_string())); params_vec.push(Box::new(locale.to_string()));
params_vec.push(Box::new(min_fetched_at)); params_vec.push(Box::new(min_fetched_at));
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let rows = stmt let rows = stmt
.query_map(params_refs.as_slice(), |row| { .query_map(params_refs.as_slice(), |row| {
@@ -328,7 +348,12 @@ impl ApiCache {
// ============ Artist Cache ============ // ============ Artist Cache ============
/// Get a cached artist if it exists and hasn't expired /// Get a cached artist if it exists and hasn't expired
pub fn get_artist(&self, artist_id: u64, locale: &str, ttl_secs: Option<i64>) -> Result<Option<String>, String> { pub fn get_artist(
&self,
artist_id: u64,
locale: &str,
ttl_secs: Option<i64>,
) -> Result<Option<String>, String> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let min_fetched_at = Self::current_timestamp() - ttl; let min_fetched_at = Self::current_timestamp() - ttl;
@@ -360,7 +385,11 @@ impl ApiCache {
// ============ Track Cache ============ // ============ Track Cache ============
/// Get a cached track if it exists and hasn't expired /// Get a cached track if it exists and hasn't expired
pub fn get_track(&self, track_id: u64, ttl_secs: Option<i64>) -> Result<Option<String>, String> { pub fn get_track(
&self,
track_id: u64,
ttl_secs: Option<i64>,
) -> Result<Option<String>, String> {
let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS);
let min_fetched_at = Self::current_timestamp() - ttl; let min_fetched_at = Self::current_timestamp() - ttl;
@@ -476,8 +505,12 @@ impl ApiCache {
params![locale], params![locale],
) )
.map_err(|e| format!("Failed to clear cached artists by locale: {}", e))?; .map_err(|e| format!("Failed to clear cached artists by locale: {}", e))?;
log::info!("Cleared {} cached artist(s) for locale '{}'", deleted, locale); log::info!(
"Cleared {} cached artist(s) for locale '{}'",
deleted,
locale
);
Ok(deleted) Ok(deleted)
} }
@@ -487,7 +520,7 @@ impl ApiCache {
.conn .conn
.execute("DELETE FROM cached_artists", []) .execute("DELETE FROM cached_artists", [])
.map_err(|e| format!("Failed to clear all cached artists: {}", e))?; .map_err(|e| format!("Failed to clear all cached artists: {}", e))?;
log::info!("Cleared {} cached artist(s)", deleted); log::info!("Cleared {} cached artist(s)", deleted);
Ok(deleted) Ok(deleted)
} }
+127 -86
View File
@@ -1,17 +1,21 @@
use axum::{ use axum::{
body::Body, body::Body,
extract::{ws::{Message, WebSocket, WebSocketUpgrade}, ConnectInfo, Path, Query, State}, extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
ConnectInfo, Path, Query, State,
},
http::{header, Method, StatusCode}, http::{header, Method, StatusCode},
middleware, middleware,
response::{sse::{Event, Sse}, IntoResponse}, response::{
sse::{Event, Sse},
IntoResponse,
},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use futures_util::stream::Stream;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use axum_server::{tls_rustls::RustlsConfig, Handle as AxumHandle}; use axum_server::{tls_rustls::RustlsConfig, Handle as AxumHandle};
use base64::Engine; use base64::Engine;
use futures_util::stream::Stream;
use rcgen::{CertificateParams, DistinguishedName, DnType, SanType}; use rcgen::{CertificateParams, DistinguishedName, DnType, SanType};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
@@ -20,6 +24,8 @@ use std::sync::Once;
use std::time::Duration; use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{broadcast, Mutex}; use tokio::sync::{broadcast, Mutex};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{ use crate::{
@@ -28,7 +34,7 @@ use crate::{
commands::{self, search::SearchAllResults}, commands::{self, search::SearchAllResults},
config::{ config::{
audio_settings::AudioSettingsState, audio_settings::AudioSettingsState,
playback_preferences::{AutoplayMode, PlaybackPreferencesState, PlaybackPreferences}, playback_preferences::{AutoplayMode, PlaybackPreferences, PlaybackPreferencesState},
remote_control_settings::{RemoteControlSettings, RemoteControlSettingsState}, remote_control_settings::{RemoteControlSettings, RemoteControlSettingsState},
}, },
offline_cache::OfflineCacheState, offline_cache::OfflineCacheState,
@@ -175,7 +181,9 @@ impl ApiServerState {
} }
if let Some(handle) = inner.server.take() { if let Some(handle) = inner.server.take() {
handle.handle.graceful_shutdown(Some(Duration::from_secs(2))); handle
.handle
.graceful_shutdown(Some(Duration::from_secs(2)));
} }
inner.current = Some(settings.clone()); inner.current = Some(settings.clone());
@@ -195,7 +203,8 @@ impl ApiServerState {
let allowed_origins = app_handle let allowed_origins = app_handle
.try_state::<crate::config::AllowedOriginsState>() .try_state::<crate::config::AllowedOriginsState>()
.map(|state| { .map(|state| {
state.get_origins() state
.get_origins()
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|o| o.origin) .map(|o| o.origin)
@@ -315,10 +324,13 @@ fn emit_queue_state_update(app_handle: &AppHandle) {
crate::queue::RepeatMode::All => "all", crate::queue::RepeatMode::All => "all",
crate::queue::RepeatMode::One => "one", crate::queue::RepeatMode::One => "one",
}; };
let _ = app_handle.emit("queue:state", serde_json::json!({ let _ = app_handle.emit(
"shuffle": shuffle, "queue:state",
"repeat": repeat serde_json::json!({
})); "shuffle": shuffle,
"repeat": repeat
}),
);
} }
#[tauri::command] #[tauri::command]
@@ -367,9 +379,7 @@ pub async fn remote_control_set_secure(
} }
#[tauri::command] #[tauri::command]
pub async fn remote_control_regenerate_token( pub async fn remote_control_regenerate_token(app: AppHandle) -> Result<RemoteControlQr, String> {
app: AppHandle,
) -> Result<RemoteControlQr, String> {
let settings_state = app.state::<RemoteControlSettingsState>(); let settings_state = app.state::<RemoteControlSettingsState>();
let _ = settings_state.regenerate_token()?; let _ = settings_state.regenerate_token()?;
sync_server(&app).await?; sync_server(&app).await?;
@@ -377,9 +387,7 @@ pub async fn remote_control_regenerate_token(
} }
#[tauri::command] #[tauri::command]
pub async fn remote_control_get_pairing_qr( pub async fn remote_control_get_pairing_qr(app: AppHandle) -> Result<RemoteControlQr, String> {
app: AppHandle,
) -> Result<RemoteControlQr, String> {
let settings_state = app.state::<RemoteControlSettingsState>(); let settings_state = app.state::<RemoteControlSettingsState>();
let settings = settings_state.get_settings()?; let settings = settings_state.get_settings()?;
let url = get_local_url(settings.port, settings.secure); let url = get_local_url(settings.port, settings.secure);
@@ -395,7 +403,8 @@ pub async fn remote_control_get_pairing_qr(
qrcode_generator::QrCodeEcc::Low, qrcode_generator::QrCodeEcc::Low,
512, 512,
None::<&str>, None::<&str>,
).map_err(|e| format!("QR generation failed: {}", e))?; )
.map_err(|e| format!("QR generation failed: {}", e))?;
let svg_base64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes()); let svg_base64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
let data_url = format!("data:image/svg+xml;base64,{}", svg_base64); let data_url = format!("data:image/svg+xml;base64,{}", svg_base64);
@@ -438,10 +447,7 @@ pub async fn remote_control_add_allowed_origin(
} }
#[tauri::command] #[tauri::command]
pub async fn remote_control_remove_allowed_origin( pub async fn remote_control_remove_allowed_origin(id: i64, app: AppHandle) -> Result<(), String> {
id: i64,
app: AppHandle,
) -> Result<(), String> {
let state = app.state::<crate::config::AllowedOriginsState>(); let state = app.state::<crate::config::AllowedOriginsState>();
state.remove_origin(id)?; state.remove_origin(id)?;
// Restart server to apply new CORS settings // Restart server to apply new CORS settings
@@ -547,7 +553,9 @@ async fn get_openapi_spec() -> impl IntoResponse {
(headers, spec) (headers, spec)
} }
async fn now_playing(State(ctx): State<ApiContext>) -> Result<Json<NowPlayingResponse>, StatusCode> { async fn now_playing(
State(ctx): State<ApiContext>,
) -> Result<Json<NowPlayingResponse>, StatusCode> {
let app_state = ctx.app_handle.state::<AppState>(); let app_state = ctx.app_handle.state::<AppState>();
let playback = app_state let playback = app_state
.player .player
@@ -579,9 +587,7 @@ async fn pause(State(ctx): State<ApiContext>) -> Result<StatusCode, StatusCode>
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn next_track( async fn next_track(State(ctx): State<ApiContext>) -> Result<Json<Option<QueueTrack>>, StatusCode> {
State(ctx): State<ApiContext>,
) -> Result<Json<Option<QueueTrack>>, StatusCode> {
let app_state = ctx.app_handle.state::<AppState>(); let app_state = ctx.app_handle.state::<AppState>();
let next = app_state.queue.next(); let next = app_state.queue.next();
if let Some(track) = next { if let Some(track) = next {
@@ -603,10 +609,7 @@ async fn previous_track(
Ok(Json(None)) Ok(Json(None))
} }
async fn play_queue_track( async fn play_queue_track(ctx: &ApiContext, track: QueueTrack) -> Result<QueueTrack, StatusCode> {
ctx: &ApiContext,
track: QueueTrack,
) -> Result<QueueTrack, StatusCode> {
if track.is_local { if track.is_local {
return Err(StatusCode::NOT_IMPLEMENTED); return Err(StatusCode::NOT_IMPLEMENTED);
} }
@@ -622,7 +625,9 @@ async fn play_queue_track(
app_state, app_state,
offline_cache, offline_cache,
audio_settings, audio_settings,
).await { )
.await
{
log::error!("Remote control play_track failed: {}", err); log::error!("Remote control play_track failed: {}", err);
return Err(StatusCode::BAD_GATEWAY); return Err(StatusCode::BAD_GATEWAY);
} }
@@ -692,7 +697,8 @@ async fn get_playback_preferences(
State(ctx): State<ApiContext>, State(ctx): State<ApiContext>,
) -> Result<Json<PlaybackPreferences>, StatusCode> { ) -> Result<Json<PlaybackPreferences>, StatusCode> {
let prefs_state = ctx.app_handle.state::<PlaybackPreferencesState>(); let prefs_state = ctx.app_handle.state::<PlaybackPreferencesState>();
prefs_state.get_preferences() prefs_state
.get_preferences()
.map(Json) .map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
@@ -708,7 +714,8 @@ async fn set_autoplay(
"infinite" => AutoplayMode::InfiniteRadio, "infinite" => AutoplayMode::InfiniteRadio,
_ => return Err(StatusCode::BAD_REQUEST), _ => return Err(StatusCode::BAD_REQUEST),
}; };
prefs_state.set_autoplay_mode(mode) prefs_state
.set_autoplay_mode(mode)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -743,9 +750,24 @@ async fn search_all(
// Parallel search for each type // Parallel search for each type
let (albums_result, tracks_result, artists_result) = tokio::join!( let (albums_result, tracks_result, artists_result) = tokio::join!(
client.search_albums(&query.q, query.limit.unwrap_or(10), query.offset.unwrap_or(0), None), client.search_albums(
client.search_tracks(&query.q, query.limit.unwrap_or(10), query.offset.unwrap_or(0), None), &query.q,
client.search_artists(&query.q, query.limit.unwrap_or(10), query.offset.unwrap_or(0), None), query.limit.unwrap_or(10),
query.offset.unwrap_or(0),
None
),
client.search_tracks(
&query.q,
query.limit.unwrap_or(10),
query.offset.unwrap_or(0),
None
),
client.search_artists(
&query.q,
query.limit.unwrap_or(10),
query.offset.unwrap_or(0),
None
),
); );
let mut albums = albums_result.map_err(|_| StatusCode::BAD_GATEWAY)?; let mut albums = albums_result.map_err(|_| StatusCode::BAD_GATEWAY)?;
@@ -753,7 +775,9 @@ async fn search_all(
let mut artists = artists_result.map_err(|_| StatusCode::BAD_GATEWAY)?; let mut artists = artists_result.map_err(|_| StatusCode::BAD_GATEWAY)?;
// Filter blacklisted artists // Filter blacklisted artists
albums.items.retain(|album| !blacklist_state.is_blacklisted(album.artist.id)); albums
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
tracks.items.retain(|track| { tracks.items.retain(|track| {
if let Some(ref performer) = track.performer { if let Some(ref performer) = track.performer {
!blacklist_state.is_blacklisted(performer.id) !blacklist_state.is_blacklisted(performer.id)
@@ -761,13 +785,20 @@ async fn search_all(
true true
} }
}); });
artists.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); artists
.items
.retain(|artist| !blacklist_state.is_blacklisted(artist.id));
Ok(Json(SearchAllResults { Ok(Json(SearchAllResults {
albums, albums,
tracks, tracks,
artists, artists,
playlists: SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 10 }, playlists: SearchResultsPage {
items: vec![],
total: 0,
offset: 0,
limit: 10,
},
most_popular: None, most_popular: None,
})) }))
} }
@@ -812,7 +843,9 @@ async fn play_queue_index(
app_state, app_state,
offline_cache, offline_cache,
audio_settings, audio_settings,
).await { )
.await
{
log::error!("Remote control play_queue_index failed: {}", err); log::error!("Remote control play_queue_index failed: {}", err);
return Err(StatusCode::BAD_GATEWAY); return Err(StatusCode::BAD_GATEWAY);
} }
@@ -867,7 +900,11 @@ async fn get_favorites(
let app_state = ctx.app_handle.state::<AppState>(); let app_state = ctx.app_handle.state::<AppState>();
let client = app_state.client.read().await; let client = app_state.client.read().await;
let result = client let result = client
.get_favorites(&query.fav_type, query.limit.unwrap_or(50), query.offset.unwrap_or(0)) .get_favorites(
&query.fav_type,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await .await
.map_err(|_| StatusCode::BAD_GATEWAY)?; .map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Json(result)) Ok(Json(result))
@@ -916,29 +953,35 @@ async fn play_album(
// Extract tracks and convert to QueueTrack // Extract tracks and convert to QueueTrack
let tracks: Vec<QueueTrack> = if let Some(tracks) = album.tracks { let tracks: Vec<QueueTrack> = if let Some(tracks) = album.tracks {
tracks.items.into_iter().map(|t| { tracks
let artist_name = t.performer.as_ref() .items
.map(|p| p.name.clone()) .into_iter()
.unwrap_or_else(|| album.artist.name.clone()); .map(|t| {
let artwork = album.image.large.clone().or(album.image.small.clone()); let artist_name = t
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_else(|| album.artist.name.clone());
let artwork = album.image.large.clone().or(album.image.small.clone());
QueueTrack { QueueTrack {
id: t.id, id: t.id,
title: t.title, title: t.title,
artist: artist_name, artist: artist_name,
album: album.title.clone(), album: album.title.clone(),
duration_secs: t.duration as u64, duration_secs: t.duration as u64,
artwork_url: artwork, artwork_url: artwork,
hires: t.hires, hires: t.hires,
bit_depth: t.maximum_bit_depth, bit_depth: t.maximum_bit_depth,
sample_rate: t.maximum_sampling_rate, sample_rate: t.maximum_sampling_rate,
is_local: false, is_local: false,
album_id: Some(album.id.clone()), album_id: Some(album.id.clone()),
artist_id: t.performer.as_ref().map(|p| p.id), artist_id: t.performer.as_ref().map(|p| p.id),
streamable: t.streamable, streamable: t.streamable,
source: Some("qobuz".to_string()), source: Some("qobuz".to_string()),
} }
}).collect() })
.collect()
} else { } else {
return Err(StatusCode::NOT_FOUND); return Err(StatusCode::NOT_FOUND);
}; };
@@ -965,7 +1008,9 @@ async fn play_album(
app_state, app_state,
offline_cache, offline_cache,
audio_settings, audio_settings,
).await { )
.await
{
log::error!("Remote control play_album failed: {}", err); log::error!("Remote control play_album failed: {}", err);
return Err(StatusCode::BAD_GATEWAY); return Err(StatusCode::BAD_GATEWAY);
} }
@@ -997,16 +1042,13 @@ async fn get_artist(
let client = app_state.client.read().await; let client = app_state.client.read().await;
let id: u64 = artist_id.parse().map_err(|_| StatusCode::BAD_REQUEST)?; let id: u64 = artist_id.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
let artist = client let artist = client
.get_artist(id, true) // Include albums .get_artist(id, true) // Include albums
.await .await
.map_err(|_| StatusCode::BAD_GATEWAY)?; .map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(Json(artist)) Ok(Json(artist))
} }
async fn ws_handler( async fn ws_handler(State(ctx): State<ApiContext>, ws: WebSocketUpgrade) -> impl IntoResponse {
State(ctx): State<ApiContext>,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws(socket, ctx.playback_tx.subscribe())) ws.on_upgrade(move |socket| handle_ws(socket, ctx.playback_tx.subscribe()))
} }
@@ -1032,21 +1074,20 @@ async fn sse_handler(
State(ctx): State<ApiContext>, State(ctx): State<ApiContext>,
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> { ) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
let rx = ctx.playback_tx.subscribe(); let rx = ctx.playback_tx.subscribe();
let stream = BroadcastStream::new(rx) let stream = BroadcastStream::new(rx).filter_map(|result| {
.filter_map(|result| { match result {
match result { Ok(event) => {
Ok(event) => { let json = serde_json::to_string(&event).ok()?;
let json = serde_json::to_string(&event).ok()?; Some(Ok(Event::default().data(json)))
Some(Ok(Event::default().data(json)))
}
Err(_) => None, // Skip lagged messages
} }
}); Err(_) => None, // Skip lagged messages
}
});
Sse::new(stream).keep_alive( Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new() axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(15)) .interval(Duration::from_secs(15))
.text("ping") .text("ping"),
) )
} }
@@ -1099,10 +1140,7 @@ async fn require_token(
StatusCode::UNAUTHORIZED.into_response() StatusCode::UNAUTHORIZED.into_response()
} }
async fn lan_only( async fn lan_only(req: axum::http::Request<Body>, next: middleware::Next) -> impl IntoResponse {
req: axum::http::Request<Body>,
next: middleware::Next,
) -> impl IntoResponse {
let addr = req let addr = req
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
@@ -1120,7 +1158,9 @@ async fn lan_only(
fn is_local_addr(ip: IpAddr) -> bool { fn is_local_addr(ip: IpAddr) -> bool {
match ip { match ip {
IpAddr::V4(addr) => addr.is_private() || addr.is_loopback(), IpAddr::V4(addr) => addr.is_private() || addr.is_loopback(),
IpAddr::V6(addr) => addr.is_loopback() || addr.is_unique_local() || is_ipv6_link_local(addr), IpAddr::V6(addr) => {
addr.is_loopback() || addr.is_unique_local() || is_ipv6_link_local(addr)
}
} }
} }
@@ -1224,7 +1264,8 @@ fn ensure_certificate() -> Result<(PathBuf, PathBuf), String> {
let cert = rcgen::Certificate::from_params(params) let cert = rcgen::Certificate::from_params(params)
.map_err(|e| format!("Failed to generate certificate: {}", e))?; .map_err(|e| format!("Failed to generate certificate: {}", e))?;
let cert_pem = cert.serialize_pem() let cert_pem = cert
.serialize_pem()
.map_err(|e| format!("Failed to serialize certificate: {}", e))?; .map_err(|e| format!("Failed to serialize certificate: {}", e))?;
let key_pem = cert.serialize_private_key_pem(); let key_pem = cert.serialize_private_key_pem();
+24 -7
View File
@@ -93,37 +93,52 @@ impl BlacklistState {
} }
/// Add an artist to the blacklist /// Add an artist to the blacklist
pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> { pub fn add(
&self,
artist_id: u64,
artist_name: &str,
notes: Option<&str>,
) -> Result<(), String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
guard.as_ref().ok_or("No active session - please log in")? guard
.as_ref()
.ok_or("No active session - please log in")?
.add(artist_id, artist_name, notes) .add(artist_id, artist_name, notes)
} }
/// Remove an artist from the blacklist /// Remove an artist from the blacklist
pub fn remove(&self, artist_id: u64) -> Result<(), String> { pub fn remove(&self, artist_id: u64) -> Result<(), String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
guard.as_ref().ok_or("No active session - please log in")? guard
.as_ref()
.ok_or("No active session - please log in")?
.remove(artist_id) .remove(artist_id)
} }
/// Get all blacklisted artists /// Get all blacklisted artists
pub fn get_all(&self) -> Result<Vec<BlacklistedArtist>, String> { pub fn get_all(&self) -> Result<Vec<BlacklistedArtist>, String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
guard.as_ref().ok_or("No active session - please log in")? guard
.as_ref()
.ok_or("No active session - please log in")?
.get_all() .get_all()
} }
/// Set enabled state /// Set enabled state
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
guard.as_ref().ok_or("No active session - please log in")? guard
.as_ref()
.ok_or("No active session - please log in")?
.set_enabled(enabled) .set_enabled(enabled)
} }
/// Get blacklist settings /// Get blacklist settings
pub fn get_settings(&self) -> Result<BlacklistSettings, String> { pub fn get_settings(&self) -> Result<BlacklistSettings, String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
Ok(guard.as_ref().ok_or("No active session - please log in")? Ok(guard
.as_ref()
.ok_or("No active session - please log in")?
.get_settings()) .get_settings())
} }
@@ -139,7 +154,9 @@ impl BlacklistState {
/// Clear all blacklisted artists /// Clear all blacklisted artists
pub fn clear_all(&self) -> Result<(), String> { pub fn clear_all(&self) -> Result<(), String> {
let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?; let guard = self.service.lock().map_err(|_| "Failed to acquire lock")?;
guard.as_ref().ok_or("No active session - please log in")? guard
.as_ref()
.ok_or("No active session - please log in")?
.clear_all() .clear_all()
} }
} }
+76 -23
View File
@@ -7,9 +7,7 @@ use std::sync::Arc;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::api::QobuzClient; use crate::api::QobuzClient;
use crate::musicbrainz::models::{ use crate::musicbrainz::models::{ArtistFullResponse, ArtistRelationships, Period, RelatedArtist};
ArtistFullResponse, ArtistRelationships, Period, RelatedArtist,
};
use crate::musicbrainz::{MusicBrainzCache, MusicBrainzClient}; use crate::musicbrainz::{MusicBrainzCache, MusicBrainzClient};
use super::sparse_vector::SparseVector; use super::sparse_vector::SparseVector;
@@ -89,7 +87,9 @@ impl ArtistVectorBuilder {
log::info!("[VectorBuilder] Acquiring store lock..."); log::info!("[VectorBuilder] Acquiring store lock...");
{ {
let mut guard__ = self.store.lock().await; let mut guard__ = self.store.lock().await;
let store = guard__.as_mut().ok_or("No active session - please log in")?; let store = guard__
.as_mut()
.ok_or("No active session - please log in")?;
log::info!("[VectorBuilder] Store lock acquired, creating index"); log::info!("[VectorBuilder] Store lock acquired, creating index");
store.get_or_create_idx(artist_mbid, artist_name)?; store.get_or_create_idx(artist_mbid, artist_name)?;
} }
@@ -107,7 +107,11 @@ impl ArtistVectorBuilder {
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to fetch MusicBrainz relations for {}: {}", artist_mbid, e); log::warn!(
"Failed to fetch MusicBrainz relations for {}: {}",
artist_mbid,
e
);
} }
} }
@@ -132,7 +136,9 @@ impl ArtistVectorBuilder {
log::info!("[VectorBuilder] Persisting vectors..."); log::info!("[VectorBuilder] Persisting vectors...");
{ {
let mut guard__ = self.store.lock().await; let mut guard__ = self.store.lock().await;
let store = guard__.as_mut().ok_or("No active session - please log in")?; let store = guard__
.as_mut()
.ok_or("No active session - please log in")?;
// Store MB relationships // Store MB relationships
if let Some(mb_vec) = mb_vec_to_store { if let Some(mb_vec) = mb_vec_to_store {
@@ -157,27 +163,47 @@ impl ArtistVectorBuilder {
} }
/// Build vector component from MusicBrainz relationships /// Build vector component from MusicBrainz relationships
async fn build_from_musicbrainz(&self, artist_mbid: &str) -> Result<(SparseVector, usize), String> { async fn build_from_musicbrainz(
log::info!("[VectorBuilder] build_from_musicbrainz: checking cache for {}", artist_mbid); &self,
artist_mbid: &str,
) -> Result<(SparseVector, usize), String> {
log::info!(
"[VectorBuilder] build_from_musicbrainz: checking cache for {}",
artist_mbid
);
// Try cache first // Try cache first
log::info!("[VectorBuilder] Acquiring mb_cache lock..."); log::info!("[VectorBuilder] Acquiring mb_cache lock...");
let cached = { let cached = {
let guard__ = self.mb_cache.lock().await; let guard__ = self.mb_cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
log::info!("[VectorBuilder] mb_cache lock acquired, checking relations"); log::info!("[VectorBuilder] mb_cache lock acquired, checking relations");
cache.get_artist_relations(artist_mbid)? cache.get_artist_relations(artist_mbid)?
}; };
log::info!("[VectorBuilder] mb_cache lock released, cached={}", cached.is_some()); log::info!(
"[VectorBuilder] mb_cache lock released, cached={}",
cached.is_some()
);
let relations = if let Some(rel) = cached { let relations = if let Some(rel) = cached {
log::info!("[VectorBuilder] Cache HIT for {}", artist_mbid); log::info!("[VectorBuilder] Cache HIT for {}", artist_mbid);
rel rel
} else { } else {
log::info!("[VectorBuilder] Cache MISS - calling MusicBrainz API for {}", artist_mbid); log::info!(
"[VectorBuilder] Cache MISS - calling MusicBrainz API for {}",
artist_mbid
);
// Fetch from API // Fetch from API
let response = self.mb_client.get_artist_with_relations(artist_mbid).await?; let response = self
log::info!("[VectorBuilder] MusicBrainz API response received for {}", artist_mbid); .mb_client
.get_artist_with_relations(artist_mbid)
.await?;
log::info!(
"[VectorBuilder] MusicBrainz API response received for {}",
artist_mbid
);
// Extract relationships from raw response // Extract relationships from raw response
let extracted = extract_relationships(&response); let extracted = extract_relationships(&response);
@@ -185,7 +211,9 @@ impl ArtistVectorBuilder {
// Cache it // Cache it
{ {
let guard__ = self.mb_cache.lock().await; let guard__ = self.mb_cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
cache.set_artist_relations(artist_mbid, &extracted)?; cache.set_artist_relations(artist_mbid, &extracted)?;
} }
@@ -197,7 +225,9 @@ impl ArtistVectorBuilder {
// Get store for index lookups // Get store for index lookups
let mut guard__ = self.store.lock().await; let mut guard__ = self.store.lock().await;
let store = guard__.as_mut().ok_or("No active session - please log in")?; let store = guard__
.as_mut()
.ok_or("No active session - please log in")?;
// Process members (band → person) // Process members (band → person)
for member in &relations.members { for member in &relations.members {
@@ -235,7 +265,10 @@ impl ArtistVectorBuilder {
} }
/// Build vector component from Qobuz similar artists /// Build vector component from Qobuz similar artists
async fn build_from_qobuz(&self, qobuz_artist_id: u64) -> Result<(SparseVector, usize), String> { async fn build_from_qobuz(
&self,
qobuz_artist_id: u64,
) -> Result<(SparseVector, usize), String> {
let similar = { let similar = {
let client = self.qobuz_client.read().await; let client = self.qobuz_client.read().await;
client client
@@ -247,7 +280,9 @@ impl ArtistVectorBuilder {
let mut vector = SparseVector::new(); let mut vector = SparseVector::new();
let mut count = 0; let mut count = 0;
let mut guard__ = self.store.lock().await; let mut guard__ = self.store.lock().await;
let store = guard__.as_mut().ok_or("No active session - please log in")?; let store = guard__
.as_mut()
.ok_or("No active session - please log in")?;
for artist in similar.items { for artist in similar.items {
// We need to resolve Qobuz artist to MBID // We need to resolve Qobuz artist to MBID
@@ -278,18 +313,29 @@ impl ArtistVectorBuilder {
// Check if we have a fresh vector // Check if we have a fresh vector
let has_fresh = { let has_fresh = {
let guard__ = self.store.lock().await; let guard__ = self.store.lock().await;
let store = guard__.as_ref().ok_or("No active session - please log in")?; let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
store.has_fresh_vector(artist_mbid, max_age_secs) store.has_fresh_vector(artist_mbid, max_age_secs)
}; };
if has_fresh { if has_fresh {
log::debug!("[VectorBuilder] Artist {} has fresh vector, skipping build", artist_mbid); log::debug!(
"[VectorBuilder] Artist {} has fresh vector, skipping build",
artist_mbid
);
return Ok(false); return Ok(false);
} }
// Build new vector // Build new vector
log::info!("[VectorBuilder] Building vector for artist: {}", artist_mbid); log::info!(
match self.build_vector(artist_mbid, artist_name, qobuz_artist_id).await { "[VectorBuilder] Building vector for artist: {}",
artist_mbid
);
match self
.build_vector(artist_mbid, artist_name, qobuz_artist_id)
.await
{
Ok(result) => { Ok(result) => {
log::info!( log::info!(
"[VectorBuilder] Vector built: {} MB relations, {} Qobuz similar", "[VectorBuilder] Vector built: {} MB relations, {} Qobuz similar",
@@ -299,7 +345,11 @@ impl ArtistVectorBuilder {
Ok(true) Ok(true)
} }
Err(e) => { Err(e) => {
log::error!("[VectorBuilder] Failed to build vector for {}: {}", artist_mbid, e); log::error!(
"[VectorBuilder] Failed to build vector for {}: {}",
artist_mbid,
e
);
Err(e) Err(e)
} }
} }
@@ -356,7 +406,10 @@ fn extract_relationships(artist: &ArtistFullResponse) -> ArtistRelationships {
let related = RelatedArtist { let related = RelatedArtist {
mbid: related_artist.id.clone(), mbid: related_artist.id.clone(),
name: related_artist.name.clone(), name: related_artist.name.clone(),
role: relation.attributes.as_ref().and_then(|a| a.first().cloned()), role: relation
.attributes
.as_ref()
.and_then(|a| a.first().cloned()),
period: Some(Period { period: Some(Period {
begin: relation.begin.clone(), begin: relation.begin.clone(),
end: relation.end.clone(), end: relation.end.clone(),
+4 -2
View File
@@ -51,8 +51,10 @@ pub mod weights;
pub use builder::{ArtistVectorBuilder, BuildResult}; pub use builder::{ArtistVectorBuilder, BuildResult};
pub use sparse_vector::SparseVector; pub use sparse_vector::SparseVector;
pub use store::{ArtistVectorStore, ArtistVectorStoreState, SimilarArtist, StoreStats, VectorEntry}; pub use store::{
ArtistVectorStore, ArtistVectorStoreState, SimilarArtist, StoreStats, VectorEntry,
};
pub use suggestions::{ pub use suggestions::{
extract_artist_mbids, SuggestionConfig, SuggestionResult, SuggestedTrack, SuggestionsEngine, extract_artist_mbids, SuggestedTrack, SuggestionConfig, SuggestionResult, SuggestionsEngine,
}; };
pub use weights::RelationshipWeights; pub use weights::RelationshipWeights;
@@ -104,7 +104,10 @@ impl SparseVector {
/// Iterate over (index, value) pairs /// Iterate over (index, value) pairs
pub fn iter(&self) -> impl Iterator<Item = (u32, f32)> + '_ { pub fn iter(&self) -> impl Iterator<Item = (u32, f32)> + '_ {
self.indices.iter().copied().zip(self.values.iter().copied()) self.indices
.iter()
.copied()
.zip(self.values.iter().copied())
} }
/// Add two sparse vectors element-wise /// Add two sparse vectors element-wise
+269 -69
View File
@@ -41,9 +41,9 @@ pub struct SuggestionConfig {
impl Default for SuggestionConfig { impl Default for SuggestionConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_artists: 30, // Increased from 20 for more variety max_artists: 30, // Increased from 20 for more variety
tracks_per_artist: 6, // Increased from 5 tracks_per_artist: 6, // Increased from 5
max_pool_size: 150, // Increased from 100 max_pool_size: 150, // Increased from 100
vector_max_age_days: 7, vector_max_age_days: 7,
min_similarity: 0.1, min_similarity: 0.1,
skip_vector_build: false, skip_vector_build: false,
@@ -144,30 +144,48 @@ impl SuggestionsEngine {
} }
// Extract MBIDs for vector operations // Extract MBIDs for vector operations
let playlist_artist_mbids: Vec<String> = playlist_artists.iter().map(|(mbid, _)| mbid.clone()).collect(); let playlist_artist_mbids: Vec<String> = playlist_artists
.iter()
.map(|(mbid, _)| mbid.clone())
.collect();
// 1. Ensure vectors exist for playlist artists (skip if configured) // 1. Ensure vectors exist for playlist artists (skip if configured)
let step1_start = Instant::now(); let step1_start = Instant::now();
if self.config.skip_vector_build { if self.config.skip_vector_build {
log::debug!("[SuggestionsEngine] Step 1: SKIPPED (skip_vector_build=true), using only cached vectors"); log::debug!("[SuggestionsEngine] Step 1: SKIPPED (skip_vector_build=true), using only cached vectors");
} else { } else {
log::debug!("[SuggestionsEngine] Step 1: Ensuring vectors for {} artists", playlist_artists.len()); log::debug!(
"[SuggestionsEngine] Step 1: Ensuring vectors for {} artists",
playlist_artists.len()
);
for (i, (mbid, name)) in playlist_artists.iter().enumerate() { for (i, (mbid, name)) in playlist_artists.iter().enumerate() {
let artist_start = Instant::now(); let artist_start = Instant::now();
let _ = self let _ = self
.builder .builder
.ensure_vector(mbid, Some(name), None, self.config.vector_max_age_days) .ensure_vector(mbid, Some(name), None, self.config.vector_max_age_days)
.await; .await;
log::debug!("[SuggestionsEngine] ensure_vector {}/{} took {:?}", i + 1, playlist_artists.len(), artist_start.elapsed()); log::debug!(
"[SuggestionsEngine] ensure_vector {}/{} took {:?}",
i + 1,
playlist_artists.len(),
artist_start.elapsed()
);
} }
log::debug!("[SuggestionsEngine] Step 1 completed in {:?}", step1_start.elapsed()); log::debug!(
"[SuggestionsEngine] Step 1 completed in {:?}",
step1_start.elapsed()
);
} }
// 2. Compute combined playlist vector // 2. Compute combined playlist vector
log::debug!("[SuggestionsEngine] Step 2: Computing playlist vector"); log::debug!("[SuggestionsEngine] Step 2: Computing playlist vector");
let step2_start = Instant::now(); let step2_start = Instant::now();
let playlist_vector = self.compute_playlist_vector(&playlist_artist_mbids).await?; let playlist_vector = self.compute_playlist_vector(&playlist_artist_mbids).await?;
log::debug!("[SuggestionsEngine] Step 2 completed in {:?}, vector empty={}", step2_start.elapsed(), playlist_vector.is_empty()); log::debug!(
"[SuggestionsEngine] Step 2 completed in {:?}, vector empty={}",
step2_start.elapsed(),
playlist_vector.is_empty()
);
if playlist_vector.is_empty() { if playlist_vector.is_empty() {
log::warn!("[SuggestionsEngine] Playlist vector is empty, returning empty result"); log::warn!("[SuggestionsEngine] Playlist vector is empty, returning empty result");
@@ -185,19 +203,32 @@ impl SuggestionsEngine {
let exclude_vec: Vec<String> = playlist_artist_mbids.to_vec(); let exclude_vec: Vec<String> = playlist_artist_mbids.to_vec();
let similar_artists = { let similar_artists = {
let guard__ = self.store.lock().await; let guard__ = self.store.lock().await;
let store = guard__.as_ref().ok_or("No active session - please log in")?; let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Use direct relationship lookup instead of vector similarity // Use direct relationship lookup instead of vector similarity
// This finds members, collaborators, etc. from the MusicBrainz data // This finds members, collaborators, etc. from the MusicBrainz data
store.get_all_related_artists(&playlist_artist_mbids, &exclude_vec, self.config.max_artists)? store.get_all_related_artists(
&playlist_artist_mbids,
&exclude_vec,
self.config.max_artists,
)?
}; };
log::debug!("[SuggestionsEngine] Step 3 completed in {:?}, found {} related artists", step3_start.elapsed(), similar_artists.len()); log::debug!(
"[SuggestionsEngine] Step 3 completed in {:?}, found {} related artists",
step3_start.elapsed(),
similar_artists.len()
);
let similar_artists_count = similar_artists.len(); let similar_artists_count = similar_artists.len();
let mut source_artists = Vec::new(); let mut source_artists = Vec::new();
let mut all_tracks = Vec::new(); let mut all_tracks = Vec::new();
// 4a. First, search for tracks by playlist artists themselves (highest relevance) // 4a. First, search for tracks by playlist artists themselves (highest relevance)
log::info!("[SuggestionsEngine] Step 4a: Searching tracks for {} playlist artists", playlist_artists.len()); log::info!(
"[SuggestionsEngine] Step 4a: Searching tracks for {} playlist artists",
playlist_artists.len()
);
let step4a_start = Instant::now(); let step4a_start = Instant::now();
for (mbid, name) in playlist_artists { for (mbid, name) in playlist_artists {
@@ -207,9 +238,20 @@ impl SuggestionsEngine {
// Fetch many more tracks since many might already be in playlist // Fetch many more tracks since many might already be in playlist
// For a playlist with 23 tracks, we need to search beyond those to find new ones // For a playlist with 23 tracks, we need to search beyond those to find new ones
let playlist_artist_limit = (self.config.tracks_per_artist * 5).max(30); // At least 30 tracks let playlist_artist_limit = (self.config.tracks_per_artist * 5).max(30); // At least 30 tracks
log::info!("[SuggestionsEngine] Step 4a: Searching for '{}' (MBID: {}) with limit {}", name, mbid, playlist_artist_limit); log::info!(
let tracks = self.search_artist_tracks_with_limit(mbid, Some(name), 1.0, playlist_artist_limit).await; "[SuggestionsEngine] Step 4a: Searching for '{}' (MBID: {}) with limit {}",
log::info!("[SuggestionsEngine] Step 4a: Found {} tracks for '{}'", tracks.len(), name); name,
mbid,
playlist_artist_limit
);
let tracks = self
.search_artist_tracks_with_limit(mbid, Some(name), 1.0, playlist_artist_limit)
.await;
log::info!(
"[SuggestionsEngine] Step 4a: Found {} tracks for '{}'",
tracks.len(),
name
);
let mut added = 0; let mut added = 0;
let mut skipped = 0; let mut skipped = 0;
@@ -229,10 +271,17 @@ impl SuggestionsEngine {
} }
log::info!("[SuggestionsEngine] Step 4a: Added {} tracks for '{}' ({} skipped as already in playlist)", added, name, skipped); log::info!("[SuggestionsEngine] Step 4a: Added {} tracks for '{}' ({} skipped as already in playlist)", added, name, skipped);
} }
log::info!("[SuggestionsEngine] Step 4a completed in {:?}, got {} tracks from playlist artists", step4a_start.elapsed(), all_tracks.len()); log::info!(
"[SuggestionsEngine] Step 4a completed in {:?}, got {} tracks from playlist artists",
step4a_start.elapsed(),
all_tracks.len()
);
// 4b. Then search for tracks by related/similar artists // 4b. Then search for tracks by related/similar artists
log::debug!("[SuggestionsEngine] Step 4b: Searching tracks for {} related artists", similar_artists.len()); log::debug!(
"[SuggestionsEngine] Step 4b: Searching tracks for {} related artists",
similar_artists.len()
);
let step4b_start = Instant::now(); let step4b_start = Instant::now();
for (i, artist) in similar_artists.iter().enumerate() { for (i, artist) in similar_artists.iter().enumerate() {
@@ -272,11 +321,19 @@ impl SuggestionsEngine {
// Stop if we have enough tracks // Stop if we have enough tracks
if all_tracks.len() >= self.config.max_pool_size * 2 { if all_tracks.len() >= self.config.max_pool_size * 2 {
log::debug!("[SuggestionsEngine] Reached extended pool size {} after {} related artists", all_tracks.len(), i + 1); log::debug!(
"[SuggestionsEngine] Reached extended pool size {} after {} related artists",
all_tracks.len(),
i + 1
);
break; break;
} }
} }
log::debug!("[SuggestionsEngine] Step 4b completed in {:?}, got {} total tracks", step4b_start.elapsed(), all_tracks.len()); log::debug!(
"[SuggestionsEngine] Step 4b completed in {:?}, got {} total tracks",
step4b_start.elapsed(),
all_tracks.len()
);
// 4c. If pool is still small, use Qobuz's "similar artists" API as fallback // 4c. If pool is still small, use Qobuz's "similar artists" API as fallback
// This gives us artists that definitely exist in Qobuz // This gives us artists that definitely exist in Qobuz
@@ -308,7 +365,14 @@ impl SuggestionsEngine {
qobuz_similar_ids.insert(similar_artist.id); qobuz_similar_ids.insert(similar_artist.id);
// Check genre compatibility // Check genre compatibility
if self.has_incompatible_genre(&client, similar_artist.id, &similar_artist.name).await { if self
.has_incompatible_genre(
&client,
similar_artist.id,
&similar_artist.name,
)
.await
{
log::debug!( log::debug!(
"[SuggestionsEngine] Skipping Qobuz similar '{}' - incompatible genre", "[SuggestionsEngine] Skipping Qobuz similar '{}' - incompatible genre",
similar_artist.name similar_artist.name
@@ -321,12 +385,14 @@ impl SuggestionsEngine {
} }
// Search tracks for this similar artist (use empty MBID since we have Qobuz ID) // Search tracks for this similar artist (use empty MBID since we have Qobuz ID)
let tracks = self.search_artist_tracks_by_qobuz_id( let tracks = self
&client, .search_artist_tracks_by_qobuz_id(
similar_artist.id, &client,
&similar_artist.name, similar_artist.id,
0.8, // High similarity since Qobuz says they're similar &similar_artist.name,
).await; 0.8, // High similarity since Qobuz says they're similar
)
.await;
for mut track in tracks { for mut track in tracks {
if exclude_track_ids.contains(&track.track_id) { if exclude_track_ids.contains(&track.track_id) {
@@ -371,7 +437,11 @@ impl SuggestionsEngine {
// 5. Deduplicate by title+artist (keeps highest similarity version) // 5. Deduplicate by title+artist (keeps highest similarity version)
let mut seen_titles: HashSet<String> = HashSet::new(); let mut seen_titles: HashSet<String> = HashSet::new();
all_tracks.retain(|track| { all_tracks.retain(|track| {
let key = format!("{}|{}", track.title.to_lowercase(), track.artist_name.to_lowercase()); let key = format!(
"{}|{}",
track.title.to_lowercase(),
track.artist_name.to_lowercase()
);
seen_titles.insert(key) seen_titles.insert(key)
}); });
@@ -398,7 +468,9 @@ impl SuggestionsEngine {
) -> Result<SparseVector, String> { ) -> Result<SparseVector, String> {
let mut combined = SparseVector::new(); let mut combined = SparseVector::new();
let guard__ = self.store.lock().await; let guard__ = self.store.lock().await;
let store = guard__.as_ref().ok_or("No active session - please log in")?; let store = guard__
.as_ref()
.ok_or("No active session - please log in")?;
for mbid in artist_mbids { for mbid in artist_mbids {
if let Some(vector) = store.get_vector(mbid) { if let Some(vector) = store.get_vector(mbid) {
@@ -417,7 +489,13 @@ impl SuggestionsEngine {
artist_name: Option<&str>, artist_name: Option<&str>,
similarity: f32, similarity: f32,
) -> Vec<SuggestedTrack> { ) -> Vec<SuggestedTrack> {
self.search_artist_tracks_with_limit(artist_mbid, artist_name, similarity, self.config.tracks_per_artist).await self.search_artist_tracks_with_limit(
artist_mbid,
artist_name,
similarity,
self.config.tracks_per_artist,
)
.await
} }
/// Search Qobuz for tracks by Qobuz artist ID (more reliable when we already validated the artist) /// Search Qobuz for tracks by Qobuz artist ID (more reliable when we already validated the artist)
@@ -432,7 +510,10 @@ impl SuggestionsEngine {
let limit = self.config.tracks_per_artist; let limit = self.config.tracks_per_artist;
// Search by artist name but verify tracks belong to this specific Qobuz artist ID // Search by artist name but verify tracks belong to this specific Qobuz artist ID
match client.search_tracks(artist_name, (limit * 3) as u32, 0, None).await { match client
.search_tracks(artist_name, (limit * 3) as u32, 0, None)
.await
{
Ok(results) => { Ok(results) => {
let mut tracks = Vec::new(); let mut tracks = Vec::new();
@@ -443,7 +524,11 @@ impl SuggestionsEngine {
continue; continue;
} }
tracks.push(self.track_to_suggested_with_qobuz_id(&item, qobuz_artist_id, similarity)); tracks.push(self.track_to_suggested_with_qobuz_id(
&item,
qobuz_artist_id,
similarity,
));
if tracks.len() >= limit { if tracks.len() >= limit {
break; break;
} }
@@ -452,17 +537,29 @@ impl SuggestionsEngine {
tracks tracks
} }
Err(e) => { Err(e) => {
log::warn!("Failed to search tracks for {} (Qobuz ID {}): {}", artist_name, qobuz_artist_id, e); log::warn!(
"Failed to search tracks for {} (Qobuz ID {}): {}",
artist_name,
qobuz_artist_id,
e
);
Vec::new() Vec::new()
} }
} }
} }
/// Convert a Track to a SuggestedTrack (using Qobuz artist ID instead of MBID) /// Convert a Track to a SuggestedTrack (using Qobuz artist ID instead of MBID)
fn track_to_suggested_with_qobuz_id(&self, track: &Track, _qobuz_artist_id: u64, similarity: f32) -> SuggestedTrack { fn track_to_suggested_with_qobuz_id(
&self,
track: &Track,
_qobuz_artist_id: u64,
similarity: f32,
) -> SuggestedTrack {
let (album_title, album_id, album_image_url) = match &track.album { let (album_title, album_id, album_image_url) = match &track.album {
Some(album) => { Some(album) => {
let image_url = album.image.thumbnail let image_url = album
.image
.thumbnail
.as_ref() .as_ref()
.or(album.image.small.as_ref()) .or(album.image.small.as_ref())
.or(album.image.large.as_ref()) .or(album.image.large.as_ref())
@@ -536,7 +633,9 @@ impl SuggestionsEngine {
let (qobuz_artist_id, qobuz_artist_name) = validated_artist.unwrap(); let (qobuz_artist_id, qobuz_artist_name) = validated_artist.unwrap();
log::info!( log::info!(
"[SuggestionsEngine] Validated '{}' -> Qobuz artist '{}' (ID: {})", "[SuggestionsEngine] Validated '{}' -> Qobuz artist '{}' (ID: {})",
search_query, qobuz_artist_name, qobuz_artist_id search_query,
qobuz_artist_name,
qobuz_artist_id
); );
// Step 2: Search for tracks by artist name // Step 2: Search for tracks by artist name
@@ -600,7 +699,11 @@ impl SuggestionsEngine {
let mut results = match client.search_artists(name, 10, 0, None).await { let mut results = match client.search_artists(name, 10, 0, None).await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
log::warn!("[SuggestionsEngine] Artist search failed for '{}': {}", name, e); log::warn!(
"[SuggestionsEngine] Artist search failed for '{}': {}",
name,
e
);
return None; return None;
} }
}; };
@@ -610,7 +713,8 @@ impl SuggestionsEngine {
if results.items.is_empty() && name != name_normalized { if results.items.is_empty() && name != name_normalized {
log::debug!( log::debug!(
"[SuggestionsEngine] No results for '{}', trying normalized '{}'", "[SuggestionsEngine] No results for '{}', trying normalized '{}'",
name, name_normalized name,
name_normalized
); );
if let Ok(r) = client.search_artists(&name_normalized, 10, 0, None).await { if let Ok(r) = client.search_artists(&name_normalized, 10, 0, None).await {
results = r; results = r;
@@ -636,7 +740,8 @@ impl SuggestionsEngine {
let the_name_normalized = format!("the {}", name_normalized); let the_name_normalized = format!("the {}", name_normalized);
for artist in &results.items { for artist in &results.items {
let artist_normalized = normalize_name(&artist.name); let artist_normalized = normalize_name(&artist.name);
if artist_normalized == the_name_normalized && artist.albums_count.unwrap_or(0) > 0 { if artist_normalized == the_name_normalized && artist.albums_count.unwrap_or(0) > 0
{
candidate = Some((artist.id, artist.name.clone())); candidate = Some((artist.id, artist.name.clone()));
break; break;
} }
@@ -645,10 +750,14 @@ impl SuggestionsEngine {
// If we found a candidate, verify their genre is compatible // If we found a candidate, verify their genre is compatible
if let Some((artist_id, artist_name)) = candidate { if let Some((artist_id, artist_name)) = candidate {
if self.has_incompatible_genre(client, artist_id, &artist_name).await { if self
.has_incompatible_genre(client, artist_id, &artist_name)
.await
{
log::info!( log::info!(
"[SuggestionsEngine] Rejecting '{}' (ID: {}) - incompatible genre detected", "[SuggestionsEngine] Rejecting '{}' (ID: {}) - incompatible genre detected",
artist_name, artist_id artist_name,
artist_id
); );
return None; return None;
} }
@@ -662,37 +771,94 @@ impl SuggestionsEngine {
/// ///
/// Fetches a few albums and checks their genres against a blocklist. /// Fetches a few albums and checks their genres against a blocklist.
/// Returns true if incompatible, false if compatible or unknown. /// Returns true if incompatible, false if compatible or unknown.
async fn has_incompatible_genre(&self, client: &QobuzClient, artist_id: u64, artist_name: &str) -> bool { async fn has_incompatible_genre(
&self,
client: &QobuzClient,
artist_id: u64,
artist_name: &str,
) -> bool {
// Incompatible genre keywords - these would never appear in a rock/metal context // Incompatible genre keywords - these would never appear in a rock/metal context
// NOTE: We force English locale when fetching, so only English names needed // NOTE: We force English locale when fetching, so only English names needed
const INCOMPATIBLE_GENRES: &[&str] = &[ const INCOMPATIBLE_GENRES: &[&str] = &[
// Latin/Tropical // Latin/Tropical
"bachata", "merengue", "reggaeton", "salsa", "cumbia", "vallenato", "bachata",
"latin pop", "latin music", "tropical", "urbano", "regional mexican", "merengue",
"latin", // Generic Latin parent genre "reggaeton",
"salsa",
"cumbia",
"vallenato",
"latin pop",
"latin music",
"tropical",
"urbano",
"regional mexican",
"latin", // Generic Latin parent genre
// Asian pop // Asian pop
"k-pop", "kpop", "j-pop", "jpop", "mandopop", "cantopop", "c-pop", "k-pop",
"kpop",
"j-pop",
"jpop",
"mandopop",
"cantopop",
"c-pop",
// European folk/schlager // European folk/schlager
"schlager", "chanson", "french chanson", "volksmusik", "schlager",
"chanson",
"french chanson",
"volksmusik",
// Religious // Religious
"gospel", "christian", "worship", "religious", "spiritual", "gospel",
"christian",
"worship",
"religious",
"spiritual",
// Children/Family // Children/Family
"children", "nursery", "lullaby", "kids", "children",
"nursery",
"lullaby",
"kids",
// Electronic/Dance (club-oriented) // Electronic/Dance (club-oriented)
"trance", "techno", "house", "edm", "dubstep", "drum and bass", "trance",
"hardstyle", "eurodance", "hands up", "happy hardcore", "dance", "techno",
"house",
"edm",
"dubstep",
"drum and bass",
"hardstyle",
"eurodance",
"hands up",
"happy hardcore",
"dance",
// Spoken word/Non-music // Spoken word/Non-music
"audiobook", "spoken word", "podcast", "meditation", "asmr", "audiobook",
"relaxation", "sleep", "nature sounds", "white noise", "spoken word",
"comedy", "stand-up", "podcast",
"meditation",
"asmr",
"relaxation",
"sleep",
"nature sounds",
"white noise",
"comedy",
"stand-up",
// Country (usually incompatible with metal) // Country (usually incompatible with metal)
"country", "bluegrass", "americana", "country",
"bluegrass",
"americana",
// New age/Wellness // New age/Wellness
"new age", "healing", "spa", "yoga", "mindfulness", "wellness", "new age",
"healing",
"spa",
"yoga",
"mindfulness",
"wellness",
]; ];
// Fetch artist with a few albums (use English locale for consistent genre names) // Fetch artist with a few albums (use English locale for consistent genre names)
match client.get_artist_with_pagination_and_locale(artist_id, true, Some(5), None, Some("en")).await { match client
.get_artist_with_pagination_and_locale(artist_id, true, Some(5), None, Some("en"))
.await
{
Ok(artist) => { Ok(artist) => {
if let Some(albums) = &artist.albums { if let Some(albums) = &artist.albums {
for album in &albums.items { for album in &albums.items {
@@ -725,10 +891,23 @@ impl SuggestionsEngine {
// Additional title-based checks for non-music content // Additional title-based checks for non-music content
const INCOMPATIBLE_TITLE_KEYWORDS: &[&str] = &[ const INCOMPATIBLE_TITLE_KEYWORDS: &[&str] = &[
"audiobook", "hörbuch", "hörspiel", "gelesen von", "read by", "audiobook",
"narrated by", "lesung", "märchen", "fairy tale", "hörbuch",
"meditation", "relaxation", "sleep music", "yoga music", "hörspiel",
"trance mix", "club mix", "dance mix", "dj mix", "gelesen von",
"read by",
"narrated by",
"lesung",
"märchen",
"fairy tale",
"meditation",
"relaxation",
"sleep music",
"yoga music",
"trance mix",
"club mix",
"dance mix",
"dj mix",
]; ];
for keyword in INCOMPATIBLE_TITLE_KEYWORDS { for keyword in INCOMPATIBLE_TITLE_KEYWORDS {
if title_lower.contains(keyword) { if title_lower.contains(keyword) {
@@ -746,7 +925,8 @@ impl SuggestionsEngine {
Err(e) => { Err(e) => {
log::warn!( log::warn!(
"[SuggestionsEngine] Failed to fetch albums for genre check ({}): {}", "[SuggestionsEngine] Failed to fetch albums for genre check ({}): {}",
artist_name, e artist_name,
e
); );
// On error, don't block - let it through // On error, don't block - let it through
false false
@@ -755,11 +935,18 @@ impl SuggestionsEngine {
} }
/// Convert a Track to a SuggestedTrack /// Convert a Track to a SuggestedTrack
fn track_to_suggested(&self, track: &Track, artist_mbid: &str, similarity: f32) -> SuggestedTrack { fn track_to_suggested(
&self,
track: &Track,
artist_mbid: &str,
similarity: f32,
) -> SuggestedTrack {
// Extract album info including image URL // Extract album info including image URL
let (album_title, album_id, album_image_url) = match &track.album { let (album_title, album_id, album_image_url) = match &track.album {
Some(album) => { Some(album) => {
let image_url = album.image.thumbnail let image_url = album
.image
.thumbnail
.as_ref() .as_ref()
.or(album.image.small.as_ref()) .or(album.image.small.as_ref())
.or(album.image.large.as_ref()) .or(album.image.large.as_ref())
@@ -808,10 +995,23 @@ impl SuggestionsEngine {
/// Normalize a name for comparison (remove accents, lowercase) /// Normalize a name for comparison (remove accents, lowercase)
fn normalize_name(name: &str) -> String { fn normalize_name(name: &str) -> String {
name.to_lowercase() name.to_lowercase()
.replace('á', "a").replace('é', "e").replace('í', "i").replace('ó', "o").replace('ú', "u") .replace('á', "a")
.replace('à', "a").replace('è', "e").replace('ì', "i").replace('ò', "o").replace('ù', "u") .replace('é', "e")
.replace('ä', "a").replace('ë', "e").replace('ï', "i").replace('ö', "o").replace('ü', "u") .replace('í', "i")
.replace('ñ', "n").replace('ç', "c") .replace('ó', "o")
.replace('ú', "u")
.replace('à', "a")
.replace('è', "e")
.replace('ì', "i")
.replace('ò', "o")
.replace('ù', "u")
.replace('ä', "a")
.replace('ë', "e")
.replace('ï', "i")
.replace('ö', "o")
.replace('ü', "u")
.replace('ñ', "n")
.replace('ç', "c")
} }
/// Check if two artist names are similar enough to be considered a match /// Check if two artist names are similar enough to be considered a match
@@ -893,8 +1093,8 @@ mod tests {
(1, Some("mbid-1".to_string())), (1, Some("mbid-1".to_string())),
(2, Some("mbid-2".to_string())), (2, Some("mbid-2".to_string())),
(3, Some("mbid-1".to_string())), // Duplicate (3, Some("mbid-1".to_string())), // Duplicate
(4, None), // No MBID (4, None), // No MBID
(5, Some("".to_string())), // Empty MBID (5, Some("".to_string())), // Empty MBID
(6, Some("mbid-3".to_string())), (6, Some("mbid-3".to_string())),
]; ];
+7 -8
View File
@@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationshipWeights { pub struct RelationshipWeights {
// === MusicBrainz artist-to-artist relationships === // === MusicBrainz artist-to-artist relationships ===
/// Weight for band membership (artist was/is member of group) /// Weight for band membership (artist was/is member of group)
/// Strongest connection - same creative unit /// Strongest connection - same creative unit
pub member_of_band: f32, pub member_of_band: f32,
@@ -24,7 +23,6 @@ pub struct RelationshipWeights {
pub founder: f32, pub founder: f32,
// === MusicBrainz recording/release relationships === // === MusicBrainz recording/release relationships ===
/// Weight for performer credit on a recording /// Weight for performer credit on a recording
pub performer: f32, pub performer: f32,
@@ -41,17 +39,14 @@ pub struct RelationshipWeights {
pub engineer: f32, pub engineer: f32,
// === Qobuz relationships === // === Qobuz relationships ===
/// Weight for Qobuz similar artists /// Weight for Qobuz similar artists
pub qobuz_similar: f32, pub qobuz_similar: f32,
// === Tag-based relationships === // === Tag-based relationships ===
/// Weight for shared MusicBrainz tags (genres) /// Weight for shared MusicBrainz tags (genres)
pub shared_tag: f32, pub shared_tag: f32,
// === Behavioral relationships === // === Behavioral relationships ===
/// Weight for user listening affinity (artists played together) /// Weight for user listening affinity (artists played together)
pub user_affinity: f32, pub user_affinity: f32,
} }
@@ -115,7 +110,7 @@ impl RelationshipWeights {
producer: 0.3, producer: 0.3,
conductor: 0.3, conductor: 0.3,
engineer: 0.2, engineer: 0.2,
qobuz_similar: 1.0, // Prioritize Qobuz similarity qobuz_similar: 1.0, // Prioritize Qobuz similarity
shared_tag: 0.5, shared_tag: 0.5,
user_affinity: 0.6, user_affinity: 0.6,
} }
@@ -133,8 +128,12 @@ impl RelationshipWeights {
"collaboration" | "collaborated" | "collaborator" => self.collaboration, "collaboration" | "collaborated" | "collaborator" => self.collaboration,
// Performance credits // Performance credits
"performer" | "vocal" | "instrument" | "performing orchestra" "performer"
| "orchestra" | "chorus master" => self.performer, | "vocal"
| "instrument"
| "performing orchestra"
| "orchestra"
| "chorus master" => self.performer,
// Composition // Composition
"composer" | "writer" | "lyricist" | "librettist" | "arranger" => self.composer, "composer" | "writer" | "lyricist" | "librettist" | "arranger" => self.composer,
+1 -7
View File
@@ -11,10 +11,4 @@
//! 2. When loading, check memory -> disk -> network //! 2. When loading, check memory -> disk -> network
// Re-export everything from qbz-cache // Re-export everything from qbz-cache
pub use qbz_cache::{ pub use qbz_cache::{AudioCache, CacheStats, CachedTrack, PlaybackCache, PlaybackCacheStats};
AudioCache,
CachedTrack,
CacheStats,
PlaybackCache,
PlaybackCacheStats,
};
+23 -10
View File
@@ -29,9 +29,7 @@ impl AirPlayState {
#[tauri::command] #[tauri::command]
pub async fn airplay_start_discovery(state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_start_discovery(state: State<'_, AirPlayState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await; let mut discovery = state.discovery.lock().await;
discovery discovery.start_discovery().map_err(|e| e.to_string())
.start_discovery()
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -51,7 +49,10 @@ pub async fn airplay_get_devices(
// === Connection === // === Connection ===
#[tauri::command] #[tauri::command]
pub async fn airplay_connect(device_id: String, state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_connect(
device_id: String,
state: State<'_, AirPlayState>,
) -> Result<(), String> {
let device = { let device = {
let discovery = state.discovery.lock().await; let discovery = state.discovery.lock().await;
discovery discovery
@@ -79,7 +80,9 @@ pub async fn airplay_disconnect(state: State<'_, AirPlayState>) -> Result<(), St
#[tauri::command] #[tauri::command]
pub async fn airplay_get_status(state: State<'_, AirPlayState>) -> Result<AirPlayStatus, String> { pub async fn airplay_get_status(state: State<'_, AirPlayState>) -> Result<AirPlayStatus, String> {
let connection = state.connection.lock().await; let connection = state.connection.lock().await;
let conn = connection.as_ref().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_ref()
.ok_or_else(|| "Not connected".to_string())?;
Ok(conn.get_status()) Ok(conn.get_status())
} }
@@ -91,34 +94,44 @@ pub async fn airplay_load_media(
state: State<'_, AirPlayState>, state: State<'_, AirPlayState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.load_media(metadata).map_err(|e| e.to_string()) conn.load_media(metadata).map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn airplay_play(state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_play(state: State<'_, AirPlayState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.play().map_err(|e| e.to_string()) conn.play().map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn airplay_pause(state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_pause(state: State<'_, AirPlayState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.pause().map_err(|e| e.to_string()) conn.pause().map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn airplay_stop(state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_stop(state: State<'_, AirPlayState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.stop().map_err(|e| e.to_string()) conn.stop().map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn airplay_set_volume(volume: f32, state: State<'_, AirPlayState>) -> Result<(), String> { pub async fn airplay_set_volume(volume: f32, state: State<'_, AirPlayState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.set_volume(volume).map_err(|e| e.to_string()) conn.set_volume(volume).map_err(|e| e.to_string())
} }
+2 -2
View File
@@ -6,8 +6,8 @@ pub mod commands;
// Re-export from qbz-cast // Re-export from qbz-cast
pub use qbz_cast::{ pub use qbz_cast::{
AirPlayConnection, AirPlayMetadata, AirPlayStatus, AirPlayDiscovery, DiscoveredAirPlayDevice, AirPlayConnection, AirPlayDiscovery, AirPlayError, AirPlayMetadata, AirPlayStatus,
AirPlayError, DiscoveredAirPlayDevice,
}; };
// Re-export Tauri command state // Re-export Tauri command state
+35 -15
View File
@@ -5,12 +5,13 @@ use tauri::State;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::api::models::Quality; use crate::api::models::Quality;
use crate::AppState;
use crate::cast::{
CastError, CastStatus, CastPositionInfo, DeviceDiscovery, DiscoveredDevice, MediaMetadata, MediaServer,
};
use crate::cast::chromecast_thread::ChromecastHandle; use crate::cast::chromecast_thread::ChromecastHandle;
use crate::cast::{
CastError, CastPositionInfo, CastStatus, DeviceDiscovery, DiscoveredDevice, MediaMetadata,
MediaServer,
};
use crate::library::{AudioFormat, LibraryState}; use crate::library::{AudioFormat, LibraryState};
use crate::AppState;
/// Cast state shared across commands /// Cast state shared across commands
/// Uses a dedicated thread for Chromecast operations since rust_cast is not thread-safe /// Uses a dedicated thread for Chromecast operations since rust_cast is not thread-safe
@@ -49,9 +50,7 @@ impl CastState {
#[tauri::command] #[tauri::command]
pub async fn cast_start_discovery(state: State<'_, CastState>) -> Result<(), String> { pub async fn cast_start_discovery(state: State<'_, CastState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await; let mut discovery = state.discovery.lock().await;
discovery discovery.start_discovery().map_err(|e| e.to_string())
.start_discovery()
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -112,7 +111,10 @@ pub async fn cast_get_status(state: State<'_, CastState>) -> Result<CastStatus,
#[tauri::command] #[tauri::command]
pub async fn cast_get_position(state: State<'_, CastState>) -> Result<CastPositionInfo, String> { pub async fn cast_get_position(state: State<'_, CastState>) -> Result<CastPositionInfo, String> {
state.chromecast.get_media_position().map_err(|e| e.to_string()) state
.chromecast
.get_media_position()
.map_err(|e| e.to_string())
} }
// === Playback === // === Playback ===
@@ -149,11 +151,16 @@ pub async fn cast_play_track(
}; };
// Ensure media server is started (lazy init) // Ensure media server is started (lazy init)
state.get_or_create_media_server().await.map_err(|e| e.to_string())?; state
.get_or_create_media_server()
.await
.map_err(|e| e.to_string())?;
let url = { let url = {
let mut server_guard = state.media_server.lock().await; let mut server_guard = state.media_server.lock().await;
let server = server_guard.as_mut().ok_or("Media server not initialized")?; let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(track_id, audio_data, &content_type); server.register_audio(track_id, audio_data, &content_type);
let url = match target_ip.as_deref() { let url = match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(track_id, ip), Some(ip) => server.get_audio_url_for_target(track_id, ip),
@@ -176,7 +183,9 @@ pub async fn cast_play_local_track(
) -> Result<(), String> { ) -> Result<(), String> {
let track = { let track = {
let db_opt__ = library_state.db.lock().await; let db_opt__ = library_state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_track(track_id) db.get_track(track_id)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.ok_or_else(|| "Track not found".to_string())? .ok_or_else(|| "Track not found".to_string())?
@@ -188,11 +197,16 @@ pub async fn cast_play_local_track(
}; };
// Ensure media server is started (lazy init) // Ensure media server is started (lazy init)
state.get_or_create_media_server().await.map_err(|e| e.to_string())?; state
.get_or_create_media_server()
.await
.map_err(|e| e.to_string())?;
let url = { let url = {
let mut server_guard = state.media_server.lock().await; let mut server_guard = state.media_server.lock().await;
let server = server_guard.as_mut().ok_or("Media server not initialized")?; let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server server
.register_file(track_id as u64, &track.file_path) .register_file(track_id as u64, &track.file_path)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -236,12 +250,18 @@ pub async fn cast_stop(state: State<'_, CastState>) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub async fn cast_seek(position_secs: f64, state: State<'_, CastState>) -> Result<(), String> { pub async fn cast_seek(position_secs: f64, state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.seek(position_secs).map_err(|e| e.to_string()) state
.chromecast
.seek(position_secs)
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn cast_set_volume(volume: f32, state: State<'_, CastState>) -> Result<(), String> { pub async fn cast_set_volume(volume: f32, state: State<'_, CastState>) -> Result<(), String> {
state.chromecast.set_volume(volume).map_err(|e| e.to_string()) state
.chromecast
.set_volume(volume)
.map_err(|e| e.to_string())
} }
async fn download_audio(url: &str) -> Result<Vec<u8>, String> { async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
+50 -21
View File
@@ -5,11 +5,12 @@ use tauri::State;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::api::models::Quality; use crate::api::models::Quality;
use crate::AppState;
use crate::cast::dlna::{ use crate::cast::dlna::{
DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo, DlnaStatus, DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo,
DlnaStatus,
}; };
use crate::cast::MediaServer; use crate::cast::MediaServer;
use crate::AppState;
/// DLNA state shared across commands /// DLNA state shared across commands
pub struct DlnaState { pub struct DlnaState {
@@ -46,10 +47,7 @@ impl DlnaState {
#[tauri::command] #[tauri::command]
pub async fn dlna_start_discovery(state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_start_discovery(state: State<'_, DlnaState>) -> Result<(), String> {
let mut discovery = state.discovery.lock().await; let mut discovery = state.discovery.lock().await;
discovery discovery.start_discovery().await.map_err(|e| e.to_string())
.start_discovery()
.await
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -78,7 +76,9 @@ pub async fn dlna_connect(device_id: String, state: State<'_, DlnaState>) -> Res
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
let connection = DlnaConnection::connect(device).await.map_err(|e| e.to_string())?; let connection = DlnaConnection::connect(device)
.await
.map_err(|e| e.to_string())?;
let mut state_connection = state.connection.lock().await; let mut state_connection = state.connection.lock().await;
*state_connection = Some(connection); *state_connection = Some(connection);
Ok(()) Ok(())
@@ -97,7 +97,9 @@ pub async fn dlna_disconnect(state: State<'_, DlnaState>) -> Result<(), String>
#[tauri::command] #[tauri::command]
pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus, String> { pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus, String> {
let connection = state.connection.lock().await; let connection = state.connection.lock().await;
let conn = connection.as_ref().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_ref()
.ok_or_else(|| "Not connected".to_string())?;
Ok(conn.get_status()) Ok(conn.get_status())
} }
@@ -105,7 +107,9 @@ pub async fn dlna_get_status(state: State<'_, DlnaState>) -> Result<DlnaStatus,
#[tauri::command] #[tauri::command]
pub async fn dlna_get_position(state: State<'_, DlnaState>) -> Result<DlnaPositionInfo, String> { pub async fn dlna_get_position(state: State<'_, DlnaState>) -> Result<DlnaPositionInfo, String> {
let connection = state.connection.lock().await; let connection = state.connection.lock().await;
let conn = connection.as_ref().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_ref()
.ok_or_else(|| "Not connected".to_string())?;
conn.get_position_info().await.map_err(|e| e.to_string()) conn.get_position_info().await.map_err(|e| e.to_string())
} }
@@ -120,7 +124,7 @@ pub async fn dlna_play_track(
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("DLNA: dlna_play_track called for track_id={}", track_id); log::info!("DLNA: dlna_play_track called for track_id={}", track_id);
// Get stream URL from Qobuz // Get stream URL from Qobuz
let stream_url = { let stream_url = {
let client = app_state.client.read().await; let client = app_state.client.read().await;
@@ -149,11 +153,16 @@ pub async fn dlna_play_track(
}; };
// Ensure media server is started (lazy init) // Ensure media server is started (lazy init)
dlna_state.ensure_media_server().await.map_err(|e| e.to_string())?; dlna_state
.ensure_media_server()
.await
.map_err(|e| e.to_string())?;
let url = { let url = {
let mut server_guard = dlna_state.media_server.lock().await; let mut server_guard = dlna_state.media_server.lock().await;
let server = server_guard.as_mut().ok_or("Media server not initialized")?; let server = server_guard
.as_mut()
.ok_or("Media server not initialized")?;
server.register_audio(track_id, audio_data, &content_type); server.register_audio(track_id, audio_data, &content_type);
let url = match target_ip.as_deref() { let url = match target_ip.as_deref() {
Some(ip) => server.get_audio_url_for_target(track_id, ip), Some(ip) => server.get_audio_url_for_target(track_id, ip),
@@ -162,20 +171,30 @@ pub async fn dlna_play_track(
url.ok_or_else(|| "Failed to build media URL".to_string())? url.ok_or_else(|| "Failed to build media URL".to_string())?
}; };
log::info!("DLNA: Playing track {} via MediaServer URL: {}", track_id, url); log::info!(
"DLNA: Playing track {} via MediaServer URL: {}",
track_id,
url
);
log::info!("DLNA: Content-Type from Qobuz: {}", content_type); log::info!("DLNA: Content-Type from Qobuz: {}", content_type);
// Load media on DLNA device // Load media on DLNA device
{ {
let mut connection = dlna_state.connection.lock().await; let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
conn.load_media(&url, &metadata, &content_type).await.map_err(|e| e.to_string())?; .as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.load_media(&url, &metadata, &content_type)
.await
.map_err(|e| e.to_string())?;
} }
// Start playback // Start playback
{ {
let mut connection = dlna_state.connection.lock().await; let mut connection = dlna_state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.play().await.map_err(|e| e.to_string())?; conn.play().await.map_err(|e| e.to_string())?;
} }
@@ -197,35 +216,45 @@ pub async fn dlna_load_media(
#[tauri::command] #[tauri::command]
pub async fn dlna_play(state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_play(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.play().await.map_err(|e| e.to_string()) conn.play().await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn dlna_pause(state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_pause(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.pause().await.map_err(|e| e.to_string()) conn.pause().await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn dlna_stop(state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_stop(state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.stop().await.map_err(|e| e.to_string()) conn.stop().await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn dlna_seek(position_secs: u64, state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_seek(position_secs: u64, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.seek(position_secs).await.map_err(|e| e.to_string()) conn.seek(position_secs).await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn dlna_set_volume(volume: f32, state: State<'_, DlnaState>) -> Result<(), String> { pub async fn dlna_set_volume(volume: f32, state: State<'_, DlnaState>) -> Result<(), String> {
let mut connection = state.connection.lock().await; let mut connection = state.connection.lock().await;
let conn = connection.as_mut().ok_or_else(|| "Not connected".to_string())?; let conn = connection
.as_mut()
.ok_or_else(|| "Not connected".to_string())?;
conn.set_volume(volume).await.map_err(|e| e.to_string()) conn.set_volume(volume).await.map_err(|e| e.to_string())
} }
+2 -2
View File
@@ -6,8 +6,8 @@ pub mod commands;
// Re-export from qbz-cast // Re-export from qbz-cast
pub use qbz_cast::{ pub use qbz_cast::{
DlnaConnection, DlnaMetadata, DlnaPositionInfo, DlnaStatus, DlnaDiscovery, DiscoveredDlnaDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo,
DiscoveredDlnaDevice, DlnaError, DlnaStatus,
}; };
// Re-export Tauri command state // Re-export Tauri command state
+1 -1
View File
@@ -2,4 +2,4 @@
//! //!
//! Re-exported from qbz-cast crate. //! Re-exported from qbz-cast crate.
pub use qbz_cast::{CastError, AirPlayError, DlnaError}; pub use qbz_cast::{AirPlayError, CastError, DlnaError};
+8 -9
View File
@@ -3,26 +3,25 @@
//! This module provides Tauri commands for casting functionality. //! This module provides Tauri commands for casting functionality.
//! The core casting logic is in the qbz-cast crate. //! The core casting logic is in the qbz-cast crate.
pub mod airplay;
pub mod chromecast_thread; pub mod chromecast_thread;
pub mod commands; pub mod commands;
pub mod device; pub mod device;
pub mod discovery; pub mod discovery;
pub mod dlna;
pub mod errors; pub mod errors;
pub mod media_server; pub mod media_server;
pub mod airplay;
pub mod dlna;
// Re-export from qbz-cast for internal use // Re-export from qbz-cast for internal use
pub use qbz_cast::{ pub use qbz_cast::{
CastError, CastDevice, CastDeviceConnection, CastStatus, MediaMetadata, CastPositionInfo, AirPlayConnection, AirPlayDiscovery, AirPlayError, AirPlayMetadata, AirPlayStatus, CastCommand,
DeviceDiscovery, DiscoveredDevice, MediaServer, ChromecastHandle, CastCommand, CastDevice, CastDeviceConnection, CastError, CastPositionInfo, CastStatus, ChromecastHandle,
AirPlayConnection, AirPlayDiscovery, AirPlayError, AirPlayMetadata, AirPlayStatus, DeviceDiscovery, DiscoveredAirPlayDevice, DiscoveredDevice, DiscoveredDlnaDevice,
DiscoveredAirPlayDevice, DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaPositionInfo, DlnaStatus,
DlnaConnection, DlnaDiscovery, DlnaError, DlnaMetadata, DlnaStatus, DlnaPositionInfo, MediaMetadata, MediaServer,
DiscoveredDlnaDevice,
}; };
// Re-export Tauri command states // Re-export Tauri command states
pub use commands::CastState;
pub use airplay::commands::AirPlayState; pub use airplay::commands::AirPlayState;
pub use commands::CastState;
pub use dlna::commands::DlnaState; pub use dlna::commands::DlnaState;
+217 -88
View File
@@ -43,7 +43,8 @@ fn limit_quality_for_device(quality: Quality, max_sample_rate: Option<u32>) -> Q
Quality::UltraHiRes | Quality::HiRes => { Quality::UltraHiRes | Quality::HiRes => {
log::info!( log::info!(
"[Quality Limit] Device max {}Hz, limiting {} to Lossless (44.1kHz)", "[Quality Limit] Device max {}Hz, limiting {} to Lossless (44.1kHz)",
max_rate, quality.label() max_rate,
quality.label()
); );
Quality::Lossless Quality::Lossless
} }
@@ -139,7 +140,11 @@ pub async fn play_track(
if let Some(file_path) = cached_path { if let Some(file_path) = cached_path {
let path = std::path::Path::new(&file_path); let path = std::path::Path::new(&file_path);
if path.exists() { if path.exists() {
log::info!("[CACHE HIT] Track {} from OFFLINE cache: {:?}", track_id, path); log::info!(
"[CACHE HIT] Track {} from OFFLINE cache: {:?}",
track_id,
path
);
// Read file and play // Read file and play
let audio_data = std::fs::read(path) let audio_data = std::fs::read(path)
@@ -149,8 +154,15 @@ pub async fn play_track(
// Check if prefetch should be skipped (streaming_only mode) // Check if prefetch should be skipped (streaming_only mode)
let skip_prefetch = { let skip_prefetch = {
let guard = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = audio_settings
guard.as_ref().and_then(|s| s.get_settings().ok()).map(|s| s.streaming_only).unwrap_or(false) .store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.streaming_only)
.unwrap_or(false)
}; };
// Prefetch next track in background // Prefetch next track in background
@@ -171,13 +183,24 @@ pub async fn play_track(
// Check if track is in memory cache (L1) // Check if track is in memory cache (L1)
if let Some(cached) = cache.get(track_id) { if let Some(cached) = cache.get(track_id) {
log::info!("[CACHE HIT] Track {} from MEMORY cache ({} bytes) - instant playback", track_id, cached.size_bytes); log::info!(
"[CACHE HIT] Track {} from MEMORY cache ({} bytes) - instant playback",
track_id,
cached.size_bytes
);
state.player.play_data(cached.data, track_id)?; state.player.play_data(cached.data, track_id)?;
// Check if prefetch should be skipped (streaming_only mode) // Check if prefetch should be skipped (streaming_only mode)
let skip_prefetch = { let skip_prefetch = {
let guard = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = audio_settings
guard.as_ref().and_then(|s| s.get_settings().ok()).map(|s| s.streaming_only).unwrap_or(false) .store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.streaming_only)
.unwrap_or(false)
}; };
// Prefetch next track in background // Prefetch next track in background
@@ -195,7 +218,11 @@ pub async fn play_track(
// Check if track is in playback cache (L2 - disk) // Check if track is in playback cache (L2 - disk)
if let Some(playback_cache) = cache.get_playback_cache() { if let Some(playback_cache) = cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) { if let Some(audio_data) = playback_cache.get(track_id) {
log::info!("[CACHE HIT] Track {} from DISK cache ({} bytes) - instant playback", track_id, audio_data.len()); log::info!(
"[CACHE HIT] Track {} from DISK cache ({} bytes) - instant playback",
track_id,
audio_data.len()
);
// Promote back to memory cache // Promote back to memory cache
cache.insert(track_id, audio_data.clone()); cache.insert(track_id, audio_data.clone());
@@ -204,8 +231,15 @@ pub async fn play_track(
// Check if prefetch should be skipped (streaming_only mode) // Check if prefetch should be skipped (streaming_only mode)
let skip_prefetch = { let skip_prefetch = {
let guard = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = audio_settings
guard.as_ref().and_then(|s| s.get_settings().ok()).map(|s| s.streaming_only).unwrap_or(false) .store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.get_settings().ok())
.map(|s| s.streaming_only)
.unwrap_or(false)
}; };
// Prefetch next track in background // Prefetch next track in background
@@ -222,13 +256,23 @@ pub async fn play_track(
} }
// Not in any cache - check if streaming is enabled // Not in any cache - check if streaming is enabled
log::info!("Track {} not in any cache, fetching from network...", track_id); log::info!(
"Track {} not in any cache, fetching from network...",
track_id
);
// Check streaming settings // Check streaming settings
let (stream_first_enabled, buffer_seconds, streaming_only) = { let (stream_first_enabled, buffer_seconds, streaming_only) = {
let guard = audio_settings.store.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = audio_settings
.store
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
match guard.as_ref().and_then(|s| s.get_settings().ok()) { match guard.as_ref().and_then(|s| s.get_settings().ok()) {
Some(settings) => (settings.stream_first_track, settings.stream_buffer_seconds, settings.streaming_only), Some(settings) => (
settings.stream_first_track,
settings.stream_buffer_seconds,
settings.streaming_only,
),
None => { None => {
log::warn!("Failed to get audio settings, using defaults"); log::warn!("Failed to get audio settings, using defaults");
(false, 3, false) (false, 3, false)
@@ -238,7 +282,9 @@ pub async fn play_track(
log::info!( log::info!(
"[Playback Settings] stream_first: {}, streaming_only: {}, buffer: {}s", "[Playback Settings] stream_first: {}, streaming_only: {}, buffer: {}s",
stream_first_enabled, streaming_only, buffer_seconds stream_first_enabled,
streaming_only,
buffer_seconds
); );
let client = state.client.read().await; let client = state.client.read().await;
@@ -253,7 +299,11 @@ pub async fn play_track(
if stream_first_enabled { if stream_first_enabled {
// Use streaming playback - start playing before full download // Use streaming playback - start playing before full download
log::info!("[STREAMING] Track {} - streaming from network (cache_after: {})", track_id, !streaming_only); log::info!(
"[STREAMING] Track {} - streaming from network (cache_after: {})",
track_id,
!streaming_only
);
// Get content length, audio info, and measured speed via HEAD request // Get content length, audio info, and measured speed via HEAD request
let stream_info = get_stream_info(&stream_url.url).await?; let stream_info = get_stream_info(&stream_url.url).await?;
@@ -288,14 +338,29 @@ pub async fn play_track(
let content_len = stream_info.content_length; let content_len = stream_info.content_length;
let skip_cache = streaming_only; let skip_cache = streaming_only;
tokio::spawn(async move { tokio::spawn(async move {
match download_and_stream(&url, buffer_writer, track_id, cache_clone, content_len, skip_cache).await { match download_and_stream(
&url,
buffer_writer,
track_id,
cache_clone,
content_len,
skip_cache,
)
.await
{
Ok(()) => { Ok(()) => {
if skip_cache { if skip_cache {
log::info!("[STREAMING COMPLETE] Track {} - NOT cached (streaming_only mode)", track_id); log::info!(
"[STREAMING COMPLETE] Track {} - NOT cached (streaming_only mode)",
track_id
);
} else { } else {
log::info!("[STREAMING COMPLETE] Track {} - cached for instant replay", track_id); log::info!(
"[STREAMING COMPLETE] Track {} - cached for instant replay",
track_id
);
} }
}, }
Err(e) => log::error!("[STREAMING ERROR] Track {}: {}", track_id, e), Err(e) => log::error!("[STREAMING ERROR] Track {}: {}", track_id, e),
} }
}); });
@@ -312,11 +377,17 @@ pub async fn play_track(
streaming_only, streaming_only,
); );
return Ok(PlayTrackResult { format_id: Some(actual_format_id) }); return Ok(PlayTrackResult {
format_id: Some(actual_format_id),
});
} }
// Standard download path (streaming disabled) // Standard download path (streaming disabled)
log::info!("[DOWNLOAD] Track {} - full download before playback (cache_after: {})", track_id, !streaming_only); log::info!(
"[DOWNLOAD] Track {} - full download before playback (cache_after: {})",
track_id,
!streaming_only
);
// Download the audio // Download the audio
let audio_data = download_audio(&stream_url.url).await?; let audio_data = download_audio(&stream_url.url).await?;
@@ -327,7 +398,10 @@ pub async fn play_track(
cache.insert(track_id, audio_data.clone()); cache.insert(track_id, audio_data.clone());
log::info!("[CACHED] Track {} stored in memory cache", track_id); log::info!("[CACHED] Track {} stored in memory cache", track_id);
} else { } else {
log::info!("[NOT CACHED] Track {} - streaming_only mode active", track_id); log::info!(
"[NOT CACHED] Track {} - streaming_only mode active",
track_id
);
} }
// Play it // Play it
@@ -347,7 +421,9 @@ pub async fn play_track(
streaming_only, streaming_only,
); );
Ok(PlayTrackResult { format_id: Some(stream_url.format_id) }) Ok(PlayTrackResult {
format_id: Some(stream_url.format_id),
})
} }
/// Prefetch a track into the in-memory cache without starting playback /// Prefetch a track into the in-memory cache without starting playback
@@ -390,7 +466,11 @@ pub async fn prefetch_track(
log::info!( log::info!(
"Command: prefetch_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})", "Command: prefetch_track {} (quality_str={:?}, parsed={:?}, final={:?}, format_id={})",
track_id, quality, preferred_quality, final_quality, final_quality.id() track_id,
quality,
preferred_quality,
final_quality,
final_quality.id()
); );
let cache = state.audio_cache.clone(); let cache = state.audio_cache.clone();
@@ -519,14 +599,19 @@ async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
Err(e) => { Err(e) => {
last_error = e.to_string(); last_error = e.to_string();
if attempt < 2 { if attempt < 2 {
log::warn!("HEAD request attempt {} failed: {}, retrying...", attempt + 1, e); log::warn!(
"HEAD request attempt {} failed: {}, retrying...",
attempt + 1,
e
);
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;
} }
} }
} }
} }
let head_response = head_response.ok_or_else(|| format!("Failed HEAD request after 3 attempts: {}", last_error))?; let head_response = head_response
.ok_or_else(|| format!("Failed HEAD request after 3 attempts: {}", last_error))?;
if !head_response.status().is_success() { if !head_response.status().is_success() {
return Err(format!("HEAD request failed: {}", head_response.status())); return Err(format!("HEAD request failed: {}", head_response.status()));
@@ -542,7 +627,7 @@ async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
// Download first ~64KB to probe audio format and measure speed // Download first ~64KB to probe audio format and measure speed
// This is enough for FLAC/M4A headers // This is enough for FLAC/M4A headers
let start_time = Instant::now(); let start_time = Instant::now();
let range_response = client let range_response = client
.get(url) .get(url)
.header("User-Agent", "Mozilla/5.0") .header("User-Agent", "Mozilla/5.0")
@@ -551,7 +636,9 @@ async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
.await .await
.map_err(|e| format!("Failed range request: {}", e))?; .map_err(|e| format!("Failed range request: {}", e))?;
if !range_response.status().is_success() && range_response.status() != reqwest::StatusCode::PARTIAL_CONTENT { if !range_response.status().is_success()
&& range_response.status() != reqwest::StatusCode::PARTIAL_CONTENT
{
return Err(format!("Range request failed: {}", range_response.status())); return Err(format!("Range request failed: {}", range_response.status()));
} }
@@ -589,8 +676,8 @@ async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
/// Extract sample rate, channels, and bit depth from audio file header /// Extract sample rate, channels, and bit depth from audio file header
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), String> { fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), String> {
use std::io::{BufReader, Cursor};
use rodio::Decoder; use rodio::Decoder;
use std::io::{BufReader, Cursor};
// Check if this is FLAC // Check if this is FLAC
if data.len() >= 4 && &data[0..4] == b"fLaC" { if data.len() >= 4 && &data[0..4] == b"fLaC" {
@@ -599,7 +686,8 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), Stri
if data.len() >= 26 { if data.len() >= 26 {
// STREAMINFO starts at byte 8 // STREAMINFO starts at byte 8
// Bytes 18-20: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits) // Bytes 18-20: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
let sr_high = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4); let sr_high =
((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
let sample_rate = sr_high; let sample_rate = sr_high;
let channels = ((data[20] >> 1) & 0x07) + 1; let channels = ((data[20] >> 1) & 0x07) + 1;
// Bits per sample: 5 bits starting at bit 4 of byte 20 // Bits per sample: 5 bits starting at bit 4 of byte 20
@@ -636,12 +724,19 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), Stri
} }
} }
impl symphonia::core::io::MediaSource for ProbeSource { impl symphonia::core::io::MediaSource for ProbeSource {
fn is_seekable(&self) -> bool { true } fn is_seekable(&self) -> bool {
fn byte_len(&self) -> Option<u64> { Some(self.len) } true
}
fn byte_len(&self) -> Option<u64> {
Some(self.len)
}
} }
let len = data.len() as u64; let len = data.len() as u64;
let source = Box::new(ProbeSource { inner: Cursor::new(data.to_vec()), len }); let source = Box::new(ProbeSource {
inner: Cursor::new(data.to_vec()),
len,
});
let mss = MediaSourceStream::new(source, Default::default()); let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new(); let mut hint = Hint::new();
@@ -652,10 +747,16 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), Stri
..Default::default() ..Default::default()
}; };
if let Ok(probed) = get_probe().format(&hint, mss, &format_opts, &MetadataOptions::default()) { if let Ok(probed) =
get_probe().format(&hint, mss, &format_opts, &MetadataOptions::default())
{
if let Some(track) = probed.format.default_track() { if let Some(track) = probed.format.default_track() {
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
let channels = track.codec_params.channels.map(|c| c.count() as u16).unwrap_or(2); let channels = track
.codec_params
.channels
.map(|c| c.count() as u16)
.unwrap_or(2);
let bit_depth = track.codec_params.bits_per_sample.unwrap_or(16); let bit_depth = track.codec_params.bits_per_sample.unwrap_or(16);
return Ok((sample_rate, channels, bit_depth)); return Ok((sample_rate, channels, bit_depth));
} }
@@ -674,7 +775,9 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), Stri
} }
Err(_) => { Err(_) => {
// Default fallback // Default fallback
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo, 16-bit)"); log::warn!(
"Could not determine audio format, using defaults (44100Hz, stereo, 16-bit)"
);
Ok((44100, 2, 16)) Ok((44100, 2, 16))
} }
} }
@@ -690,8 +793,8 @@ async fn download_and_stream(
content_length: u64, content_length: u64,
skip_cache: bool, skip_cache: bool,
) -> Result<(), String> { ) -> Result<(), String> {
use std::time::{Duration, Instant};
use futures_util::StreamExt; use futures_util::StreamExt;
use std::time::{Duration, Instant};
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // Longer timeout for streaming .timeout(Duration::from_secs(300)) // Longer timeout for streaming
@@ -699,8 +802,9 @@ async fn download_and_stream(
.build() .build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?; .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
log::info!("Starting streaming cache for track {} ({:.2} MB total)", log::info!(
track_id, "Starting streaming cache for track {} ({:.2} MB total)",
track_id,
content_length as f64 / (1024.0 * 1024.0) content_length as f64 / (1024.0 * 1024.0)
); );
@@ -740,7 +844,7 @@ async fn download_and_stream(
let elapsed_total = start_time.elapsed().as_secs_f64(); let elapsed_total = start_time.elapsed().as_secs_f64();
let elapsed_interval = now.duration_since(last_log_time).as_secs_f64(); let elapsed_interval = now.duration_since(last_log_time).as_secs_f64();
let bytes_this_interval = bytes_received - last_log_bytes; let bytes_this_interval = bytes_received - last_log_bytes;
// Current speed (MB/s) // Current speed (MB/s)
let current_speed = (bytes_this_interval as f64 / elapsed_interval) / (1024.0 * 1024.0); let current_speed = (bytes_this_interval as f64 / elapsed_interval) / (1024.0 * 1024.0);
// Average speed // Average speed
@@ -777,7 +881,7 @@ async fn download_and_stream(
let total_time = start_time.elapsed(); let total_time = start_time.elapsed();
let avg_speed = (bytes_received as f64 / total_time.as_secs_f64()) / (1024.0 * 1024.0); let avg_speed = (bytes_received as f64 / total_time.as_secs_f64()) / (1024.0 * 1024.0);
log::info!( log::info!(
"Streaming cache complete: {:.2} MB in {:.1}s ({:.2} MB/s avg)", "Streaming cache complete: {:.2} MB in {:.1}s ({:.2} MB/s avg)",
bytes_received as f64 / (1024.0 * 1024.0), bytes_received as f64 / (1024.0 * 1024.0),
@@ -850,7 +954,11 @@ fn spawn_prefetch(
// Skip local tracks - they don't need prefetching from Qobuz // Skip local tracks - they don't need prefetching from Qobuz
if track.is_local { if track.is_local {
log::debug!("Skipping prefetch for local track: {} - {}", track_id, track_title); log::debug!(
"Skipping prefetch for local track: {} - {}",
track_id,
track_title
);
continue; continue;
} }
@@ -983,7 +1091,11 @@ pub async fn play_next_gapless(
// Check memory cache (L1) // Check memory cache (L1)
let cache = state.audio_cache.clone(); let cache = state.audio_cache.clone();
if let Some(cached) = cache.get(track_id) { if let Some(cached) = cache.get(track_id) {
log::info!("[GAPLESS] Track {} from MEMORY cache ({} bytes)", track_id, cached.size_bytes); log::info!(
"[GAPLESS] Track {} from MEMORY cache ({} bytes)",
track_id,
cached.size_bytes
);
state.player.play_next(cached.data, track_id)?; state.player.play_next(cached.data, track_id)?;
return Ok(true); return Ok(true);
} }
@@ -991,13 +1103,20 @@ pub async fn play_next_gapless(
// Check playback cache (L2 - disk) // Check playback cache (L2 - disk)
if let Some(playback_cache) = cache.get_playback_cache() { if let Some(playback_cache) = cache.get_playback_cache() {
if let Some(audio_data) = playback_cache.get(track_id) { if let Some(audio_data) = playback_cache.get(track_id) {
log::info!("[GAPLESS] Track {} from DISK cache ({} bytes)", track_id, audio_data.len()); log::info!(
"[GAPLESS] Track {} from DISK cache ({} bytes)",
track_id,
audio_data.len()
);
state.player.play_next(audio_data, track_id)?; state.player.play_next(audio_data, track_id)?;
return Ok(true); return Ok(true);
} }
} }
log::info!("[GAPLESS] Track {} not in any cache, gapless not possible", track_id); log::info!(
"[GAPLESS] Track {} not in any cache, gapless not possible",
track_id
);
Ok(false) Ok(false)
} }
@@ -1043,10 +1162,9 @@ pub fn seek(position: u64, state: State<'_, AppState>) -> Result<(), String> {
// Update MPRIS with new position // Update MPRIS with new position
let playback_state = state.player.get_state().unwrap_or_default(); let playback_state = state.player.get_state().unwrap_or_default();
state.media_controls.set_playback_with_progress( state
playback_state.is_playing, .media_controls
position, .set_playback_with_progress(playback_state.is_playing, position);
);
result result
} }
@@ -1057,10 +1175,9 @@ pub fn get_playback_state(state: State<'_, AppState>) -> Result<PlaybackState, S
let playback_state = state.player.get_state()?; let playback_state = state.player.get_state()?;
// Update MPRIS with current progress (called every ~500ms from frontend) // Update MPRIS with current progress (called every ~500ms from frontend)
state.media_controls.set_playback_with_progress( state
playback_state.is_playing, .media_controls
playback_state.position, .set_playback_with_progress(playback_state.is_playing, playback_state.position);
);
Ok(playback_state) Ok(playback_state)
} }
@@ -1068,10 +1185,10 @@ pub fn get_playback_state(state: State<'_, AppState>) -> Result<PlaybackState, S
/// Audio device information /// Audio device information
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct AudioDevice { pub struct AudioDevice {
pub name: String, // Technical name (internal use) pub name: String, // Technical name (internal use)
pub display_name: String, // Friendly name for UI pub display_name: String, // Friendly name for UI
pub is_default: bool, pub is_default: bool,
pub device_type: Option<String>, // "USB", "PCI", "HDMI", etc. pub device_type: Option<String>, // "USB", "PCI", "HDMI", etc.
} }
/// Get friendly device names from PipeWire/PulseAudio /// Get friendly device names from PipeWire/PulseAudio
@@ -1105,7 +1222,10 @@ fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, Strin
} }
} }
log::info!("Found {} device descriptions from PipeWire/PulseAudio", map.len()); log::info!(
"Found {} device descriptions from PipeWire/PulseAudio",
map.len()
);
map map
} }
@@ -1123,9 +1243,7 @@ pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
let host = rodio::cpal::default_host(); let host = rodio::cpal::default_host();
let default_device_name = host let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
.default_output_device()
.and_then(|d| d.name().ok());
// Get friendly names from PipeWire/PulseAudio // Get friendly names from PipeWire/PulseAudio
let friendly_names = get_pipewire_device_descriptions(); let friendly_names = get_pipewire_device_descriptions();
@@ -1135,20 +1253,20 @@ pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
.map_err(|e| format!("Failed to enumerate devices: {}", e))? .map_err(|e| format!("Failed to enumerate devices: {}", e))?
.filter_map(|device| { .filter_map(|device| {
device.name().ok().map(|name| { device.name().ok().map(|name| {
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false); let is_default = default_device_name
.as_ref()
.map(|d| d == &name)
.unwrap_or(false);
// Try to get friendly name from PipeWire/PulseAudio // Try to get friendly name from PipeWire/PulseAudio
let display_name = friendly_names let display_name = friendly_names.get(&name).cloned().unwrap_or_else(|| {
.get(&name) // Fallback: clean up technical name slightly
.cloned() name.replace("alsa_output.", "")
.unwrap_or_else(|| { .replace("_", " ")
// Fallback: clean up technical name slightly .replace("pro-output", "")
name.replace("alsa_output.", "") .trim()
.replace("_", " ") .to_string()
.replace("pro-output", "") });
.trim()
.to_string()
});
// Detect device type from technical name // Detect device type from technical name
let device_type = if name.contains("usb") || name.contains("USB") { let device_type = if name.contains("usb") || name.contains("USB") {
@@ -1221,7 +1339,10 @@ pub fn reinit_audio_device(
if let Ok(guard) = audio_settings_state.store.lock() { if let Ok(guard) = audio_settings_state.store.lock() {
if let Some(store) = guard.as_ref() { if let Some(store) = guard.as_ref() {
if let Ok(fresh_settings) = store.get_settings() { if let Ok(fresh_settings) = store.get_settings() {
log::info!("Reloading audio settings before reinit (backend_type: {:?})", fresh_settings.backend_type); log::info!(
"Reloading audio settings before reinit (backend_type: {:?})",
fresh_settings.backend_type
);
let _ = state.player.reload_settings(fresh_settings); let _ = state.player.reload_settings(fresh_settings);
} }
} }
@@ -1255,9 +1376,7 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
let host = rodio::cpal::default_host(); let host = rodio::cpal::default_host();
// Get default device name from CPAL // Get default device name from CPAL
let default_device_name = host let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
.default_output_device()
.and_then(|d| d.name().ok());
log::info!("CPAL default device: {:?}", default_device_name); log::info!("CPAL default device: {:?}", default_device_name);
@@ -1269,10 +1388,14 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
.filter_map(|(idx, device)| { .filter_map(|(idx, device)| {
match device.name() { match device.name() {
Ok(name) => { Ok(name) => {
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false); let is_default = default_device_name
.as_ref()
.map(|d| d == &name)
.unwrap_or(false);
// Get detailed device info for logging // Get detailed device info for logging
let configs_info = device.supported_output_configs() let configs_info = device
.supported_output_configs()
.ok() .ok()
.map(|configs| { .map(|configs| {
let config_strs: Vec<String> = configs let config_strs: Vec<String> = configs
@@ -1283,8 +1406,13 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
}) })
.unwrap_or_else(|| "no configs".to_string()); .unwrap_or_else(|| "no configs".to_string());
log::info!(" [{}] Device: '{}' (default: {}) - Configs: {}", log::info!(
idx, name, is_default, configs_info); " [{}] Device: '{}' (default: {}) - Configs: {}",
idx,
name,
is_default,
configs_info
);
// CRITICAL: Use CPAL device name as both name and description // CRITICAL: Use CPAL device name as both name and description
// This ensures the name we save is the exact name CPAL can find later // This ensures the name we save is the exact name CPAL can find later
@@ -1319,16 +1447,17 @@ pub fn get_pipewire_sinks() -> Result<Vec<PipewireSink>, String> {
let host = rodio::cpal::default_host(); let host = rodio::cpal::default_host();
let default_device_name = host let default_device_name = host.default_output_device().and_then(|d| d.name().ok());
.default_output_device()
.and_then(|d| d.name().ok());
let sinks: Vec<PipewireSink> = host let sinks: Vec<PipewireSink> = host
.output_devices() .output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))? .map_err(|e| format!("Failed to enumerate devices: {}", e))?
.filter_map(|device| { .filter_map(|device| {
device.name().ok().map(|name| { device.name().ok().map(|name| {
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false); let is_default = default_device_name
.as_ref()
.map(|d| d == &name)
.unwrap_or(false);
PipewireSink { PipewireSink {
name: name.clone(), name: name.clone(),
description: name, // On Mac/Windows, name is usually descriptive enough description: name, // On Mac/Windows, name is usually descriptive enough
@@ -1378,17 +1507,17 @@ pub fn set_pipewire_default_sink(_sink_name: String) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn debug_get_cpal_devices() -> Result<Vec<String>, String> { pub fn debug_get_cpal_devices() -> Result<Vec<String>, String> {
use rodio::cpal::traits::{DeviceTrait, HostTrait}; use rodio::cpal::traits::{DeviceTrait, HostTrait};
log::info!("Command: debug_get_cpal_devices"); log::info!("Command: debug_get_cpal_devices");
let host = rodio::cpal::default_host(); let host = rodio::cpal::default_host();
let devices: Vec<String> = host let devices: Vec<String> = host
.output_devices() .output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))? .map_err(|e| format!("Failed to enumerate devices: {}", e))?
.filter_map(|device| device.name().ok()) .filter_map(|device| device.name().ok())
.collect(); .collect();
log::info!("CPAL devices: {:?}", devices); log::info!("CPAL devices: {:?}", devices);
Ok(devices) Ok(devices)
} }
+162 -49
View File
@@ -2,7 +2,11 @@
use tauri::State; use tauri::State;
use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData, DiscoverResponse, DiscoverPlaylistsResponse, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, ReleasesGridResponse, SearchResultsPage, Track, TracksContainer}; use crate::api::{
endpoints, endpoints::paths, Album, Artist, ArtistAlbums, DiscoverAlbum, DiscoverData,
DiscoverPlaylistsResponse, DiscoverResponse, LabelDetail, PageArtistResponse, Playlist,
PlaylistTag, ReleasesGridResponse, SearchResultsPage, Track, TracksContainer,
};
use crate::api_cache::ApiCacheState; use crate::api_cache::ApiCacheState;
use crate::artist_blacklist::BlacklistState; use crate::artist_blacklist::BlacklistState;
use crate::AppState; use crate::AppState;
@@ -39,18 +43,28 @@ pub async fn search_albums(
let mut results = { let mut results = {
let client = state.client.read().await; let client = state.client.read().await;
client client
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .search_albums(
&query,
limit.unwrap_or(20),
offset.unwrap_or(0),
search_type.as_deref(),
)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
// Filter out albums from blacklisted artists // Filter out albums from blacklisted artists
let original_count = results.items.len(); let original_count = results.items.len();
results.items.retain(|album| !blacklist_state.is_blacklisted(album.artist.id)); results
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_count = original_count - results.items.len(); let filtered_count = original_count - results.items.len();
if filtered_count > 0 { if filtered_count > 0 {
log::debug!("[Blacklist] Filtered {} albums from search results", filtered_count); log::debug!(
"[Blacklist] Filtered {} albums from search results",
filtered_count
);
results.total = results.total.saturating_sub(filtered_count as u32); results.total = results.total.saturating_sub(filtered_count as u32);
} }
@@ -69,7 +83,12 @@ pub async fn search_tracks(
let mut results = { let mut results = {
let client = state.client.read().await; let client = state.client.read().await;
client client
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .search_tracks(
&query,
limit.unwrap_or(20),
offset.unwrap_or(0),
search_type.as_deref(),
)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
@@ -86,7 +105,10 @@ pub async fn search_tracks(
let filtered_count = original_count - results.items.len(); let filtered_count = original_count - results.items.len();
if filtered_count > 0 { if filtered_count > 0 {
log::debug!("[Blacklist] Filtered {} tracks from search results", filtered_count); log::debug!(
"[Blacklist] Filtered {} tracks from search results",
filtered_count
);
// Adjust total to reflect filtered count (approximate) // Adjust total to reflect filtered count (approximate)
results.total = results.total.saturating_sub(filtered_count as u32); results.total = results.total.saturating_sub(filtered_count as u32);
} }
@@ -106,18 +128,28 @@ pub async fn search_artists(
let mut results = { let mut results = {
let client = state.client.read().await; let client = state.client.read().await;
client client
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .search_artists(
&query,
limit.unwrap_or(20),
offset.unwrap_or(0),
search_type.as_deref(),
)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
// Filter out blacklisted artists // Filter out blacklisted artists
let original_count = results.items.len(); let original_count = results.items.len();
results.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); results
.items
.retain(|artist| !blacklist_state.is_blacklisted(artist.id));
let filtered_count = original_count - results.items.len(); let filtered_count = original_count - results.items.len();
if filtered_count > 0 { if filtered_count > 0 {
log::debug!("[Blacklist] Filtered {} artists from search results", filtered_count); log::debug!(
"[Blacklist] Filtered {} artists from search results",
filtered_count
);
results.total = results.total.saturating_sub(filtered_count as u32); results.total = results.total.saturating_sub(filtered_count as u32);
} }
@@ -141,13 +173,15 @@ pub async fn search_all(
client client
.get_http() .get_http()
.get(&url) .get(&url)
.header("X-App-Id", client.app_id().await.map_err(|e| e.to_string())?) .header(
.header("X-User-Auth-Token", client.auth_token().await.map_err(|e| e.to_string())?) "X-App-Id",
.query(&[ client.app_id().await.map_err(|e| e.to_string())?,
("query", query.as_str()), )
("limit", "30"), .header(
("offset", "0"), "X-User-Auth-Token",
]) client.auth_token().await.map_err(|e| e.to_string())?,
)
.query(&[("query", query.as_str()), ("limit", "30"), ("offset", "0")])
.send() .send()
.await .await
.map_err(|e| format!("Request failed: {}", e))? .map_err(|e| format!("Request failed: {}", e))?
@@ -160,25 +194,45 @@ pub async fn search_all(
let albums: SearchResultsPage<Album> = response let albums: SearchResultsPage<Album> = response
.get("albums") .get("albums")
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); .unwrap_or_else(|| SearchResultsPage {
items: vec![],
total: 0,
offset: 0,
limit: 30,
});
// Parse tracks // Parse tracks
let mut tracks: SearchResultsPage<Track> = response let mut tracks: SearchResultsPage<Track> = response
.get("tracks") .get("tracks")
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); .unwrap_or_else(|| SearchResultsPage {
items: vec![],
total: 0,
offset: 0,
limit: 30,
});
// Parse artists // Parse artists
let mut artists: SearchResultsPage<Artist> = response let mut artists: SearchResultsPage<Artist> = response
.get("artists") .get("artists")
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); .unwrap_or_else(|| SearchResultsPage {
items: vec![],
total: 0,
offset: 0,
limit: 30,
});
// Parse playlists // Parse playlists
let playlists: SearchResultsPage<Playlist> = response let playlists: SearchResultsPage<Playlist> = response
.get("playlists") .get("playlists")
.and_then(|v| serde_json::from_value(v.clone()).ok()) .and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); .unwrap_or_else(|| SearchResultsPage {
items: vec![],
total: 0,
offset: 0,
limit: 30,
});
// Parse most_popular - find the first non-blacklisted item // Parse most_popular - find the first non-blacklisted item
let most_popular: Option<MostPopularItem> = response let most_popular: Option<MostPopularItem> = response
@@ -245,7 +299,9 @@ pub async fn search_all(
// Filter albums from blacklisted artists // Filter albums from blacklisted artists
let original_album_count = albums.items.len(); let original_album_count = albums.items.len();
let mut albums = albums; let mut albums = albums;
albums.items.retain(|album| !blacklist_state.is_blacklisted(album.artist.id)); albums
.items
.retain(|album| !blacklist_state.is_blacklisted(album.artist.id));
let filtered_albums = original_album_count - albums.items.len(); let filtered_albums = original_album_count - albums.items.len();
if filtered_albums > 0 { if filtered_albums > 0 {
albums.total = albums.total.saturating_sub(filtered_albums as u32); albums.total = albums.total.saturating_sub(filtered_albums as u32);
@@ -267,7 +323,9 @@ pub async fn search_all(
// Filter blacklisted artists // Filter blacklisted artists
let original_artist_count = artists.items.len(); let original_artist_count = artists.items.len();
artists.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); artists
.items
.retain(|artist| !blacklist_state.is_blacklisted(artist.id));
let filtered_artists = original_artist_count - artists.items.len(); let filtered_artists = original_artist_count - artists.items.len();
if filtered_artists > 0 { if filtered_artists > 0 {
artists.total = artists.total.saturating_sub(filtered_artists as u32); artists.total = artists.total.saturating_sub(filtered_artists as u32);
@@ -302,7 +360,9 @@ pub async fn get_album(
// Check cache first // Check cache first
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
match cache.get_album(&album_id, None) { match cache.get_album(&album_id, None) {
Ok(Some(cached_data)) => { Ok(Some(cached_data)) => {
log::debug!("Cache hit for album {}", album_id); log::debug!("Cache hit for album {}", album_id);
@@ -320,13 +380,18 @@ pub async fn get_album(
log::debug!("Cache miss for album {}, fetching from API", album_id); log::debug!("Cache miss for album {}, fetching from API", album_id);
let album = { let album = {
let client = state.client.read().await; let client = state.client.read().await;
client.get_album(&album_id).await.map_err(|e| e.to_string())? client
.get_album(&album_id)
.await
.map_err(|e| e.to_string())?
}; };
// Cache the result // Cache the result
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let json = serde_json::to_string(&album) let json = serde_json::to_string(&album)
.map_err(|e| format!("Failed to serialize album: {}", e))?; .map_err(|e| format!("Failed to serialize album: {}", e))?;
let _ = cache.set_album(&album_id, &json); let _ = cache.set_album(&album_id, &json);
@@ -347,7 +412,12 @@ pub async fn get_featured_albums(
let result = { let result = {
let client = state.client.read().await; let client = state.client.read().await;
client client
.get_featured_albums(&featured_type, limit.unwrap_or(12), offset.unwrap_or(0), genre_id) .get_featured_albums(
&featured_type,
limit.unwrap_or(12),
offset.unwrap_or(0),
genre_id,
)
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
@@ -363,7 +433,9 @@ pub async fn get_track(
// Check cache first // Check cache first
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
match cache.get_track(track_id, None) { match cache.get_track(track_id, None) {
Ok(Some(cached_data)) => { Ok(Some(cached_data)) => {
log::debug!("Cache hit for track {}", track_id); log::debug!("Cache hit for track {}", track_id);
@@ -381,13 +453,18 @@ pub async fn get_track(
log::debug!("Cache miss for track {}, fetching from API", track_id); log::debug!("Cache miss for track {}, fetching from API", track_id);
let track = { let track = {
let client = state.client.read().await; let client = state.client.read().await;
client.get_track(track_id).await.map_err(|e| e.to_string())? client
.get_track(track_id)
.await
.map_err(|e| e.to_string())?
}; };
// Cache the result // Cache the result
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let json = serde_json::to_string(&track) let json = serde_json::to_string(&track)
.map_err(|e| format!("Failed to serialize track: {}", e))?; .map_err(|e| format!("Failed to serialize track: {}", e))?;
let _ = cache.set_track(track_id, &json); let _ = cache.set_track(track_id, &json);
@@ -414,7 +491,9 @@ pub async fn get_artist(
// Check cache first // Check cache first
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
if let Some(cached_data) = cache.get_artist(artist_id, &locale, None)? { if let Some(cached_data) = cache.get_artist(artist_id, &locale, None)? {
log::debug!("Cache hit for artist {} (locale: {})", artist_id, locale); log::debug!("Cache hit for artist {} (locale: {})", artist_id, locale);
return serde_json::from_str(&cached_data) return serde_json::from_str(&cached_data)
@@ -423,7 +502,11 @@ pub async fn get_artist(
} }
// Cache miss - fetch from API // Cache miss - fetch from API
log::debug!("Cache miss for artist {} (locale: {}), fetching from API", artist_id, locale); log::debug!(
"Cache miss for artist {} (locale: {}), fetching from API",
artist_id,
locale
);
let client = state.client.read().await; let client = state.client.read().await;
let artist = client let artist = client
.get_artist(artist_id, true) .get_artist(artist_id, true)
@@ -433,7 +516,9 @@ pub async fn get_artist(
// Cache the result // Cache the result
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let json = serde_json::to_string(&artist) let json = serde_json::to_string(&artist)
.map_err(|e| format!("Failed to serialize artist: {}", e))?; .map_err(|e| format!("Failed to serialize artist: {}", e))?;
cache.set_artist(artist_id, &locale, &json)?; cache.set_artist(artist_id, &locale, &json)?;
@@ -458,16 +543,26 @@ pub async fn get_artist_basic(
// Check cache first (reuse same cache - basic response works for both) // Check cache first (reuse same cache - basic response works for both)
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
if let Some(cached_data) = cache.get_artist(artist_id, &locale, None)? { if let Some(cached_data) = cache.get_artist(artist_id, &locale, None)? {
log::debug!("Cache hit for artist_basic {} (locale: {})", artist_id, locale); log::debug!(
"Cache hit for artist_basic {} (locale: {})",
artist_id,
locale
);
return serde_json::from_str(&cached_data) return serde_json::from_str(&cached_data)
.map_err(|e| format!("Failed to parse cached artist: {}", e)); .map_err(|e| format!("Failed to parse cached artist: {}", e));
} }
} }
// Cache miss - fetch from API (without albums - much faster) // Cache miss - fetch from API (without albums - much faster)
log::debug!("Cache miss for artist_basic {} (locale: {}), fetching from API", artist_id, locale); log::debug!(
"Cache miss for artist_basic {} (locale: {}), fetching from API",
artist_id,
locale
);
let artist = { let artist = {
let client = state.client.read().await; let client = state.client.read().await;
client client
@@ -479,7 +574,9 @@ pub async fn get_artist_basic(
// Cache the result // Cache the result
{ {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let json = serde_json::to_string(&artist) let json = serde_json::to_string(&artist)
.map_err(|e| format!("Failed to serialize artist: {}", e))?; .map_err(|e| format!("Failed to serialize artist: {}", e))?;
cache.set_artist(artist_id, &locale, &json)?; cache.set_artist(artist_id, &locale, &json)?;
@@ -577,7 +674,9 @@ pub async fn get_similar_artists(
// Filter out blacklisted artists // Filter out blacklisted artists
let original_count = results.items.len(); let original_count = results.items.len();
results.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); results
.items
.retain(|artist| !blacklist_state.is_blacklisted(artist.id));
let filtered_count = original_count - results.items.len(); let filtered_count = original_count - results.items.len();
if filtered_count > 0 { if filtered_count > 0 {
@@ -638,7 +737,10 @@ pub async fn get_genres(
} }
// Cache miss - fetch from API // Cache miss - fetch from API
log::debug!("Cache miss for genres parent_id={:?}, fetching from API", parent_id); log::debug!(
"Cache miss for genres parent_id={:?}, fetching from API",
parent_id
);
let client = state.client.read().await; let client = state.client.read().await;
let genres = client let genres = client
.get_genres(parent_id) .get_genres(parent_id)
@@ -685,7 +787,13 @@ pub async fn get_discover_playlists(
offset: Option<u32>, offset: Option<u32>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<DiscoverPlaylistsResponse, String> { ) -> Result<DiscoverPlaylistsResponse, String> {
log::debug!("Command: get_discover_playlists tag={:?} genre_ids={:?} limit={:?} offset={:?}", tag, genre_ids, limit, offset); log::debug!(
"Command: get_discover_playlists tag={:?} genre_ids={:?} limit={:?} offset={:?}",
tag,
genre_ids,
limit,
offset
);
let client = state.client.read().await; let client = state.client.read().await;
client client
@@ -696,14 +804,9 @@ pub async fn get_discover_playlists(
/// Get playlist tags (localized names from /playlist/getTags) /// Get playlist tags (localized names from /playlist/getTags)
#[tauri::command] #[tauri::command]
pub async fn get_playlist_tags( pub async fn get_playlist_tags(state: State<'_, AppState>) -> Result<Vec<PlaylistTag>, String> {
state: State<'_, AppState>,
) -> Result<Vec<PlaylistTag>, String> {
let client = state.client.read().await; let client = state.client.read().await;
client client.get_playlist_tags().await.map_err(|e| e.to_string())
.get_playlist_tags()
.await
.map_err(|e| e.to_string())
} }
/// Get discover albums from a browse endpoint (newReleases, idealDiscography, mostStreamed) /// Get discover albums from a browse endpoint (newReleases, idealDiscography, mostStreamed)
@@ -727,12 +830,19 @@ pub async fn get_discover_albums(
log::debug!( log::debug!(
"Command: get_discover_albums type={} offset={:?} limit={:?}", "Command: get_discover_albums type={} offset={:?} limit={:?}",
endpoint_type, offset, limit endpoint_type,
offset,
limit
); );
let client = state.client.read().await; let client = state.client.read().await;
client client
.get_discover_albums(endpoint, genre_ids, offset.unwrap_or(0), limit.unwrap_or(50)) .get_discover_albums(
endpoint,
genre_ids,
offset.unwrap_or(0),
limit.unwrap_or(50),
)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -765,7 +875,10 @@ pub async fn get_releases_grid(
) -> Result<ReleasesGridResponse, String> { ) -> Result<ReleasesGridResponse, String> {
log::debug!( log::debug!(
"Command: get_releases_grid {} type={} limit={:?} offset={:?}", "Command: get_releases_grid {} type={} limit={:?} offset={:?}",
artist_id, release_type, limit, offset artist_id,
release_type,
limit,
offset
); );
let client = state.client.read().await; let client = state.client.read().await;
+2 -7
View File
@@ -48,10 +48,7 @@ pub fn get_last_user_id() -> Option<u64> {
/// ///
/// This is a legacy command - now delegates to session_lifecycle::activate_session(). /// This is a legacy command - now delegates to session_lifecycle::activate_session().
#[tauri::command] #[tauri::command]
pub async fn activate_user_session( pub async fn activate_user_session(app: tauri::AppHandle, user_id: u64) -> Result<(), String> {
app: tauri::AppHandle,
user_id: u64,
) -> Result<(), String> {
log::info!("[Legacy] activate_user_session called - delegating to session_lifecycle"); log::info!("[Legacy] activate_user_session called - delegating to session_lifecycle");
crate::session_lifecycle::activate_session(&app, user_id).await crate::session_lifecycle::activate_session(&app, user_id).await
} }
@@ -61,9 +58,7 @@ pub async fn activate_user_session(
/// Tears down all per-user stores, closing database connections. /// Tears down all per-user stores, closing database connections.
/// This is a legacy command - now delegates to session_lifecycle::deactivate_session(). /// This is a legacy command - now delegates to session_lifecycle::deactivate_session().
#[tauri::command] #[tauri::command]
pub async fn deactivate_user_session( pub async fn deactivate_user_session(app: tauri::AppHandle) -> Result<(), String> {
app: tauri::AppHandle,
) -> Result<(), String> {
log::info!("[Legacy] deactivate_user_session called - delegating to session_lifecycle"); log::info!("[Legacy] deactivate_user_session called - delegating to session_lifecycle");
crate::session_lifecycle::deactivate_session(&app).await crate::session_lifecycle::deactivate_session(&app).await
} }
+2 -5
View File
@@ -399,11 +399,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()),
} }
} }
+36 -21
View File
@@ -1,10 +1,10 @@
use chrono::Utc;
use md5::{Digest, Md5};
use rusqlite::{params, Connection, Result}; use rusqlite::{params, Connection, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use chrono::Utc;
use md5::{Md5, Digest};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FavoritesPreferences { pub struct FavoritesPreferences {
@@ -111,10 +111,7 @@ impl FavoritesPreferencesStore {
return Ok(trimmed.to_string()); return Ok(trimmed.to_string());
} }
let extension = source let extension = source.extension().and_then(|e| e.to_str()).unwrap_or("png");
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png");
let mut hasher = Md5::new(); let mut hasher = Md5::new();
hasher.update(trimmed.as_bytes()); hasher.update(trimmed.as_bytes());
@@ -143,18 +140,24 @@ impl FavoritesPreferencesStore {
let icon_background: Option<String> = row.get(2)?; let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?; let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path let custom_icon_path = custom_icon_path.and_then(|value| {
.and_then(|value| if value.trim().is_empty() { None } else { Some(value) }); if value.trim().is_empty() {
None
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| { } else {
vec![ Some(value)
"tracks".to_string(), }
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
}); });
let tab_order: Vec<String> =
serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![
"tracks".to_string(),
"albums".to_string(),
"artists".to_string(),
"playlists".to_string(),
]
});
Ok(FavoritesPreferences { Ok(FavoritesPreferences {
custom_icon_path, custom_icon_path,
custom_icon_preset, custom_icon_preset,
@@ -191,7 +194,10 @@ impl FavoritesPreferencesStore {
} }
} }
pub fn save_preferences(&self, mut prefs: FavoritesPreferences) -> Result<FavoritesPreferences, String> { pub fn save_preferences(
&self,
mut prefs: FavoritesPreferences,
) -> Result<FavoritesPreferences, String> {
if let Some(path) = prefs.custom_icon_path.clone() { if let Some(path) = prefs.custom_icon_path.clone() {
let resolved = self.normalize_custom_icon_path(path)?; let resolved = self.normalize_custom_icon_path(path)?;
if resolved.is_empty() { if resolved.is_empty() {
@@ -234,14 +240,18 @@ impl FavoritesPreferencesState {
pub fn init_at(&self, base_dir: &Path) -> Result<(), String> { pub fn init_at(&self, base_dir: &Path) -> Result<(), String> {
let new_store = FavoritesPreferencesStore::new_at(base_dir)?; let new_store = FavoritesPreferencesStore::new_at(base_dir)?;
let mut guard = self.store.lock() let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?; .map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = Some(new_store); *guard = Some(new_store);
Ok(()) Ok(())
} }
pub fn teardown(&self) -> Result<(), String> { pub fn teardown(&self) -> Result<(), String> {
let mut guard = self.store.lock() let mut guard = self
.store
.lock()
.map_err(|_| "Failed to lock favorites preferences store".to_string())?; .map_err(|_| "Failed to lock favorites preferences store".to_string())?;
*guard = None; *guard = None;
Ok(()) Ok(())
@@ -308,8 +318,13 @@ pub fn load_preferences(conn: &Connection) -> Result<FavoritesPreferences> {
let icon_background: Option<String> = row.get(2)?; let icon_background: Option<String> = row.get(2)?;
let tab_order_str: String = row.get(3)?; let tab_order_str: String = row.get(3)?;
let custom_icon_path = custom_icon_path let custom_icon_path = custom_icon_path.and_then(|value| {
.and_then(|value| if value.trim().is_empty() { None } else { Some(value) }); if value.trim().is_empty() {
None
} else {
Some(value)
}
});
let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| { let tab_order: Vec<String> = serde_json::from_str(&tab_order_str).unwrap_or_else(|_| {
vec![ vec![
+25 -75
View File
@@ -12,121 +12,71 @@
pub mod audio_settings; pub mod audio_settings;
pub mod developer_settings; pub mod developer_settings;
pub mod download_settings; pub mod download_settings;
pub mod graphics_settings;
pub mod playback_preferences;
pub mod favorites_preferences;
pub mod favorites_cache; pub mod favorites_cache;
pub mod favorites_preferences;
pub mod graphics_settings;
pub mod legal_settings;
pub mod playback_preferences;
pub mod remote_control_settings;
pub mod subscription_state; pub mod subscription_state;
pub mod tray_settings; pub mod tray_settings;
pub mod window_settings; pub mod window_settings;
pub mod legal_settings;
pub mod remote_control_settings;
pub use audio_settings::{ pub use audio_settings::{
AudioSettings, get_audio_settings, reset_audio_settings, set_audio_dac_passthrough, set_audio_exclusive_mode,
AudioSettingsState, set_audio_output_device, set_audio_sample_rate, AudioSettings, AudioSettingsState,
get_audio_settings,
set_audio_output_device,
set_audio_exclusive_mode,
set_audio_dac_passthrough,
set_audio_sample_rate,
reset_audio_settings,
}; };
pub use download_settings::{ pub use download_settings::{
DownloadSettings, get_download_settings, set_download_root, set_show_downloads_in_library,
DownloadSettingsState, validate_download_root, DownloadSettings, DownloadSettingsState,
get_download_settings,
set_download_root,
set_show_downloads_in_library,
validate_download_root,
}; };
pub use playback_preferences::{ pub use playback_preferences::{
AutoplayMode, get_playback_preferences, set_autoplay_mode, AutoplayMode, PlaybackPreferences,
PlaybackPreferences,
PlaybackPreferencesState, PlaybackPreferencesState,
get_playback_preferences,
set_autoplay_mode,
}; };
pub use favorites_preferences::{ pub use favorites_preferences::{
FavoritesPreferences, get_favorites_preferences, save_favorites_preferences, FavoritesPreferences,
FavoritesPreferencesState, FavoritesPreferencesState,
get_favorites_preferences,
save_favorites_preferences,
}; };
pub use subscription_state::{ pub use subscription_state::{
SubscriptionState, create_empty_subscription_state, create_subscription_state, SubscriptionState,
SubscriptionStateState, SubscriptionStateState, SubscriptionStateStore,
SubscriptionStateStore,
create_subscription_state,
create_empty_subscription_state,
}; };
pub use tray_settings::{ pub use tray_settings::{
TraySettings, get_tray_settings, set_close_to_tray, set_enable_tray, set_minimize_to_tray, TraySettings,
TraySettingsState, TraySettingsState,
get_tray_settings,
set_enable_tray,
set_minimize_to_tray,
set_close_to_tray,
}; };
pub use favorites_cache::{ pub use favorites_cache::{
FavoritesCacheState, cache_favorite_album, cache_favorite_artist, cache_favorite_track, clear_favorites_cache,
get_cached_favorite_tracks, get_cached_favorite_albums, get_cached_favorite_artists, get_cached_favorite_tracks,
get_cached_favorite_albums, sync_cached_favorite_albums, sync_cached_favorite_artists, sync_cached_favorite_tracks,
get_cached_favorite_artists, uncache_favorite_album, uncache_favorite_artist, uncache_favorite_track, FavoritesCacheState,
cache_favorite_track,
uncache_favorite_track,
cache_favorite_album,
uncache_favorite_album,
cache_favorite_artist,
uncache_favorite_artist,
sync_cached_favorite_tracks,
sync_cached_favorite_albums,
sync_cached_favorite_artists,
clear_favorites_cache,
}; };
pub use legal_settings::{ pub use legal_settings::{
LegalSettings, get_legal_settings, get_qobuz_tos_accepted, set_qobuz_tos_accepted, LegalSettings,
LegalSettingsState, LegalSettingsState, LegalSettingsStore,
LegalSettingsStore,
get_legal_settings,
get_qobuz_tos_accepted,
set_qobuz_tos_accepted,
}; };
pub use remote_control_settings::{ pub use remote_control_settings::{
RemoteControlSettings, AllowedOrigin, AllowedOriginsState, RemoteControlSettings, RemoteControlSettingsState,
RemoteControlSettingsState,
AllowedOrigin,
AllowedOriginsState,
}; };
pub use developer_settings::{ pub use developer_settings::{
DeveloperSettings, get_developer_settings, set_developer_force_dmabuf, DeveloperSettings, DeveloperSettingsState,
DeveloperSettingsState,
get_developer_settings,
set_developer_force_dmabuf,
}; };
pub use graphics_settings::{ pub use graphics_settings::{
GraphicsSettings, get_graphics_settings, set_force_x11, set_gdk_dpi_scale, set_gdk_scale,
GraphicsSettingsState, set_hardware_acceleration, GraphicsSettings, GraphicsSettingsState,
get_graphics_settings,
set_hardware_acceleration,
set_force_x11,
set_gdk_scale,
set_gdk_dpi_scale,
}; };
pub use window_settings::{ pub use window_settings::{
WindowSettings, get_window_settings, set_use_system_titlebar, WindowSettings, WindowSettingsState,
WindowSettingsState,
get_window_settings,
set_use_system_titlebar,
}; };
+36 -16
View File
@@ -10,14 +10,14 @@
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use qbz_audio::{settings::AudioSettingsStore, AudioDiagnostic, AudioSettings, VisualizerTap};
use qbz_core::QbzCore; use qbz_core::QbzCore;
use qbz_models::{ use qbz_models::{
Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse, Album, Artist, DiscoverAlbum, DiscoverData, DiscoverPlaylistsResponse, DiscoverResponse,
GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState, GenreInfo, LabelDetail, PageArtistResponse, Playlist, PlaylistTag, Quality, QueueState,
QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession, QueueTrack, RepeatMode, SearchResultsPage, StreamUrl, Track, UserSession,
}; };
use qbz_player::{Player, PlaybackState}; use qbz_player::{PlaybackState, Player};
use qbz_audio::{AudioSettings, AudioDiagnostic, VisualizerTap, settings::AudioSettingsStore};
use crate::tauri_adapter::TauriAdapter; use crate::tauri_adapter::TauriAdapter;
@@ -35,14 +35,18 @@ impl CoreBridge {
/// Loads audio settings from qbz_audio::AudioSettingsStore, creates a Player /// Loads audio settings from qbz_audio::AudioSettingsStore, creates a Player
/// from qbz-player crate, and passes it to QbzCore. /// from qbz-player crate, and passes it to QbzCore.
/// This is the V2 architecture - playback goes through QbzCore. /// This is the V2 architecture - playback goes through QbzCore.
pub async fn new(adapter: TauriAdapter, visualizer_tap: Option<VisualizerTap>) -> Result<Self, String> { pub async fn new(
adapter: TauriAdapter,
visualizer_tap: Option<VisualizerTap>,
) -> Result<Self, String> {
// Load audio settings using qbz_audio store // Load audio settings using qbz_audio store
let (device_name, audio_settings) = AudioSettingsStore::new() let (device_name, audio_settings) = AudioSettingsStore::new()
.ok() .ok()
.and_then(|store| { .and_then(|store| {
store.get_settings().ok().map(|settings| { store
(settings.output_device.clone(), settings) .get_settings()
}) .ok()
.map(|settings| (settings.output_device.clone(), settings))
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
log::info!("[CoreBridge] No saved audio settings, using defaults"); log::info!("[CoreBridge] No saved audio settings, using defaults");
@@ -83,7 +87,10 @@ impl CoreBridge {
/// Login with email and password /// Login with email and password
pub async fn login(&self, email: &str, password: &str) -> Result<UserSession, String> { pub async fn login(&self, email: &str, password: &str) -> Result<UserSession, String> {
self.core.login(email, password).await.map_err(|e| e.to_string()) self.core
.login(email, password)
.await
.map_err(|e| e.to_string())
} }
/// Logout current user /// Logout current user
@@ -219,17 +226,26 @@ impl CoreBridge {
/// Get album by ID /// Get album by ID
pub async fn get_album(&self, album_id: &str) -> Result<Album, String> { pub async fn get_album(&self, album_id: &str) -> Result<Album, String> {
self.core.get_album(album_id).await.map_err(|e| e.to_string()) self.core
.get_album(album_id)
.await
.map_err(|e| e.to_string())
} }
/// Get track by ID /// Get track by ID
pub async fn get_track(&self, track_id: u64) -> Result<Track, String> { pub async fn get_track(&self, track_id: u64) -> Result<Track, String> {
self.core.get_track(track_id).await.map_err(|e| e.to_string()) self.core
.get_track(track_id)
.await
.map_err(|e| e.to_string())
} }
/// Get artist by ID /// Get artist by ID
pub async fn get_artist(&self, artist_id: u64) -> Result<Artist, String> { pub async fn get_artist(&self, artist_id: u64) -> Result<Artist, String> {
self.core.get_artist(artist_id).await.map_err(|e| e.to_string()) self.core
.get_artist(artist_id)
.await
.map_err(|e| e.to_string())
} }
// ==================== Favorites ==================== // ==================== Favorites ====================
@@ -491,7 +507,10 @@ impl CoreBridge {
track_id: u64, track_id: u64,
quality: Quality, quality: Quality,
) -> Result<StreamUrl, String> { ) -> Result<StreamUrl, String> {
self.core.get_stream_url(track_id, quality).await.map_err(|e| e.to_string()) self.core
.get_stream_url(track_id, quality)
.await
.map_err(|e| e.to_string())
} }
// ==================== Playback Commands ==================== // ==================== Playback Commands ====================
@@ -549,10 +568,9 @@ impl CoreBridgeState {
/// Get the bridge (panics if not initialized) /// Get the bridge (panics if not initialized)
pub async fn get(&self) -> impl std::ops::Deref<Target = CoreBridge> + '_ { pub async fn get(&self) -> impl std::ops::Deref<Target = CoreBridge> + '_ {
tokio::sync::RwLockReadGuard::map( tokio::sync::RwLockReadGuard::map(self.0.read().await, |opt| {
self.0.read().await, opt.as_ref().expect("CoreBridge not initialized")
|opt| opt.as_ref().expect("CoreBridge not initialized") })
)
} }
/// Try to get the bridge, returns None if not initialized yet /// Try to get the bridge, returns None if not initialized yet
@@ -560,7 +578,9 @@ impl CoreBridgeState {
pub async fn try_get(&self) -> Option<impl std::ops::Deref<Target = CoreBridge> + '_> { pub async fn try_get(&self) -> Option<impl std::ops::Deref<Target = CoreBridge> + '_> {
let guard = self.0.read().await; let guard = self.0.read().await;
if guard.is_some() { if guard.is_some() {
Some(tokio::sync::RwLockReadGuard::map(guard, |opt| opt.as_ref().unwrap())) Some(tokio::sync::RwLockReadGuard::map(guard, |opt| {
opt.as_ref().unwrap()
}))
} else { } else {
None None
} }
+40 -35
View File
@@ -94,19 +94,19 @@ fn derive_key() -> [u8; 32] {
/// Encrypt credentials using AES-256-GCM /// Encrypt credentials using AES-256-GCM
fn encrypt_credentials(credentials: &QobuzCredentials) -> Result<String, String> { fn encrypt_credentials(credentials: &QobuzCredentials) -> Result<String, String> {
let key = derive_key(); let key = derive_key();
let cipher = Aes256Gcm::new_from_slice(&key) let cipher =
.map_err(|e| format!("Failed to create cipher: {}", e))?; Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
// Generate random nonce // Generate random nonce
let nonce_bytes: [u8; 12] = aes_gcm::aead::generic_array::GenericArray::from( let nonce_bytes: [u8; 12] =
rand::random::<[u8; 12]>() aes_gcm::aead::generic_array::GenericArray::from(rand::random::<[u8; 12]>()).into();
).into();
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let json = serde_json::to_string(credentials) let json = serde_json::to_string(credentials)
.map_err(|e| format!("Failed to serialize credentials: {}", e))?; .map_err(|e| format!("Failed to serialize credentials: {}", e))?;
let ciphertext = cipher.encrypt(nonce, json.as_bytes()) let ciphertext = cipher
.encrypt(nonce, json.as_bytes())
.map_err(|e| format!("Encryption failed: {}", e))?; .map_err(|e| format!("Encryption failed: {}", e))?;
let encrypted = EncryptedCredentials { let encrypted = EncryptedCredentials {
@@ -125,28 +125,33 @@ fn decrypt_credentials(encrypted_json: &str) -> Result<QobuzCredentials, String>
.map_err(|e| format!("Failed to parse encrypted data: {}", e))?; .map_err(|e| format!("Failed to parse encrypted data: {}", e))?;
if encrypted.version != 1 { if encrypted.version != 1 {
return Err(format!("Unsupported encryption version: {}", encrypted.version)); return Err(format!(
"Unsupported encryption version: {}",
encrypted.version
));
} }
let key = derive_key(); let key = derive_key();
let cipher = Aes256Gcm::new_from_slice(&key) let cipher =
.map_err(|e| format!("Failed to create cipher: {}", e))?; Aes256Gcm::new_from_slice(&key).map_err(|e| format!("Failed to create cipher: {}", e))?;
let nonce_bytes = BASE64.decode(&encrypted.nonce) let nonce_bytes = BASE64
.decode(&encrypted.nonce)
.map_err(|e| format!("Failed to decode nonce: {}", e))?; .map_err(|e| format!("Failed to decode nonce: {}", e))?;
let nonce = Nonce::from_slice(&nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = BASE64.decode(&encrypted.ciphertext) let ciphertext = BASE64
.decode(&encrypted.ciphertext)
.map_err(|e| format!("Failed to decode ciphertext: {}", e))?; .map_err(|e| format!("Failed to decode ciphertext: {}", e))?;
let plaintext = cipher.decrypt(nonce, ciphertext.as_ref()) let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?; .map_err(|_| "Decryption failed (wrong key or corrupted data)".to_string())?;
let json = String::from_utf8(plaintext) let json = String::from_utf8(plaintext)
.map_err(|e| format!("Failed to decode decrypted data: {}", e))?; .map_err(|e| format!("Failed to decode decrypted data: {}", e))?;
serde_json::from_str(&json) serde_json::from_str(&json).map_err(|e| format!("Failed to parse credentials: {}", e))
.map_err(|e| format!("Failed to parse credentials: {}", e))
} }
/// Legacy XOR deobfuscation (for migration only) /// Legacy XOR deobfuscation (for migration only)
@@ -163,10 +168,11 @@ fn load_legacy_credentials(path: &PathBuf) -> Result<Option<QobuzCredentials>, S
return Ok(None); return Ok(None);
} }
let encoded = fs::read_to_string(path) let encoded =
.map_err(|e| format!("Failed to read legacy file: {}", e))?; fs::read_to_string(path).map_err(|e| format!("Failed to read legacy file: {}", e))?;
let obfuscated = BASE64.decode(encoded.trim()) let obfuscated = BASE64
.decode(encoded.trim())
.map_err(|e| format!("Failed to decode legacy data: {}", e))?; .map_err(|e| format!("Failed to decode legacy data: {}", e))?;
let json_bytes = legacy_deobfuscate(&obfuscated); let json_bytes = legacy_deobfuscate(&obfuscated);
@@ -190,8 +196,7 @@ fn save_to_fallback(credentials: &QobuzCredentials) -> Result<(), String> {
let encrypted = encrypt_credentials(credentials)?; let encrypted = encrypt_credentials(credentials)?;
fs::write(&path, encrypted) fs::write(&path, encrypted).map_err(|e| format!("Failed to write credentials file: {}", e))?;
.map_err(|e| format!("Failed to write credentials file: {}", e))?;
log::info!("Credentials saved to encrypted fallback file"); log::info!("Credentials saved to encrypted fallback file");
Ok(()) Ok(())
@@ -235,7 +240,9 @@ fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
if let Ok(Some(creds)) = load_legacy_credentials(p) { if let Ok(Some(creds)) = load_legacy_credentials(p) {
// Save in new format // Save in new format
if save_to_fallback(&creds).is_ok() { if save_to_fallback(&creds).is_ok() {
log::info!("Successfully migrated credentials to new encrypted format"); log::info!(
"Successfully migrated credentials to new encrypted format"
);
return Ok(Some(creds)); return Ok(Some(creds));
} }
} }
@@ -247,8 +254,8 @@ fn load_from_fallback() -> Result<Option<QobuzCredentials>, String> {
return Ok(None); return Ok(None);
} }
let content = fs::read_to_string(&path) let content =
.map_err(|e| format!("Failed to read credentials file: {}", e))?; fs::read_to_string(&path).map_err(|e| format!("Failed to read credentials file: {}", e))?;
// Check if it's the new format or legacy // Check if it's the new format or legacy
if content.trim().starts_with('{') && content.contains("\"version\"") { if content.trim().starts_with('{') && content.contains("\"version\"") {
@@ -364,20 +371,18 @@ pub fn has_saved_credentials() -> bool {
// Check keyring // Check keyring
match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) { match Entry::new(SERVICE_NAME, QOBUZ_CREDENTIALS_KEY) {
Ok(entry) => { Ok(entry) => match entry.get_password() {
match entry.get_password() { Ok(_) => {
Ok(_) => { log::info!("Found credentials in system keyring");
log::info!("Found credentials in system keyring"); return true;
return true;
}
Err(keyring::Error::NoEntry) => {
log::info!("No credentials in keyring (NoEntry)");
}
Err(e) => {
log::warn!("Keyring check failed: {}", e);
}
} }
} Err(keyring::Error::NoEntry) => {
log::info!("No credentials in keyring (NoEntry)");
}
Err(e) => {
log::warn!("Keyring check failed: {}", e);
}
},
Err(e) => { Err(e) => {
log::warn!("Keyring not available: {}", e); log::warn!("Keyring not available: {}", e);
} }
+80 -26
View File
@@ -228,14 +228,23 @@ impl DiscogsClient {
log::debug!("Fetching Discogs release details for ID: {}", release_id); log::debug!("Fetching Discogs release details for ID: {}", release_id);
let response = self.client.get(&url).send().await let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch release details: {}", e))?; .map_err(|e| format!("Failed to fetch release details: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Failed to fetch release details: {}", response.status())); return Err(format!(
"Failed to fetch release details: {}",
response.status()
));
} }
let details: ReleaseDetails = response.json().await let details: ReleaseDetails = response
.json()
.await
.map_err(|e| format!("Failed to parse release details: {}", e))?; .map_err(|e| format!("Failed to parse release details: {}", e))?;
Ok(details) Ok(details)
@@ -269,14 +278,23 @@ impl DiscogsClient {
catalog_number catalog_number
); );
let response = self.client.get(&url).send().await let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?; .map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Discogs search failed with status: {}", response.status())); return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
} }
let search: SearchResponse = response.json().await let search: SearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?; .map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
// Get IDs of top 2 relevant releases // Get IDs of top 2 relevant releases
@@ -323,7 +341,12 @@ impl DiscogsClient {
count += 1; count += 1;
} }
} }
log::debug!("Added {} images from release #{} ({})", count, idx + 1, details.title); log::debug!(
"Added {} images from release #{} ({})",
count,
idx + 1,
details.title
);
} }
} }
Err(e) => { Err(e) => {
@@ -379,7 +402,10 @@ impl DiscogsClient {
// Return up to 10 unique images // Return up to 10 unique images
all_images.truncate(10); all_images.truncate(10);
log::info!("Returning {} artwork options from Discogs", all_images.len()); log::info!(
"Returning {} artwork options from Discogs",
all_images.len()
);
Ok(all_images) Ok(all_images)
} }
@@ -399,7 +425,8 @@ impl DiscogsClient {
let cache_path = cache_dir.join(&filename); let cache_path = cache_dir.join(&filename);
// Download the image // Download the image
self.download_image(image_url, &cache_path).await self.download_image(image_url, &cache_path)
.await
.ok_or_else(|| "Failed to download image".to_string())?; .ok_or_else(|| "Failed to download image".to_string())?;
Ok(cache_path.to_string_lossy().to_string()) Ok(cache_path.to_string_lossy().to_string())
@@ -415,14 +442,23 @@ impl DiscogsClient {
log::debug!("Searching Discogs for artist: {}", query); log::debug!("Searching Discogs for artist: {}", query);
let response = self.client.get(&url).send().await let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?; .map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Discogs search failed with status: {}", response.status())); return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
} }
let search: SearchResponse = response.json().await let search: SearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?; .map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
Ok(search) Ok(search)
@@ -458,11 +494,18 @@ impl DiscogsClient {
catalog_number catalog_number
); );
let response = self.client.get(&url).send().await let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?; .map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Discogs search failed with status: {}", response.status())); return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
} }
// Parse response with extended fields // Parse response with extended fields
@@ -471,11 +514,14 @@ impl DiscogsClient {
results: Vec<DiscogsSearchResultExtended>, results: Vec<DiscogsSearchResultExtended>,
} }
let search: ExtendedSearchResponse = response.json().await let search: ExtendedSearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?; .map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
// Filter to releases only // Filter to releases only
let results: Vec<_> = search.results let results: Vec<_> = search
.results
.into_iter() .into_iter()
.filter(|r| r.result_type == "release" || r.result_type == "master") .filter(|r| r.result_type == "release" || r.result_type == "master")
.collect(); .collect();
@@ -485,22 +531,35 @@ impl DiscogsClient {
} }
/// Get full release metadata including tracklist /// Get full release metadata including tracklist
pub async fn get_release_metadata(&self, release_id: u64) -> Result<DiscogsReleaseMetadata, String> { pub async fn get_release_metadata(
&self,
release_id: u64,
) -> Result<DiscogsReleaseMetadata, String> {
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id); let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
log::debug!("Fetching Discogs release metadata for ID: {}", release_id); log::debug!("Fetching Discogs release metadata for ID: {}", release_id);
let response = self.client.get(&url).send().await let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch release: {}", e))?; .map_err(|e| format!("Failed to fetch release: {}", e))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Failed to fetch release: {}", response.status())); return Err(format!("Failed to fetch release: {}", response.status()));
} }
let metadata: DiscogsReleaseMetadata = response.json().await let metadata: DiscogsReleaseMetadata = response
.json()
.await
.map_err(|e| format!("Failed to parse release metadata: {}", e))?; .map_err(|e| format!("Failed to parse release metadata: {}", e))?;
log::info!("Fetched Discogs release: {} ({:?})", metadata.title, metadata.year); log::info!(
"Fetched Discogs release: {} ({:?})",
metadata.title,
metadata.year
);
Ok(metadata) Ok(metadata)
} }
@@ -515,12 +574,7 @@ impl DiscogsClient {
urlencoding::encode(image_url) urlencoding::encode(image_url)
); );
let response = self let response = self.client.get(&proxy_url).send().await.ok()?;
.client
.get(&proxy_url)
.send()
.await
.ok()?;
if !response.status().is_success() { if !response.status().is_success() {
log::warn!("Failed to download Discogs image: {}", response.status()); log::warn!("Failed to download Discogs image: {}", response.status());
+3 -5
View File
@@ -3,8 +3,8 @@
//! Detects when QBZ is running inside a Flatpak sandbox //! Detects when QBZ is running inside a Flatpak sandbox
//! and provides sandbox-specific utilities. //! and provides sandbox-specific utilities.
use std::path::{Path, PathBuf};
use std::fs; use std::fs;
use std::path::{Path, PathBuf};
/// Check if QBZ is running inside a Flatpak sandbox /// Check if QBZ is running inside a Flatpak sandbox
pub fn is_flatpak() -> bool { pub fn is_flatpak() -> bool {
@@ -24,8 +24,7 @@ pub fn migrate_app_id_data() -> Result<bool, String> {
// dirs::config_dir() returns ~/.var/app/com.blitzfc.qbz/config/ (new sandbox) // dirs::config_dir() returns ~/.var/app/com.blitzfc.qbz/config/ (new sandbox)
// We need to access ~/.var/app/com.blitzkriegfc.qbz/ (old sandbox) which is outside our current sandbox // We need to access ~/.var/app/com.blitzkriegfc.qbz/ (old sandbox) which is outside our current sandbox
let home = std::env::var("HOME") let home = std::env::var("HOME").map_err(|_| "Could not determine HOME directory")?;
.map_err(|_| "Could not determine HOME directory")?;
let home_path = PathBuf::from(home); let home_path = PathBuf::from(home);
let old_sandbox = home_path.join(".var/app/com.blitzkriegfc.qbz"); let old_sandbox = home_path.join(".var/app/com.blitzkriegfc.qbz");
@@ -75,8 +74,7 @@ pub fn migrate_app_id_data() -> Result<bool, String> {
if old_data.exists() && !new_data.exists() { if old_data.exists() && !new_data.exists() {
log::info!("Migrating data: {:?} → {:?}", old_data, new_data); log::info!("Migrating data: {:?} → {:?}", old_data, new_data);
copy_dir_all(&old_data, &new_data) copy_dir_all(&old_data, &new_data).map_err(|e| format!("Failed to migrate data: {}", e))?;
.map_err(|e| format!("Failed to migrate data: {}", e))?;
migrated = true; migrated = true;
} }
+13 -6
View File
@@ -8,9 +8,7 @@ use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use qbz_integrations::{ use qbz_integrations::{
LastFmClient, LastFmClient, ListenBrainzClient, ListenBrainzConfig, MusicBrainzClient, MusicBrainzConfig,
ListenBrainzClient, ListenBrainzConfig,
MusicBrainzClient, MusicBrainzConfig,
}; };
/// V2 ListenBrainz state wrapper for Tauri /// V2 ListenBrainz state wrapper for Tauri
@@ -31,7 +29,12 @@ impl ListenBrainzV2State {
} }
/// Initialize with saved credentials /// Initialize with saved credentials
pub async fn init_with_credentials(&self, token: Option<String>, user_name: Option<String>, enabled: bool) { pub async fn init_with_credentials(
&self,
token: Option<String>,
user_name: Option<String>,
enabled: bool,
) {
let config = ListenBrainzConfig { let config = ListenBrainzConfig {
enabled, enabled,
token: token.clone(), token: token.clone(),
@@ -136,7 +139,11 @@ impl LastFmV2State {
/// Get session key for persistence /// Get session key for persistence
pub async fn get_session_key(&self) -> Option<String> { pub async fn get_session_key(&self) -> Option<String> {
self.client.lock().await.session_key().map(|s| s.to_string()) self.client
.lock()
.await
.session_key()
.map(|s| s.to_string())
} }
/// Store pending token during auth flow /// Store pending token during auth flow
@@ -157,6 +164,6 @@ impl Default for LastFmV2State {
} }
// Re-export types for commands // Re-export types for commands
pub use qbz_integrations::lastfm::LastFmSession as LfSession;
pub use qbz_integrations::listenbrainz::ListenBrainzStatus as LbStatus; pub use qbz_integrations::listenbrainz::ListenBrainzStatus as LbStatus;
pub use qbz_integrations::listenbrainz::UserInfo; pub use qbz_integrations::listenbrainz::UserInfo;
pub use qbz_integrations::lastfm::LastFmSession as LfSession;
+7 -4
View File
@@ -122,9 +122,9 @@ impl LastFmClient {
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 { message, .. } => Err(message), LastFmResponse::Error { message, .. } => Err(message),
@@ -133,7 +133,10 @@ impl LastFmClient {
/// Get session key after user has authorized /// Get session key after user has authorized
pub async fn get_session(&mut self, token: &str) -> Result<LastFmSession, String> { pub async fn get_session(&mut self, token: &str) -> Result<LastFmSession, String> {
log::info!("Getting Last.fm session with token: {}...", &token[..token.len().min(8)]); log::info!(
"Getting Last.fm session with token: {}...",
&token[..token.len().min(8)]
);
let url = format!("{}/auth.getSession", LASTFM_PROXY_URL); let url = format!("{}/auth.getSession", LASTFM_PROXY_URL);
File diff suppressed because it is too large. Load diff
+62 -51
View File
@@ -9,33 +9,62 @@ pub mod remote_metadata;
// Re-export everything from qbz-library // Re-export everything from qbz-library
pub use qbz_library::{ pub use qbz_library::{
apply_sidecar_to_track,
clear_thumbnails,
cue_to_tracks,
delete_album_sidecar,
// Thumbnails
generate_thumbnail,
generate_thumbnail_from_bytes,
get_artwork_cache_dir,
get_cache_size,
// Utility functions
get_db_path,
get_or_generate_thumbnail,
get_thumbnail_path,
get_thumbnails_dir,
read_album_sidecar,
sidecar_path,
thumbnail_exists,
write_album_sidecar,
// Tag sidecar
AlbumMetadataOverride,
AlbumSettings,
AlbumTagSidecar,
AlbumTrackUpdate,
ArtistImageInfo,
// Models
AudioFormat,
AudioProperties,
// CUE Parser
CueParser,
CueSheet,
CueTime,
CueTrack,
// Database
LibraryDatabase,
// Errors // Errors
LibraryError, LibraryError,
// Models LibraryFolder,
AudioFormat, LocalTrack, LocalAlbum, LocalArtist,
PlaylistLocalTrack, ScanProgress, ScanStatus, ScanError,
AudioProperties, AlbumSettings, ArtistImageInfo,
// Scanner // Scanner
LibraryScanner, ScanResult, LibraryScanner,
LibraryStats,
LocalAlbum,
LocalArtist,
LocalContentStatus,
LocalTrack,
// Metadata // Metadata
MetadataExtractor, MetadataExtractor,
// CUE Parser PlaylistFolder,
CueParser, CueSheet, CueTrack, CueTime, cue_to_tracks, PlaylistLocalTrack,
// Database PlaylistSettings,
LibraryDatabase, LibraryFolder, LibraryStats, PlaylistStats,
AlbumTrackUpdate, TrackMetadataUpdateFull, ScanError,
PlaylistFolder, PlaylistSettings, PlaylistStats, ScanProgress,
LocalContentStatus, ScanResult,
// Thumbnails ScanStatus,
generate_thumbnail, generate_thumbnail_from_bytes, TrackMetadataOverride,
get_thumbnail_path, get_thumbnails_dir, thumbnail_exists, TrackMetadataUpdateFull,
get_or_generate_thumbnail, clear_thumbnails, get_cache_size,
// Tag sidecar
AlbumMetadataOverride, TrackMetadataOverride, AlbumTagSidecar,
sidecar_path, read_album_sidecar, write_album_sidecar,
delete_album_sidecar, apply_sidecar_to_track,
// Utility functions
get_db_path, get_artwork_cache_dir,
}; };
// Backwards compatibility: re-export as modules // Backwards compatibility: re-export as modules
@@ -45,41 +74,23 @@ pub mod database {
pub mod thumbnails { pub mod thumbnails {
pub use qbz_library::{ pub use qbz_library::{
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,
}; };
} }
// Re-export commands::LibraryState for compatibility // Re-export commands::LibraryState for compatibility
pub use commands::LibraryState; pub use commands::LibraryState;
pub use commands::{ pub use commands::{
LibraryAlbumMetadataUpdateRequest, library_add_folder, library_cleanup_missing_files, library_clear_impl,
CleanupResult, library_fetch_missing_artwork, library_get_album_tracks, library_get_artists,
library_add_folder, library_get_folders, library_get_folders_with_metadata, library_get_scan_progress,
library_cleanup_missing_files, library_get_stats, library_get_thumbnail, library_get_thumbnails_cache_size,
library_fetch_missing_artwork, library_get_tracks_by_ids, library_refresh_album_metadata_from_files_impl,
library_get_album_tracks, library_scan_folder_impl, library_scan_impl, library_search, library_stop_scan_impl,
library_get_artists, library_update_album_metadata_impl, library_write_album_metadata_to_files_impl,
library_get_folders, playlist_get_custom_order, playlist_get_tracks_with_local_copies, playlist_has_custom_order,
library_get_folders_with_metadata, update_playlist_folder, CleanupResult, LibraryAlbumMetadataUpdateRequest,
library_scan_impl,
library_get_scan_progress,
library_get_stats,
library_get_thumbnail,
library_get_thumbnails_cache_size,
library_get_tracks_by_ids,
library_search,
library_stop_scan_impl,
library_scan_folder_impl,
library_clear_impl,
playlist_get_custom_order,
playlist_get_tracks_with_local_copies,
playlist_has_custom_order,
update_playlist_folder,
library_update_album_metadata_impl,
library_write_album_metadata_to_files_impl,
library_refresh_album_metadata_from_files_impl,
}; };
use std::path::Path; use std::path::Path;
+2 -10
View File
@@ -95,11 +95,7 @@ impl RemoteMetadataCache {
let cache = self.search_cache.read().await; let cache = self.search_cache.read().await;
if let Some(entry) = cache.get(&key) { if let Some(entry) = cache.get(&key) {
if !entry.is_expired() { if !entry.is_expired() {
log::debug!( log::debug!("Cache hit for search: {} {:?}", provider, query);
"Cache hit for search: {} {:?}",
provider,
query
);
return Some(entry.data.clone()); return Some(entry.data.clone());
} }
} }
@@ -140,11 +136,7 @@ impl RemoteMetadataCache {
let cache = self.metadata_cache.read().await; let cache = self.metadata_cache.read().await;
if let Some(entry) = cache.get(&key) { if let Some(entry) = cache.get(&key) {
if !entry.is_expired() { if !entry.is_expired() {
log::debug!( log::debug!("Cache hit for metadata: {} {}", provider, provider_id);
"Cache hit for metadata: {} {}",
provider,
provider_id
);
return Some(entry.data.clone()); return Some(entry.data.clone());
} }
} }
+104 -76
View File
@@ -80,9 +80,10 @@ pub fn musicbrainz_release_to_search_result(
.unwrap_or_default(); .unwrap_or_default();
// Extract year from date (YYYY or YYYY-MM-DD) // Extract year from date (YYYY or YYYY-MM-DD)
let year = release.date.as_ref().and_then(|d| { let year = release
d.split('-').next().and_then(|y| y.parse::<u16>().ok()) .date
}); .as_ref()
.and_then(|d| d.split('-').next().and_then(|y| y.parse::<u16>().ok()));
// Extract label and catalog number // Extract label and catalog number
let (label, catalog_number) = release let (label, catalog_number) = release
@@ -99,9 +100,10 @@ pub fn musicbrainz_release_to_search_result(
// Get track count - either from direct field or sum from media // Get track count - either from direct field or sum from media
let track_count = release.track_count.or_else(|| { let track_count = release.track_count.or_else(|| {
release.media.as_ref().map(|media| { release
media.iter().filter_map(|m| m.track_count).sum() .media
}) .as_ref()
.map(|media| media.iter().filter_map(|m| m.track_count).sum())
}); });
// Get format from first medium // Get format from first medium
@@ -269,16 +271,21 @@ pub fn musicbrainz_full_to_metadata(
.unwrap_or_default(); .unwrap_or_default();
// Extract year from date // Extract year from date
let year = release.date.as_ref().and_then(|d| { let year = release
d.split('-').next().and_then(|y| y.parse::<u16>().ok()) .date
}); .as_ref()
.and_then(|d| d.split('-').next().and_then(|y| y.parse::<u16>().ok()));
// Extract genres from tags (sorted by count, take top 5) // Extract genres from tags (sorted by count, take top 5)
let genres: Vec<String> = release.tags.as_ref().map(|tags| { let genres: Vec<String> = release
let mut sorted: Vec<_> = tags.iter().collect(); .tags
sorted.sort_by(|a, b| b.count.cmp(&a.count)); .as_ref()
sorted.iter().take(5).map(|t| t.name.clone()).collect() .map(|tags| {
}).unwrap_or_default(); let mut sorted: Vec<_> = tags.iter().collect();
sorted.sort_by(|a, b| b.count.cmp(&a.count));
sorted.iter().take(5).map(|t| t.name.clone()).collect()
})
.unwrap_or_default();
// Extract label and catalog number // Extract label and catalog number
let (label, catalog_number) = release let (label, catalog_number) = release
@@ -297,26 +304,35 @@ pub fn musicbrainz_full_to_metadata(
let disc_count = release.media.as_ref().map(|m| m.len() as u8).unwrap_or(1); let disc_count = release.media.as_ref().map(|m| m.len() as u8).unwrap_or(1);
// Convert tracks // Convert tracks
let tracks: Vec<RemoteTrackMetadata> = release.media.as_ref().map(|media| { let tracks: Vec<RemoteTrackMetadata> = release
let mut all_tracks = Vec::new(); .media
for medium in media { .as_ref()
if let Some(tracks) = &medium.tracks { .map(|media| {
for track in tracks { let mut all_tracks = Vec::new();
all_tracks.push(RemoteTrackMetadata { for medium in media {
disc_number: medium.position.unwrap_or(1), if let Some(tracks) = &medium.tracks {
track_number: track.position.unwrap_or(1), for track in tracks {
title: track.title.clone().or_else(|| { all_tracks.push(RemoteTrackMetadata {
track.recording.as_ref().and_then(|r| r.title.clone()) disc_number: medium.position.unwrap_or(1),
}).unwrap_or_default(), track_number: track.position.unwrap_or(1),
duration_ms: track.length.map(|l| l as u32).or_else(|| { title: track
track.recording.as_ref().and_then(|r| r.length.map(|l| l as u32)) .title
}), .clone()
}); .or_else(|| track.recording.as_ref().and_then(|r| r.title.clone()))
.unwrap_or_default(),
duration_ms: track.length.map(|l| l as u32).or_else(|| {
track
.recording
.as_ref()
.and_then(|r| r.length.map(|l| l as u32))
}),
});
}
} }
} }
} all_tracks
all_tracks })
}).unwrap_or_default(); .unwrap_or_default();
RemoteAlbumMetadata { RemoteAlbumMetadata {
provider: RemoteProvider::MusicBrainz, provider: RemoteProvider::MusicBrainz,
@@ -346,13 +362,7 @@ pub fn discogs_full_to_metadata(
.map(|artists| { .map(|artists| {
artists artists
.iter() .iter()
.map(|a| { .map(|a| format!("{}{}", a.name.clone(), a.join.as_deref().unwrap_or("")))
format!(
"{}{}",
a.name.clone(),
a.join.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("") .join("")
}) })
@@ -379,24 +389,28 @@ pub fn discogs_full_to_metadata(
.unwrap_or((None, None)); .unwrap_or((None, None));
// Convert tracklist // Convert tracklist
let tracks: Vec<RemoteTrackMetadata> = release.tracklist.as_ref().map(|tracklist| { let tracks: Vec<RemoteTrackMetadata> = release
tracklist .tracklist
.iter() .as_ref()
.filter(|t| { .map(|tracklist| {
// Filter out headings (disc separators) tracklist
t.track_type.as_deref() != Some("heading") .iter()
}) .filter(|t| {
.map(|t| { // Filter out headings (disc separators)
let (disc_number, track_number) = parse_discogs_position(&t.position); t.track_type.as_deref() != Some("heading")
RemoteTrackMetadata { })
disc_number, .map(|t| {
track_number, let (disc_number, track_number) = parse_discogs_position(&t.position);
title: t.title.clone(), RemoteTrackMetadata {
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)), disc_number,
} track_number,
}) title: t.title.clone(),
.collect() duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
}).unwrap_or_default(); }
})
.collect()
})
.unwrap_or_default();
// Count unique discs // Count unique discs
let disc_count = tracks.iter().map(|t| t.disc_number).max().unwrap_or(1); let disc_count = tracks.iter().map(|t| t.disc_number).max().unwrap_or(1);
@@ -437,15 +451,19 @@ impl RemoteMetadataState {
let results = match provider { let results = match provider {
RemoteProvider::MusicBrainz => { RemoteProvider::MusicBrainz => {
self.search_musicbrainz(query, artist.unwrap_or(""), limit).await? self.search_musicbrainz(query, artist.unwrap_or(""), limit)
.await?
} }
RemoteProvider::Discogs => { RemoteProvider::Discogs => {
self.search_discogs(query, artist.unwrap_or(""), limit).await? self.search_discogs(query, artist.unwrap_or(""), limit)
.await?
} }
}; };
// Cache results // Cache results
self.cache.set_search(provider, query, None, artist, results.clone()).await; self.cache
.set_search(provider, query, None, artist, results.clone())
.await;
Ok(results) Ok(results)
} }
@@ -463,16 +481,14 @@ impl RemoteMetadataState {
} }
let metadata = match provider { let metadata = match provider {
RemoteProvider::MusicBrainz => { RemoteProvider::MusicBrainz => self.get_musicbrainz_metadata(provider_id).await?,
self.get_musicbrainz_metadata(provider_id).await? RemoteProvider::Discogs => self.get_discogs_metadata(provider_id).await?,
}
RemoteProvider::Discogs => {
self.get_discogs_metadata(provider_id).await?
}
}; };
// Cache results // Cache results
self.cache.set_metadata(provider, provider_id, metadata.clone()).await; self.cache
.set_metadata(provider, provider_id, metadata.clone())
.await;
Ok(metadata) Ok(metadata)
} }
@@ -484,15 +500,21 @@ impl RemoteMetadataState {
artist: &str, artist: &str,
limit: usize, limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> { ) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref() let mb_state =
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?; self.musicbrainz
.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable(
"MusicBrainz not initialized".to_string(),
))?;
let response = mb_state.client let response = mb_state
.client
.search_releases_extended(query, artist, None, limit) .search_releases_extended(query, artist, None, limit)
.await .await
.map_err(|e| RemoteMetadataError::NetworkError(e))?; .map_err(|e| RemoteMetadataError::NetworkError(e))?;
let results = response.releases let results = response
.releases
.iter() .iter()
.map(musicbrainz_release_to_search_result) .map(musicbrainz_release_to_search_result)
.collect(); .collect();
@@ -527,10 +549,15 @@ impl RemoteMetadataState {
&self, &self,
release_id: &str, release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> { ) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref() let mb_state =
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?; self.musicbrainz
.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable(
"MusicBrainz not initialized".to_string(),
))?;
let response = mb_state.client let response = mb_state
.client
.get_release_with_tracks(release_id) .get_release_with_tracks(release_id)
.await .await
.map_err(|e| RemoteMetadataError::NetworkError(e))?; .map_err(|e| RemoteMetadataError::NetworkError(e))?;
@@ -543,8 +570,9 @@ impl RemoteMetadataState {
&self, &self,
release_id: &str, release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> { ) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let release_id: u64 = release_id.parse() let release_id: u64 = release_id.parse().map_err(|_| {
.map_err(|_| RemoteMetadataError::InvalidProviderId("Invalid Discogs release ID".to_string()))?; RemoteMetadataError::InvalidProviderId("Invalid Discogs release ID".to_string())
})?;
let client = self.discogs.lock().await; let client = self.discogs.lock().await;
+12 -3
View File
@@ -103,7 +103,9 @@ impl ListenBrainzClient {
return Err(validation.message); return Err(validation.message);
} }
let user_name = validation.user_name.ok_or("Token valid but no username returned")?; let user_name = validation
.user_name
.ok_or("Token valid but no username returned")?;
// Store validated token and username // Store validated token and username
{ {
@@ -252,7 +254,11 @@ impl ListenBrainzClient {
} }
/// Internal: Submit listens to API /// Internal: Submit listens to API
async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> Result<(), String> { async fn submit_listens(
&self,
token: &str,
payload: &SubmitListensPayload,
) -> Result<(), String> {
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL); let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
let response = self let response = self
@@ -282,7 +288,10 @@ impl ListenBrainzClient {
} else { } else {
let status = response.status(); let status = response.status();
let text = response.text().await.unwrap_or_default(); let text = response.text().await.unwrap_or_default();
Err(format!("ListenBrainz submission failed: {} - {}", status, text)) Err(format!(
"ListenBrainz submission failed: {} - {}",
status, text
))
} }
} }
} }
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod models;
pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats}; pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats};
pub use client::{ListenBrainzClient, ListenBrainzConfig}; pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{ pub use models::{
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
SubmitListensPayload, TrackMetadata, UserInfo, TrackMetadata, UserInfo,
}; };
use std::path::Path; use std::path::Path;
+12 -9
View File
@@ -11,9 +11,8 @@ use std::sync::{LazyLock, Mutex};
const MAX_LOG_LINES: usize = 5000; const MAX_LOG_LINES: usize = 5000;
/// Global ring buffer for captured log lines /// Global ring buffer for captured log lines
static LOG_BUFFER: LazyLock<Mutex<VecDeque<String>>> = LazyLock::new(|| { static LOG_BUFFER: LazyLock<Mutex<VecDeque<String>>> =
Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES)) LazyLock::new(|| Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES)));
});
/// Push a log line to the ring buffer (used by startup messages before env_logger is initialized) /// Push a log line to the ring buffer (used by startup messages before env_logger is initialized)
pub fn push_log(line: String) { pub fn push_log(line: String) {
@@ -27,7 +26,8 @@ pub fn push_log(line: String) {
/// Get all captured log lines /// Get all captured log lines
pub fn get_logs() -> Vec<String> { pub fn get_logs() -> Vec<String> {
LOG_BUFFER.lock() LOG_BUFFER
.lock()
.map(|buf| buf.iter().cloned().collect()) .map(|buf| buf.iter().cloned().collect())
.unwrap_or_default() .unwrap_or_default()
} }
@@ -73,12 +73,13 @@ pub fn get_backend_logs() -> Vec<String> {
#[tauri::command] #[tauri::command]
pub async fn upload_logs_to_paste(content: String) -> Result<String, String> { pub async fn upload_logs_to_paste(content: String) -> Result<String, String> {
let form = reqwest::multipart::Form::new() let form = reqwest::multipart::Form::new().part(
.part("file", reqwest::multipart::Part::text(content) "file",
reqwest::multipart::Part::text(content)
.file_name("qbz-logs.txt") .file_name("qbz-logs.txt")
.mime_str("text/plain") .mime_str("text/plain")
.map_err(|e| format!("Failed to create multipart part: {}", e))? .map_err(|e| format!("Failed to create multipart part: {}", e))?,
); );
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent("QBZ/1.0") .user_agent("QBZ/1.0")
@@ -95,7 +96,9 @@ pub async fn upload_logs_to_paste(content: String) -> Result<String, String> {
return Err(format!("Upload failed with status: {}", response.status())); return Err(format!("Upload failed with status: {}", response.status()));
} }
let url = response.text().await let url = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?; .map_err(|e| format!("Failed to read response: {}", e))?;
Ok(url.trim().to_string()) Ok(url.trim().to_string())
+34 -14
View File
@@ -1,10 +1,10 @@
//! Tauri commands for lyrics //! Tauri commands for lyrics
use tauri::State;
use serde::Serialize; use serde::Serialize;
use tauri::State;
use super::{build_cache_key, LyricsPayload, LyricsState};
use super::providers::{fetch_lrclib, fetch_lyrics_ovh}; use super::providers::{fetch_lrclib, fetch_lyrics_ovh};
use super::{build_cache_key, LyricsPayload, LyricsState};
#[tauri::command] #[tauri::command]
pub async fn lyrics_get( pub async fn lyrics_get(
@@ -29,7 +29,9 @@ pub async fn lyrics_get(
// (search-first strategy is likely to find synced now). // (search-first strategy is likely to find synced now).
{ {
let db_opt__ = state.db.lock().await; let db_opt__ = state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
let cached = if let Some(id) = track_id { let cached = if let Some(id) = track_id {
db.get_by_track_id(id).ok().flatten() db.get_by_track_id(id).ok().flatten()
@@ -39,7 +41,11 @@ pub async fn lyrics_get(
.or_else(|| db.get_by_cache_key(&cache_key).ok().flatten()); .or_else(|| db.get_by_cache_key(&cache_key).ok().flatten());
if let Some(payload) = cached { if let Some(payload) = cached {
let has_synced = payload.synced_lrc.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false); let has_synced = payload
.synced_lrc
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if has_synced { if has_synced {
return Ok(Some(payload)); return Ok(Some(payload));
} }
@@ -56,7 +62,10 @@ pub async fn lyrics_get(
match fetch_lrclib(title_trimmed, artist_trimmed, duration_secs).await { match fetch_lrclib(title_trimmed, artist_trimmed, duration_secs).await {
Ok(data) => data, Ok(data) => data,
Err(e2) => { Err(e2) => {
eprintln!("[Lyrics] LRCLIB attempt 2 failed: {}, falling back to lyrics.ovh", e2); eprintln!(
"[Lyrics] LRCLIB attempt 2 failed: {}, falling back to lyrics.ovh",
e2
);
None None
} }
} }
@@ -77,7 +86,9 @@ pub async fn lyrics_get(
}; };
let db_opt__ = state.db.lock().await; let db_opt__ = state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
db.upsert(&cache_key, &payload)?; db.upsert(&cache_key, &payload)?;
return Ok(Some(payload)); return Ok(Some(payload));
} }
@@ -96,7 +107,9 @@ pub async fn lyrics_get(
}; };
let db_opt__ = state.db.lock().await; let db_opt__ = state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
db.upsert(&cache_key, &payload)?; db.upsert(&cache_key, &payload)?;
return Ok(Some(payload)); return Ok(Some(payload));
} }
@@ -107,7 +120,9 @@ pub async fn lyrics_get(
#[tauri::command] #[tauri::command]
pub async fn lyrics_clear_cache(state: State<'_, LyricsState>) -> Result<(), String> { pub async fn lyrics_clear_cache(state: State<'_, LyricsState>) -> Result<(), String> {
let db_opt__ = state.db.lock().await; let db_opt__ = state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
db.clear() db.clear()
} }
@@ -119,10 +134,14 @@ pub struct LyricsCacheStats {
} }
#[tauri::command] #[tauri::command]
pub async fn lyrics_get_cache_stats(state: State<'_, LyricsState>) -> Result<LyricsCacheStats, String> { pub async fn lyrics_get_cache_stats(
state: State<'_, LyricsState>,
) -> Result<LyricsCacheStats, String> {
let entries = { let entries = {
let db_opt__ = state.db.lock().await; let db_opt__ = state.db.lock().await;
let db = db_opt__.as_ref().ok_or("No active session - please log in")?; let db = db_opt__
.as_ref()
.ok_or("No active session - please log in")?;
db.count_entries()? db.count_entries()?
}; };
@@ -132,9 +151,10 @@ pub async fn lyrics_get_cache_stats(state: State<'_, LyricsState>) -> Result<Lyr
.join("qbz") .join("qbz")
.join("lyrics") .join("lyrics")
.join("lyrics.db"); .join("lyrics.db");
let size_bytes = std::fs::metadata(&db_path) let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
.map(|m| m.len())
.unwrap_or(0);
Ok(LyricsCacheStats { entries, size_bytes }) Ok(LyricsCacheStats {
entries,
size_bytes,
})
} }
+25 -9
View File
@@ -176,17 +176,26 @@ async fn fetch_lrclib_get(
} }
// Get raw text first for debugging // Get raw text first for debugging
let text = response.text().await let text = response
.text()
.await
.map_err(|e| format!("LRCLIB get response text failed: {}", e))?; .map_err(|e| format!("LRCLIB get response text failed: {}", e))?;
// Log if syncedLyrics is present in raw response // Log if syncedLyrics is present in raw response
let has_synced = text.contains("syncedLyrics") && !text.contains("\"syncedLyrics\":null"); let has_synced = text.contains("syncedLyrics") && !text.contains("\"syncedLyrics\":null");
println!("[Lyrics] LRCLIB get raw response has syncedLyrics: {}, len: {}", has_synced, text.len()); println!(
"[Lyrics] LRCLIB get raw response has syncedLyrics: {}, len: {}",
has_synced,
text.len()
);
let item: LrclibItem = serde_json::from_str(&text) let item: LrclibItem = serde_json::from_str(&text)
.map_err(|e| format!("LRCLIB get response parse failed: {}", e))?; .map_err(|e| format!("LRCLIB get response parse failed: {}", e))?;
println!("[Lyrics] LRCLIB parsed - synced_lyrics present: {}", item.synced_lyrics.is_some()); println!(
"[Lyrics] LRCLIB parsed - synced_lyrics present: {}",
item.synced_lyrics.is_some()
);
Ok(Some(item)) Ok(Some(item))
} }
@@ -199,10 +208,7 @@ async fn fetch_lrclib_search(
let response = client let response = client
.get("https://lrclib.net/api/search") .get("https://lrclib.net/api/search")
.header("User-Agent", "QBZ-Nix/1.0 (https://github.com/qbz-nix)") .header("User-Agent", "QBZ-Nix/1.0 (https://github.com/qbz-nix)")
.query(&[ .query(&[("track_name", title), ("artist_name", artist)])
("track_name", title),
("artist_name", artist),
])
.send() .send()
.await .await
.map_err(|e| format!("LRCLIB search request failed: {}", e))?; .map_err(|e| format!("LRCLIB search request failed: {}", e))?;
@@ -258,9 +264,19 @@ fn pick_best_match(
} }
} }
if item.synced_lyrics.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false) { if item
.synced_lyrics
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
score += 2; score += 2;
} else if item.plain_lyrics.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false) { } else if item
.plain_lyrics
.as_ref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
score += 1; score += 1;
} }
+79 -44
View File
@@ -108,15 +108,13 @@ fn main() {
// CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits // CLI flag: --reset-dmabuf — resets the developer force_dmabuf setting and exits
if std::env::args().any(|a| a == "--reset-dmabuf") { if std::env::args().any(|a| a == "--reset-dmabuf") {
match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
Ok(store) => { Ok(store) => match store.set_force_dmabuf(false) {
match store.set_force_dmabuf(false) { Ok(()) => {
Ok(()) => { eprintln!("[QBZ] Developer force_dmabuf has been reset to false.");
eprintln!("[QBZ] Developer force_dmabuf has been reset to false."); eprintln!("[QBZ] You can now start QBZ normally.");
eprintln!("[QBZ] You can now start QBZ normally.");
}
Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e),
} }
} Err(e) => eprintln!("[QBZ] Failed to reset force_dmabuf: {}", e),
},
Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e), Err(e) => eprintln!("[QBZ] Failed to open developer settings: {}", e),
} }
return; return;
@@ -178,22 +176,28 @@ fn main() {
// Graphics settings from DB (force_x11, scaling) // Graphics settings from DB (force_x11, scaling)
// Track if we're using fallback defaults (for UI visibility) // Track if we're using fallback defaults (for UI visibility)
let mut graphics_using_fallback = false; let mut graphics_using_fallback = false;
let graphics_db = match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new() { let graphics_db = match qbz_nix_lib::config::graphics_settings::GraphicsSettingsStore::new()
Ok(store) => match store.get_settings() { {
Ok(settings) => Some(settings), Ok(store) => {
Err(e) => { match store.get_settings() {
graphics_using_fallback = true; Ok(settings) => Some(settings),
eprintln!("[QBZ] WARNING: Graphics settings read failed: {}. Using safe defaults.", e); Err(e) => {
qbz_nix_lib::logging::log_startup(&format!( graphics_using_fallback = true;
eprintln!("[QBZ] WARNING: Graphics settings read failed: {}. Using safe defaults.", e);
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] Graphics settings unavailable ({}), using safe defaults. If experiencing lag, run: qbz --reset-graphics", "[QBZ] Graphics settings unavailable ({}), using safe defaults. If experiencing lag, run: qbz --reset-graphics",
e e
)); ));
None None
}
} }
}, }
Err(e) => { Err(e) => {
graphics_using_fallback = true; graphics_using_fallback = true;
eprintln!("[QBZ] WARNING: Graphics settings store unavailable: {}. Using safe defaults.", e); eprintln!(
"[QBZ] WARNING: Graphics settings store unavailable: {}. Using safe defaults.",
e
);
qbz_nix_lib::logging::log_startup(&format!( qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] Graphics settings store unavailable ({}), using safe defaults. If experiencing lag, run: qbz --reset-graphics", "[QBZ] Graphics settings store unavailable ({}), using safe defaults. If experiencing lag, run: qbz --reset-graphics",
e e
@@ -207,40 +211,47 @@ fn main() {
// opt-out that disables all GPU compositing and DMA-BUF everywhere. // opt-out that disables all GPU compositing and DMA-BUF everywhere.
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] Env override: QBZ_HARDWARE_ACCEL=0 (all GPU rendering disabled)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Env override: QBZ_HARDWARE_ACCEL=0 (all GPU rendering disabled)",
);
false false
} }
// "1" is the default behavior, log only if explicitly set // "1" is the default behavior, log only if explicitly set
Ok("1") => { Ok("1") => {
qbz_nix_lib::logging::log_startup("[QBZ] Env override: QBZ_HARDWARE_ACCEL=1 (full GPU, all safety bypassed)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Env override: QBZ_HARDWARE_ACCEL=1 (full GPU, all safety bypassed)",
);
true true
} }
_ => true, // Default: hardware acceleration ON (v1.1.9 behavior) _ => true, // Default: hardware acceleration ON (v1.1.9 behavior)
}; };
// Developer settings: force_dmabuf override (from Settings > Developer Mode) // Developer settings: force_dmabuf override (from Settings > Developer Mode)
let dev_force_dmabuf = match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() { let dev_force_dmabuf =
Ok(store) => match store.get_settings() { match qbz_nix_lib::config::developer_settings::DeveloperSettingsStore::new() {
Ok(settings) => settings.force_dmabuf, Ok(store) => match store.get_settings() {
Err(e) => { Ok(settings) => settings.force_dmabuf,
qbz_nix_lib::logging::log_startup(&format!( Err(e) => {
qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] Developer settings read failed ({}), force_dmabuf defaulting to false", "[QBZ] Developer settings read failed ({}), force_dmabuf defaulting to false",
e e
)); ));
false false
} }
}, },
Err(e) => { Err(e) => {
qbz_nix_lib::logging::log_startup(&format!( qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] Developer settings store unavailable ({}), force_dmabuf defaulting to false", "[QBZ] Developer settings store unavailable ({}), force_dmabuf defaulting to false",
e e
)); ));
false false
} }
}; };
if dev_force_dmabuf { if dev_force_dmabuf {
std::env::set_var("QBZ_FORCE_DMABUF", "1"); std::env::set_var("QBZ_FORCE_DMABUF", "1");
qbz_nix_lib::logging::log_startup("[QBZ] Developer override: force_dmabuf=true (from settings)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Developer override: force_dmabuf=true (from settings)",
);
qbz_nix_lib::logging::log_startup("[QBZ] To reset: run `qbz --reset-dmabuf`"); qbz_nix_lib::logging::log_startup("[QBZ] To reset: run `qbz --reset-dmabuf`");
} }
@@ -273,7 +284,9 @@ fn main() {
// Log graphics configuration summary (helps debug performance issues) // Log graphics configuration summary (helps debug performance issues)
if graphics_using_fallback { if graphics_using_fallback {
eprintln!("[QBZ] WARNING: Running with fallback graphics settings. Performance may be degraded."); eprintln!("[QBZ] WARNING: Running with fallback graphics settings. Performance may be degraded.");
eprintln!("[QBZ] To fix: run 'qbz --reset-graphics' or check ~/.local/share/qbz/settings.db"); eprintln!(
"[QBZ] To fix: run 'qbz --reset-graphics' or check ~/.local/share/qbz/settings.db"
);
} }
qbz_nix_lib::logging::log_startup(&format!( qbz_nix_lib::logging::log_startup(&format!(
"[QBZ] Graphics config: wayland={}, nvidia={}, force_x11={}, hw_accel={}, fallback={}", "[QBZ] Graphics config: wayland={}, nvidia={}, force_x11={}, hw_accel={}, fallback={}",
@@ -294,7 +307,9 @@ fn main() {
// LIBGL_ALWAYS_SOFTWARE=1 forces Mesa to use llvmpipe for all GL contexts. // LIBGL_ALWAYS_SOFTWARE=1 forces Mesa to use llvmpipe for all GL contexts.
// Only for VMs or explicit user request. // Only for VMs or explicit user request.
if force_software { if force_software {
qbz_nix_lib::logging::log_startup("[QBZ] User override: forcing software rendering (QBZ_SOFTWARE_RENDER=1)"); qbz_nix_lib::logging::log_startup(
"[QBZ] User override: forcing software rendering (QBZ_SOFTWARE_RENDER=1)",
);
std::env::set_var("LIBGL_ALWAYS_SOFTWARE", "1"); std::env::set_var("LIBGL_ALWAYS_SOFTWARE", "1");
} else if is_vm { } else if is_vm {
qbz_nix_lib::logging::log_startup("[QBZ] Virtual machine detected: enabling software rendering (LIBGL_ALWAYS_SOFTWARE=1)"); qbz_nix_lib::logging::log_startup("[QBZ] Virtual machine detected: enabling software rendering (LIBGL_ALWAYS_SOFTWARE=1)");
@@ -325,7 +340,13 @@ fn main() {
Ok("x11") if is_wayland => "X11 (XWayland)", Ok("x11") if is_wayland => "X11 (XWayland)",
Ok("x11") => "X11", Ok("x11") => "X11",
Ok("wayland") => "Wayland", Ok("wayland") => "Wayland",
_ => if is_wayland { "Wayland" } else { "X11" }, _ => {
if is_wayland {
"Wayland"
} else {
"X11"
}
}
}; };
qbz_nix_lib::logging::log_startup(&format!("[QBZ] Display server: {}", effective_display)); qbz_nix_lib::logging::log_startup(&format!("[QBZ] Display server: {}", effective_display));
@@ -347,8 +368,12 @@ fn main() {
// 4. Auto-detection (Wayland/NVIDIA) // 4. Auto-detection (Wayland/NVIDIA)
if !hardware_accel { if !hardware_accel {
// Nuclear opt-out: disable all GPU compositing and DMA-BUF // Nuclear opt-out: disable all GPU compositing and DMA-BUF
qbz_nix_lib::logging::log_startup("[QBZ] Hardware acceleration disabled: all GPU rendering off"); qbz_nix_lib::logging::log_startup(
qbz_nix_lib::logging::log_startup("[QBZ] To restore: unset QBZ_HARDWARE_ACCEL or set to 1"); "[QBZ] Hardware acceleration disabled: all GPU rendering off",
);
qbz_nix_lib::logging::log_startup(
"[QBZ] To restore: unset QBZ_HARDWARE_ACCEL or set to 1",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
} else if std::env::var("QBZ_HARDWARE_ACCEL").as_deref() == Ok("1") { } else if std::env::var("QBZ_HARDWARE_ACCEL").as_deref() == Ok("1") {
@@ -362,28 +387,38 @@ fn main() {
// Only applies to native Wayland (not force_x11/XWayland). // Only applies to native Wayland (not force_x11/XWayland).
if is_wayland && !force_x11 { if is_wayland && !force_x11 {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
qbz_nix_lib::logging::log_startup("[QBZ] Wayland: compositing mode disabled (prevents protocol errors)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Wayland: compositing mode disabled (prevents protocol errors)",
);
} }
// DMA-BUF renderer control // DMA-BUF renderer control
if force_dmabuf { if force_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: DMA-BUF renderer forced ON (QBZ_FORCE_DMABUF=1)"); qbz_nix_lib::logging::log_startup(
"[QBZ] User override: DMA-BUF renderer forced ON (QBZ_FORCE_DMABUF=1)",
);
} else if disable_dmabuf { } else if disable_dmabuf {
qbz_nix_lib::logging::log_startup("[QBZ] User override: DMA-BUF renderer forced OFF (QBZ_DISABLE_DMABUF=1)"); qbz_nix_lib::logging::log_startup(
"[QBZ] User override: DMA-BUF renderer forced OFF (QBZ_DISABLE_DMABUF=1)",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if is_wayland && !force_x11 { } else if is_wayland && !force_x11 {
// Wayland: disable DMA-BUF for ALL GPUs. Prevents: // Wayland: disable DMA-BUF for ALL GPUs. Prevents:
// - NVIDIA Error 71 (protocol error) // - NVIDIA Error 71 (protocol error)
// - Intel Arc EGL crash (Could not create default EGL display) // - Intel Arc EGL crash (Could not create default EGL display)
// This is an improvement over v1.1.9 which only covered NVIDIA. // This is an improvement over v1.1.9 which only covered NVIDIA.
qbz_nix_lib::logging::log_startup("[QBZ] Wayland: DMA-BUF renderer disabled (prevents EGL crashes)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Wayland: DMA-BUF renderer disabled (prevents EGL crashes)",
);
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else if has_nvidia { } else if has_nvidia {
// X11 + NVIDIA: disable DMA-BUF only (keeps full compositing) // X11 + NVIDIA: disable DMA-BUF only (keeps full compositing)
qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA on X11: DMA-BUF renderer disabled"); qbz_nix_lib::logging::log_startup("[QBZ] NVIDIA on X11: DMA-BUF renderer disabled");
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
} else { } else {
qbz_nix_lib::logging::log_startup("[QBZ] Using default WebKit renderer (full hardware acceleration)"); qbz_nix_lib::logging::log_startup(
"[QBZ] Using default WebKit renderer (full hardware acceleration)",
);
} }
} }
+14 -5
View File
@@ -5,14 +5,16 @@
//! - Media key support //! - Media key support
//! - Now playing notifications //! - Now playing notifications
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig, SeekDirection}; use serde::Serialize;
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig, SeekDirection,
};
use std::sync::{ use std::sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Mutex, Arc, Mutex,
}; };
use std::thread; use std::thread;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use serde::Serialize;
/// Track metadata for media controls /// Track metadata for media controls
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -76,7 +78,10 @@ impl MediaControlsManager {
} }
} }
Err(e) => { Err(e) => {
log::warn!("Failed to initialize media controls: {}. Media keys won't work.", e); log::warn!(
"Failed to initialize media controls: {}. Media keys won't work.",
e
);
} }
} }
}); });
@@ -90,7 +95,9 @@ impl MediaControlsManager {
title: Some(track.title.as_str()), title: Some(track.title.as_str()),
artist: Some(track.artist.as_str()), artist: Some(track.artist.as_str()),
album: Some(track.album.as_str()), album: Some(track.album.as_str()),
duration: track.duration_secs.map(|d| std::time::Duration::from_secs(d)), duration: track
.duration_secs
.map(|d| std::time::Duration::from_secs(d)),
cover_url: track.cover_url.as_deref(), cover_url: track.cover_url.as_deref(),
}; };
@@ -122,7 +129,9 @@ impl MediaControlsManager {
pub fn set_playback_with_progress(&self, playing: bool, position_secs: u64) { pub fn set_playback_with_progress(&self, playing: bool, position_secs: u64) {
if let Ok(mut guard) = self.controls.lock() { if let Ok(mut guard) = self.controls.lock() {
if let Some(controls) = guard.as_mut() { if let Some(controls) = guard.as_mut() {
let progress = Some(souvlaki::MediaPosition(std::time::Duration::from_secs(position_secs))); let progress = Some(souvlaki::MediaPosition(std::time::Duration::from_secs(
position_secs,
)));
let playback = if playing { let playback = if playing {
MediaPlayback::Playing { progress } MediaPlayback::Playing { progress }
} else { } else {
+17 -15
View File
@@ -37,18 +37,10 @@ const DATA_DIR_SUBDIR_FILES: &[(&str, &str)] = &[
]; ];
/// Cache directories to move (relative to ~/.cache/qbz/) /// Cache directories to move (relative to ~/.cache/qbz/)
const CACHE_DIRS: &[&str] = &[ const CACHE_DIRS: &[&str] = &["audio", "lyrics", "playback", "artwork", "tmp"];
"audio",
"lyrics",
"playback",
"artwork",
"tmp",
];
/// Data directories to move (relative to ~/.local/share/qbz/) /// Data directories to move (relative to ~/.local/share/qbz/)
const DATA_DIRS: &[&str] = &[ const DATA_DIRS: &[&str] = &["thumbnails"];
"thumbnails",
];
/// Check if migration has already been performed /// Check if migration has already been performed
pub fn is_migrated(global_data_dir: &Path) -> bool { pub fn is_migrated(global_data_dir: &Path) -> bool {
@@ -98,12 +90,18 @@ pub fn migrate_flat_to_user(user_id: u64) -> Result<(), String> {
// 3. Move data directories (thumbnails) // 3. Move data directories (thumbnails)
for dir_name in DATA_DIRS { for dir_name in DATA_DIRS {
move_directory(&global_data_dir.join(dir_name), &user_data_dir.join(dir_name)); move_directory(
&global_data_dir.join(dir_name),
&user_data_dir.join(dir_name),
);
} }
// 4. Move cache directories // 4. Move cache directories
for dir_name in CACHE_DIRS { for dir_name in CACHE_DIRS {
move_directory(&global_cache_dir.join(dir_name), &user_cache_dir.join(dir_name)); move_directory(
&global_cache_dir.join(dir_name),
&user_cache_dir.join(dir_name),
);
} }
// 5. Write marker file // 5. Write marker file
@@ -153,7 +151,11 @@ fn move_directory(src: &Path, dst: &Path) {
match std::fs::rename(src, dst) { match std::fs::rename(src, dst) {
Ok(()) => log::debug!("Moved dir {} -> {}", src.display(), dst.display()), Ok(()) => log::debug!("Moved dir {} -> {}", src.display(), dst.display()),
Err(e) => { Err(e) => {
log::warn!("Rename dir failed for {}, trying recursive copy: {}", src.display(), e); log::warn!(
"Rename dir failed for {}, trying recursive copy: {}",
src.display(),
e
);
if let Err(copy_err) = copy_dir_recursive(src, dst) { if let Err(copy_err) = copy_dir_recursive(src, dst) {
log::error!("Failed to migrate dir {}: {}", src.display(), copy_err); log::error!("Failed to migrate dir {}: {}", src.display(), copy_err);
} else { } else {
@@ -178,8 +180,8 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::create_dir_all(dst) std::fs::create_dir_all(dst)
.map_err(|e| format!("Create dir {} failed: {}", dst.display(), e))?; .map_err(|e| format!("Create dir {} failed: {}", dst.display(), e))?;
let entries = std::fs::read_dir(src) let entries =
.map_err(|e| format!("Read dir {} failed: {}", src.display(), e))?; std::fs::read_dir(src).map_err(|e| format!("Read dir {} failed: {}", src.display(), e))?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| format!("Read entry in {} failed: {}", src.display(), e))?; let entry = entry.map_err(|e| format!("Read entry in {} failed: {}", src.display(), e))?;
+17 -8
View File
@@ -141,7 +141,10 @@ impl MusicBrainzClient {
} }
/// Search recordings by ISRC /// Search recordings by ISRC
pub async fn search_recording_by_isrc(&self, isrc: &str) -> Result<RecordingSearchResponse, String> { pub async fn search_recording_by_isrc(
&self,
isrc: &str,
) -> Result<RecordingSearchResponse, 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());
} }
@@ -258,7 +261,10 @@ impl MusicBrainzClient {
} }
/// Get artist details with relationships /// Get artist details with relationships
pub async fn get_artist_with_relations(&self, mbid: &str) -> Result<ArtistFullResponse, String> { pub async fn get_artist_with_relations(
&self,
mbid: &str,
) -> Result<ArtistFullResponse, 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());
} }
@@ -266,10 +272,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!("{}/artist/{}?inc=artist-rels&fmt=json", base_url, mbid);
"{}/artist/{}?inc=artist-rels&fmt=json",
base_url, mbid
);
log::debug!("MusicBrainz artist lookup with relations: {}", mbid); log::debug!("MusicBrainz artist lookup with relations: {}", mbid);
@@ -293,7 +296,10 @@ impl MusicBrainzClient {
} }
/// Search releases by barcode (UPC/EAN) /// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode(&self, barcode: &str) -> Result<ReleaseSearchResponse, String> { pub async fn search_release_by_barcode(
&self,
barcode: &str,
) -> Result<ReleaseSearchResponse, 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());
} }
@@ -408,7 +414,10 @@ impl MusicBrainzClient {
/// Get full release details including tracks /// Get full release details including tracks
/// Fetches media, recordings, artist credits, labels, and tags /// Fetches media, recordings, artist credits, labels, and tags
pub async fn get_release_with_tracks(&self, release_id: &str) -> Result<ReleaseFullResponse, String> { pub async fn get_release_with_tracks(
&self,
release_id: &str,
) -> Result<ReleaseFullResponse, 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());
} }
+22 -13
View File
@@ -169,10 +169,9 @@ fn classify_fs_type(fs_type: &str, source: &str) -> MountKind {
// Virtual/pseudo filesystems // Virtual/pseudo filesystems
match fs_lower.as_str() { match fs_lower.as_str() {
"proc" | "sysfs" | "devtmpfs" | "devpts" | "tmpfs" | "ramfs" "proc" | "sysfs" | "devtmpfs" | "devpts" | "tmpfs" | "ramfs" | "securityfs" | "debugfs"
| "securityfs" | "debugfs" | "tracefs" | "configfs" | "cgroup" | "tracefs" | "configfs" | "cgroup" | "cgroup2" | "pstore" | "efivarfs" | "bpf"
| "cgroup2" | "pstore" | "efivarfs" | "bpf" | "autofs" | "autofs" | "mqueue" | "hugetlbfs" | "fusectl" | "overlay" | "squashfs" => {
| "mqueue" | "hugetlbfs" | "fusectl" | "overlay" | "squashfs" => {
return MountKind::Virtual; return MountKind::Virtual;
} }
_ => {} _ => {}
@@ -180,9 +179,8 @@ fn classify_fs_type(fs_type: &str, source: &str) -> MountKind {
// Local filesystems // Local filesystems
match fs_lower.as_str() { match fs_lower.as_str() {
"ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "zfs" "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "zfs" | "ntfs" | "ntfs3" | "vfat"
| "ntfs" | "ntfs3" | "vfat" | "fat32" | "exfat" | "hfs" | "fat32" | "exfat" | "hfs" | "hfsplus" | "apfs" | "f2fs" | "jfs" | "reiserfs" | "udf"
| "hfsplus" | "apfs" | "f2fs" | "jfs" | "reiserfs" | "udf"
| "iso9660" | "ufs" => { | "iso9660" | "ufs" => {
return MountKind::Local; return MountKind::Local;
} }
@@ -235,8 +233,7 @@ fn parse_mount_line(line: &str) -> Option<MountInfo> {
let kind = classify_fs_type(&fs_type, &source); let kind = classify_fs_type(&fs_type, &source);
// Check accessibility with a quick stat // Check accessibility with a quick stat
let accessible = Path::new(&mount_point).exists() let accessible = Path::new(&mount_point).exists() && std::fs::metadata(&mount_point).is_ok();
&& std::fs::metadata(&mount_point).is_ok();
Some(MountInfo { Some(MountInfo {
fs_type, fs_type,
@@ -427,10 +424,22 @@ mod tests {
#[test] #[test]
fn test_classify_fs_type() { fn test_classify_fs_type() {
assert!(matches!(classify_fs_type("ext4", ""), MountKind::Local)); assert!(matches!(classify_fs_type("ext4", ""), MountKind::Local));
assert!(matches!(classify_fs_type("cifs", "//server/share"), MountKind::Network(NetworkFs::Cifs))); assert!(matches!(
assert!(matches!(classify_fs_type("nfs", "server:/export"), MountKind::Network(NetworkFs::Nfs))); classify_fs_type("cifs", "//server/share"),
assert!(matches!(classify_fs_type("tmpfs", "tmpfs"), MountKind::Virtual)); MountKind::Network(NetworkFs::Cifs)
assert!(matches!(classify_fs_type("fuse.sshfs", "user@host:/path"), MountKind::Network(NetworkFs::Sshfs))); ));
assert!(matches!(
classify_fs_type("nfs", "server:/export"),
MountKind::Network(NetworkFs::Nfs)
));
assert!(matches!(
classify_fs_type("tmpfs", "tmpfs"),
MountKind::Virtual
));
assert!(matches!(
classify_fs_type("fuse.sshfs", "user@host:/path"),
MountKind::Network(NetworkFs::Sshfs)
));
} }
#[test] #[test]
+177 -86
View File
@@ -6,12 +6,12 @@ use tauri::{AppHandle, Emitter, State};
use crate::api::models::Quality; use crate::api::models::Quality;
use crate::AppState; use crate::AppState;
use crate::offline_cache::OfflineCacheState; use super::{CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, TrackCacheInfo};
use crate::offline_cache::metadata::{fetch_complete_metadata, write_flac_tags, embed_artwork, organize_cached_file, save_album_artwork}; use crate::offline_cache::metadata::{
use super::{ embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
CachedTrackInfo, OfflineCacheStats, OfflineCacheStatus, write_flac_tags,
TrackCacheInfo,
}; };
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
async fn post_process_cached_track( async fn post_process_cached_track(
@@ -25,21 +25,20 @@ async fn post_process_cached_track(
// 1. Fetch complete metadata from Qobuz // 1. Fetch complete metadata from Qobuz
let metadata = fetch_complete_metadata(track_id, qobuz_client).await?; let metadata = fetch_complete_metadata(track_id, qobuz_client).await?;
// 2. Write FLAC tags // 2. Write FLAC tags
write_flac_tags(current_path, &metadata) write_flac_tags(current_path, &metadata).map_err(|e| format!("Failed to write tags: {}", e))?;
.map_err(|e| format!("Failed to write tags: {}", e))?;
// 3. Embed artwork if available // 3. Embed artwork if available
if let Some(artwork_url) = &metadata.artwork_url { if let Some(artwork_url) = &metadata.artwork_url {
if let Err(e) = embed_artwork(current_path, artwork_url).await { if let Err(e) = embed_artwork(current_path, artwork_url).await {
log::warn!("Failed to embed artwork for track {}: {}", track_id, e); log::warn!("Failed to embed artwork for track {}: {}", track_id, e);
} }
} }
// 4. Organize file into artist/album structure // 4. Organize file into artist/album structure
let new_path = organize_cached_file(track_id, current_path, offline_root, &metadata)?; let new_path = organize_cached_file(track_id, current_path, offline_root, &metadata)?;
// 5. Download and save album cover art as cover.jpg // 5. Download and save album cover art as cover.jpg
let artwork_path = if let Some(artwork_url) = &metadata.artwork_url { let artwork_path = if let Some(artwork_url) = &metadata.artwork_url {
// Extract album directory from the new_path // Extract album directory from the new_path
@@ -49,7 +48,7 @@ async fn post_process_cached_track(
let cover_path = parent_dir.join("cover.jpg").to_string_lossy().to_string(); let cover_path = parent_dir.join("cover.jpg").to_string_lossy().to_string();
log::info!("Album artwork saved for track {}: {}", track_id, cover_path); log::info!("Album artwork saved for track {}: {}", track_id, cover_path);
Some(cover_path) Some(cover_path)
}, }
Err(e) => { Err(e) => {
log::warn!("Failed to save album artwork for track {}: {}", track_id, e); log::warn!("Failed to save album artwork for track {}: {}", track_id, e);
None None
@@ -62,7 +61,7 @@ async fn post_process_cached_track(
log::warn!("No artwork URL available for track {}", track_id); log::warn!("No artwork URL available for track {}", track_id);
None None
}; };
// 6. Extract audio properties from FLAC file // 6. Extract audio properties from FLAC file
use lofty::AudioFile; use lofty::AudioFile;
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) { let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
@@ -73,19 +72,25 @@ async fn post_process_cached_track(
(bit_depth, sample_rate) (bit_depth, sample_rate)
} }
Err(e) => { Err(e) => {
log::warn!("Failed to read audio properties for track {}: {}", track_id, e); log::warn!(
"Failed to read audio properties for track {}: {}",
track_id,
e
);
(None, None) (None, None)
} }
}; };
// 7. ALWAYS insert into local library DB (visibility controlled by toggle) // 7. ALWAYS insert into local library DB (visibility controlled by toggle)
let lib_opt__ = library_db.lock().await; let lib_opt__ = library_db.lock().await;
let lib_guard = lib_opt__.as_ref().ok_or("No active session - please log in")?; let lib_guard = lib_opt__
.as_ref()
.ok_or("No active session - please log in")?;
// Generate album_group_key: album|album_artist // Generate album_group_key: album|album_artist
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist); let album_group_key = format!("{}|{}", metadata.album, album_artist);
match lib_guard.insert_qobuz_cached_track_with_grouping( match lib_guard.insert_qobuz_cached_track_with_grouping(
track_id, track_id,
&metadata.title, &metadata.title,
@@ -103,10 +108,15 @@ async fn post_process_cached_track(
sample_rate, sample_rate,
artwork_path.as_deref(), artwork_path.as_deref(),
) { ) {
Ok(_) => log::info!("Track {} inserted to local library DB with group key: {}, artwork: {:?}", track_id, album_group_key, artwork_path), Ok(_) => log::info!(
"Track {} inserted to local library DB with group key: {}, artwork: {:?}",
track_id,
album_group_key,
artwork_path
),
Err(e) => log::error!("Failed to insert track {} to library DB: {}", track_id, e), Err(e) => log::error!("Failed to insert track {} to library DB: {}", track_id, e),
} }
log::info!("Track {} organized to: {}", track_id, new_path); log::info!("Track {} organized to: {}", track_id, new_path);
Ok(new_path) Ok(new_path)
} }
@@ -128,7 +138,12 @@ pub async fn cache_track_for_offline(
library_state: State<'_, crate::library::commands::LibraryState>, library_state: State<'_, crate::library::commands::LibraryState>,
app_handle: AppHandle, app_handle: AppHandle,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("Command: cache_track_for_offline {} - {} by {}", track_id, title, artist); log::info!(
"Command: cache_track_for_offline {} - {} by {}",
track_id,
title,
artist
);
let track_info = TrackCacheInfo { let track_info = TrackCacheInfo {
track_id, track_id,
@@ -149,7 +164,9 @@ pub async fn cache_track_for_offline(
// Insert into database as queued // Insert into database as queued
{ {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.insert_track(&track_info, &file_path_str)?; db.insert_track(&track_info, &file_path_str)?;
} }
@@ -167,7 +184,11 @@ pub async fn cache_track_for_offline(
let _permit = match semaphore.acquire_owned().await { let _permit = match semaphore.acquire_owned().await {
Ok(permit) => permit, Ok(permit) => permit,
Err(err) => { Err(err) => {
log::error!("Failed to acquire cache slot for track {}: {}", track_id, err); log::error!(
"Failed to acquire cache slot for track {}: {}",
track_id,
err
);
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( let _ = db_guard.update_status(
track_id, track_id,
@@ -175,10 +196,13 @@ pub async fn cache_track_for_offline(
Some("Failed to start caching"), Some("Failed to start caching"),
); );
} }
let _ = app.emit("offline:caching_failed", serde_json::json!({ let _ = app.emit(
"trackId": track_id, "offline:caching_failed",
"error": "Failed to acquire cache slot" serde_json::json!({
})); "trackId": track_id,
"error": "Failed to acquire cache slot"
}),
);
return; return;
} }
}; };
@@ -190,9 +214,12 @@ pub async fn cache_track_for_offline(
} }
} }
let _ = app.emit("offline:caching_started", serde_json::json!({ let _ = app.emit(
"trackId": track_id "offline:caching_started",
})); serde_json::json!({
"trackId": track_id
}),
);
// Get stream URL with highest quality available // Get stream URL with highest quality available
let stream_url = { let stream_url = {
@@ -213,10 +240,13 @@ pub async fn cache_track_for_offline(
Some(&format!("Failed to get stream URL: {}", e)), Some(&format!("Failed to get stream URL: {}", e)),
); );
} }
let _ = app.emit("offline:caching_failed", serde_json::json!({ let _ = app.emit(
"trackId": track_id, "offline:caching_failed",
"error": e.to_string() serde_json::json!({
})); "trackId": track_id,
"error": e.to_string()
}),
);
return; return;
} }
}; };
@@ -234,10 +264,13 @@ pub async fn cache_track_for_offline(
} }
} }
let _ = app.emit("offline:caching_completed", serde_json::json!({ let _ = app.emit(
"trackId": track_id, "offline:caching_completed",
"size": size serde_json::json!({
})); "trackId": track_id,
"size": size
}),
);
// Post-processing: metadata, tagging, artwork, organization // Post-processing: metadata, tagging, artwork, organization
log::info!("Starting post-processing for cached track {}", track_id); log::info!("Starting post-processing for cached track {}", track_id);
@@ -250,7 +283,9 @@ pub async fn cache_track_for_offline(
&offline_root, &offline_root,
&*qobuz_client, &*qobuz_client,
library_db.clone(), library_db.clone(),
).await { )
.await
{
Ok(new_path) => { Ok(new_path) => {
// Update database with new path // Update database with new path
if let Some(db_guard) = db.lock().await.as_ref() { if let Some(db_guard) = db.lock().await.as_ref() {
@@ -259,13 +294,20 @@ pub async fn cache_track_for_offline(
} }
} }
let _ = app.emit("offline:caching_processed", serde_json::json!({ let _ = app.emit(
"trackId": track_id, "offline:caching_processed",
"path": new_path serde_json::json!({
})); "trackId": track_id,
"path": new_path
}),
);
} }
Err(e) => { Err(e) => {
log::error!("Post-processing failed for cached track {}: {}", track_id, e); log::error!(
"Post-processing failed for cached track {}: {}",
track_id,
e
);
// File still exists and is playable, just not organized // File still exists and is playable, just not organized
} }
} }
@@ -275,10 +317,13 @@ pub async fn cache_track_for_offline(
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(&e)); let _ = db_guard.update_status(track_id, OfflineCacheStatus::Failed, Some(&e));
} }
let _ = app.emit("offline:caching_failed", serde_json::json!({ let _ = app.emit(
"trackId": track_id, "offline:caching_failed",
"error": e serde_json::json!({
})); "trackId": track_id,
"error": e
}),
);
} }
} }
}); });
@@ -293,7 +338,9 @@ pub async fn is_track_cached(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.is_cached(track_id) db.is_cached(track_id)
} }
@@ -304,7 +351,9 @@ pub async fn get_cached_track_path(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_file_path(track_id) db.get_file_path(track_id)
} }
@@ -315,7 +364,9 @@ pub async fn get_cached_track(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
) -> Result<Option<CachedTrackInfo>, String> { ) -> Result<Option<CachedTrackInfo>, String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_track(track_id) db.get_track(track_id)
} }
@@ -325,7 +376,9 @@ pub async fn get_cached_tracks(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
) -> Result<Vec<CachedTrackInfo>, String> { ) -> Result<Vec<CachedTrackInfo>, String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_all_tracks() db.get_all_tracks()
} }
@@ -336,7 +389,9 @@ pub async fn get_offline_cache_stats(
) -> Result<OfflineCacheStats, String> { ) -> Result<OfflineCacheStats, String> {
let limit = *cache_state.limit_bytes.lock().await; let limit = *cache_state.limit_bytes.lock().await;
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_stats(&cache_state.get_cache_path(), limit) db.get_stats(&cache_state.get_cache_path(), limit)
} }
@@ -351,15 +406,16 @@ pub async fn remove_cached_track(
{ {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get file path and delete from DB // Get file path and delete from DB
if let Some(file_path) = db.delete_track(track_id)? { if let Some(file_path) = db.delete_track(track_id)? {
// Delete the actual file // Delete the actual file
let path = std::path::Path::new(&file_path); let path = std::path::Path::new(&file_path);
if path.exists() { if path.exists() {
std::fs::remove_file(path) std::fs::remove_file(path).map_err(|e| format!("Failed to delete file: {}", e))?;
.map_err(|e| format!("Failed to delete file: {}", e))?;
} }
// Clean up empty album folder (and artist folder if also empty) // Clean up empty album folder (and artist folder if also empty)
@@ -371,7 +427,9 @@ pub async fn remove_cached_track(
// Also remove from library if it was added // Also remove from library if it was added
let guard__ = library_state.db.lock().await; let guard__ = library_state.db.lock().await;
let library_db = guard__.as_ref().ok_or("No active session - please log in")?; let library_db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let _ = library_db.remove_qobuz_cached_track(track_id); let _ = library_db.remove_qobuz_cached_track(track_id);
Ok(()) Ok(())
@@ -401,7 +459,8 @@ fn cleanup_empty_folder(folder: &std::path::Path, cache_root: &std::path::Path)
// If folder only contains non-audio files (like cover.jpg), delete the whole folder // If folder only contains non-audio files (like cover.jpg), delete the whole folder
let has_audio_files = entries.iter().any(|e| { let has_audio_files = entries.iter().any(|e| {
e.path().extension() e.path()
.extension()
.map(|ext| ext == "flac" || ext == "mp3" || ext == "wav" || ext == "m4a") .map(|ext| ext == "flac" || ext == "mp3" || ext == "wav" || ext == "m4a")
.unwrap_or(false) .unwrap_or(false)
}); });
@@ -431,7 +490,9 @@ pub async fn purge_all_cached_files(
) -> Result<(), String> { ) -> Result<(), String> {
let paths = { let paths = {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.clear_all()? db.clear_all()?
}; };
@@ -476,7 +537,9 @@ pub async fn purge_all_cached_files(
// Remove all Qobuz cached tracks from library // Remove all Qobuz cached tracks from library
let guard__ = library_state.db.lock().await; let guard__ = library_state.db.lock().await;
let library_db = guard__.as_ref().ok_or("No active session - please log in")?; let library_db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let removed_count = library_db let removed_count = library_db
.remove_all_qobuz_cached_tracks() .remove_all_qobuz_cached_tracks()
.map_err(|e| format!("Failed to remove cached tracks from library: {}", e))?; .map_err(|e| format!("Failed to remove cached tracks from library: {}", e))?;
@@ -534,7 +597,9 @@ pub async fn open_album_folder(
log::info!("Command: open_album_folder for album_id: {}", album_id); log::info!("Command: open_album_folder for album_id: {}", album_id);
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get tracks for this album // Get tracks for this album
let tracks = db.get_all_tracks()?; let tracks = db.get_all_tracks()?;
@@ -549,9 +614,10 @@ pub async fn open_album_folder(
// Get the first track's path and extract the album directory // Get the first track's path and extract the album directory
let first_track_id = album_tracks[0].track_id; let first_track_id = album_tracks[0].track_id;
let file_path = db.get_file_path(first_track_id)? let file_path = db
.get_file_path(first_track_id)?
.ok_or_else(|| "Track file path not found".to_string())?; .ok_or_else(|| "Track file path not found".to_string())?;
let track_path = std::path::Path::new(&file_path); let track_path = std::path::Path::new(&file_path);
let album_dir = track_path let album_dir = track_path
.parent() .parent()
@@ -576,10 +642,13 @@ pub async fn open_track_folder(
log::info!("Command: open_track_folder for track_id: {}", track_id); log::info!("Command: open_track_folder for track_id: {}", track_id);
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get the track's file path // Get the track's file path
let file_path = db.get_file_path(track_id)? let file_path = db
.get_file_path(track_id)?
.ok_or_else(|| "Track file path not found - track may not be cached".to_string())?; .ok_or_else(|| "Track file path not found - track may not be cached".to_string())?;
let track_path = std::path::Path::new(&file_path); let track_path = std::path::Path::new(&file_path);
@@ -604,7 +673,9 @@ pub async fn check_album_fully_cached(
cache_state: State<'_, OfflineCacheState>, cache_state: State<'_, OfflineCacheState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get all tracks for this album // Get all tracks for this album
let tracks = db.get_all_tracks()?; let tracks = db.get_all_tracks()?;
@@ -628,12 +699,11 @@ pub async fn check_album_fully_cached(
} }
/// Evict tracks if cache exceeds limit (LRU policy) /// Evict tracks if cache exceeds limit (LRU policy)
async fn evict_if_needed( async fn evict_if_needed(cache_state: &OfflineCacheState, limit_bytes: u64) -> Result<(), String> {
cache_state: &OfflineCacheState,
limit_bytes: u64,
) -> Result<(), String> {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let stats = db.get_stats(&cache_state.get_cache_path(), Some(limit_bytes))?; let stats = db.get_stats(&cache_state.get_cache_path(), Some(limit_bytes))?;
if stats.total_size_bytes <= limit_bytes { if stats.total_size_bytes <= limit_bytes {
@@ -679,12 +749,19 @@ pub async fn check_offline_root_mounted(
) -> Result<bool, String> { ) -> Result<bool, String> {
log::info!("Command: check_offline_root_mounted"); log::info!("Command: check_offline_root_mounted");
let root_path = cache_state.cache_dir.read().unwrap().to_string_lossy().to_string(); let root_path = cache_state
.cache_dir
.read()
.unwrap()
.to_string_lossy()
.to_string();
super::path_validator::is_offline_root_available(&root_path) super::path_validator::is_offline_root_available(&root_path)
} }
#[tauri::command] #[tauri::command]
pub async fn validate_offline_path(path: String) -> Result<super::path_validator::PathValidationResult, String> { pub async fn validate_offline_path(
path: String,
) -> Result<super::path_validator::PathValidationResult, String> {
log::info!("Command: validate_offline_path: {}", path); log::info!("Command: validate_offline_path: {}", path);
super::path_validator::validate_path(&path) super::path_validator::validate_path(&path)
} }
@@ -696,7 +773,12 @@ pub async fn move_offline_cache_to_path(
) -> Result<super::path_validator::MoveReport, String> { ) -> Result<super::path_validator::MoveReport, String> {
log::info!("Command: move_offline_cache_to_path: {}", new_path); log::info!("Command: move_offline_cache_to_path: {}", new_path);
let old_path = cache_state.cache_dir.read().unwrap().to_string_lossy().to_string(); let old_path = cache_state
.cache_dir
.read()
.unwrap()
.to_string_lossy()
.to_string();
super::path_validator::move_cached_files_to_new_path(&old_path, &new_path) super::path_validator::move_cached_files_to_new_path(&old_path, &new_path)
} }
@@ -710,13 +792,11 @@ pub async fn detect_legacy_cached_files(
let tracks_dir = cache_state.cache_dir.read().unwrap().join("tracks"); let tracks_dir = cache_state.cache_dir.read().unwrap().join("tracks");
match super::detect_legacy_cached_files(&tracks_dir) { match super::detect_legacy_cached_files(&tracks_dir) {
Ok(track_ids) => { Ok(track_ids) => Ok(super::MigrationStatus {
Ok(super::MigrationStatus { has_legacy_files: !track_ids.is_empty(),
has_legacy_files: !track_ids.is_empty(), total_tracks: track_ids.len(),
total_tracks: track_ids.len(), ..Default::default()
..Default::default() }),
})
}
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
@@ -751,7 +831,8 @@ pub async fn start_legacy_migration(
offline_root, offline_root,
qobuz_client, qobuz_client,
library_db, library_db,
).await; )
.await;
let _ = app_complete.emit("migration:complete", status); let _ = app_complete.emit("migration:complete", status);
}); });
@@ -771,7 +852,9 @@ pub async fn sync_offline_cache_to_library(
// Get ready tracks from cache (scoped to drop lock before library access) // Get ready tracks from cache (scoped to drop lock before library access)
let ready_tracks = { let ready_tracks = {
let guard__ = cache_state.db.lock().await; let guard__ = cache_state.db.lock().await;
let cache_db = guard__.as_ref().ok_or("No active session - please log in")?; let cache_db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
cache_db.get_ready_tracks_for_sync()? cache_db.get_ready_tracks_for_sync()?
}; };
@@ -782,7 +865,9 @@ pub async fn sync_offline_cache_to_library(
// Process tracks with library lock (scoped) // Process tracks with library lock (scoped)
{ {
let guard__ = library_state.db.lock().await; let guard__ = library_state.db.lock().await;
let library_db = guard__.as_ref().ok_or("No active session - please log in")?; let library_db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
for track in ready_tracks { for track in ready_tracks {
// Check if track already exists in library // Check if track already exists in library
@@ -803,7 +888,11 @@ pub async fn sync_offline_cache_to_library(
track.sample_rate, track.sample_rate,
) { ) {
Ok(_) => { Ok(_) => {
log::info!("Synced track {} to library: {}", track.track_id, track.title); log::info!(
"Synced track {} to library: {}",
track.track_id,
track.title
);
synced += 1; synced += 1;
} }
Err(e) => { Err(e) => {
@@ -822,7 +911,9 @@ pub async fn sync_offline_cache_to_library(
log::info!( log::info!(
"Sync complete: {} synced, {} already present, {} errors", "Sync complete: {} synced, {} already present, {} errors",
synced, already_present, errors synced,
already_present,
errors
); );
Ok(SyncResult { Ok(SyncResult {
+5 -2
View File
@@ -93,7 +93,9 @@ impl OfflineCacheDb {
/// Insert multiple tracks in a single transaction (batch queuing) /// Insert multiple tracks in a single transaction (batch queuing)
pub fn insert_tracks_batch(&self, tracks: &[(&TrackCacheInfo, String)]) -> Result<(), String> { pub fn insert_tracks_batch(&self, tracks: &[(&TrackCacheInfo, String)]) -> Result<(), String> {
let tx = self.conn.unchecked_transaction() let tx = self
.conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin transaction: {}", e))?; .map_err(|e| format!("Failed to begin transaction: {}", e))?;
{ {
@@ -115,7 +117,8 @@ impl OfflineCacheDb {
info.quality, info.quality,
info.bit_depth.map(|v| v as i64), info.bit_depth.map(|v| v as i64),
info.sample_rate, info.sample_rate,
]).map_err(|e| format!("Failed to insert track {}: {}", info.track_id, e))?; ])
.map_err(|e| format!("Failed to insert track {}: {}", info.track_id, e))?;
} }
} }
+5 -7
View File
@@ -91,7 +91,10 @@ impl StreamFetcher {
// Emit progress event every 2% change AND at least 200ms apart (always emit 100%) // Emit progress event every 2% change AND at least 200ms apart (always emit 100%)
let elapsed = last_emit_time.elapsed(); let elapsed = last_emit_time.elapsed();
if progress != last_progress && (progress - last_progress >= 2 || progress == 100) && (elapsed >= MIN_EMIT_INTERVAL || progress == 100) { if progress != last_progress
&& (progress - last_progress >= 2 || progress == 100)
&& (elapsed >= MIN_EMIT_INTERVAL || progress == 100)
{
last_progress = progress; last_progress = progress;
last_emit_time = Instant::now(); last_emit_time = Instant::now();
@@ -127,11 +130,7 @@ impl StreamFetcher {
std::fs::rename(&temp_path, dest_path) std::fs::rename(&temp_path, dest_path)
.map_err(|e| format!("Failed to move temp file: {}", e))?; .map_err(|e| format!("Failed to move temp file: {}", e))?;
log::info!( log::info!("Caching complete for track {}: {} bytes", track_id, cached);
"Caching complete for track {}: {} bytes",
track_id,
cached
);
Ok(cached) Ok(cached)
} }
@@ -164,4 +163,3 @@ impl Default for StreamFetcher {
Self::new() Self::new()
} }
} }
+26 -34
View File
@@ -2,10 +2,8 @@
//! //!
//! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags. //! Handles complete metadata retrieval from Qobuz API and writing to FLAC tags.
use lofty::{Accessor, AudioFile, ItemKey, Picture, PictureType, Tag, TagExt, TaggedFileExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use lofty::{
Accessor, AudioFile, ItemKey, Picture, PictureType, Tag, TagExt, TaggedFileExt,
};
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -40,10 +38,7 @@ pub async fn fetch_complete_metadata(
.map_err(|e| format!("Failed to fetch track: {}", e))?; .map_err(|e| format!("Failed to fetch track: {}", e))?;
let album = if let Some(album_obj) = &track.album { let album = if let Some(album_obj) = &track.album {
qobuz_client qobuz_client.get_album(&album_obj.id).await.ok()
.get_album(&album_obj.id)
.await
.ok()
} else { } else {
None None
}; };
@@ -68,23 +63,30 @@ pub async fn fetch_complete_metadata(
.and_then(|a| a.release_date_original.as_ref()) .and_then(|a| a.release_date_original.as_ref())
.and_then(|date_str| { .and_then(|date_str| {
// Parse YYYY-MM-DD or YYYY format // Parse YYYY-MM-DD or YYYY format
date_str.split('-').next() date_str
.split('-')
.next()
.and_then(|year_str| year_str.parse::<u32>().ok()) .and_then(|year_str| year_str.parse::<u32>().ok())
}); });
let artwork_url = album let artwork_url = album
.as_ref() .as_ref()
.and_then(|a| a.image.large.clone()) .and_then(|a| a.image.large.clone())
.or_else(|| { .or_else(|| track.album.as_ref().and_then(|a| a.image.large.clone()));
track.album.as_ref()
.and_then(|a| a.image.large.clone())
});
Ok(CompleteTrackMetadata { Ok(CompleteTrackMetadata {
track_id, track_id,
title: track.title, title: track.title,
artist: track.performer.as_ref().map(|p| p.name.clone()).unwrap_or_default(), artist: track
album: track.album.as_ref().map(|a| a.title.clone()).unwrap_or_default(), .performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default(),
album_artist, album_artist,
track_number: Some(track.track_number), track_number: Some(track.track_number),
disc_number: track.media_number, disc_number: track.media_number,
@@ -100,15 +102,12 @@ pub async fn fetch_complete_metadata(
} }
/// Write metadata tags to a FLAC file /// Write metadata tags to a FLAC file
pub fn write_flac_tags( pub fn write_flac_tags(file_path: &str, metadata: &CompleteTrackMetadata) -> Result<(), String> {
file_path: &str,
metadata: &CompleteTrackMetadata,
) -> Result<(), String> {
log::info!("Writing FLAC tags to: {}", file_path); log::info!("Writing FLAC tags to: {}", file_path);
let path = Path::new(file_path); let path = Path::new(file_path);
let mut tagged_file = lofty::read_from_path(path) let mut tagged_file =
.map_err(|e| format!("Failed to read FLAC file: {}", e))?; lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
let tag = match tagged_file.primary_tag_mut() { let tag = match tagged_file.primary_tag_mut() {
Some(primary_tag) => primary_tag, Some(primary_tag) => primary_tag,
@@ -172,10 +171,7 @@ pub fn write_flac_tags(
} }
/// Download and embed artwork in FLAC file /// Download and embed artwork in FLAC file
pub async fn embed_artwork( pub async fn embed_artwork(file_path: &str, artwork_url: &str) -> Result<(), String> {
file_path: &str,
artwork_url: &str,
) -> Result<(), String> {
log::info!("Embedding artwork from: {}", artwork_url); log::info!("Embedding artwork from: {}", artwork_url);
// Download artwork // Download artwork
@@ -207,8 +203,8 @@ pub async fn embed_artwork(
// Read file // Read file
let path = Path::new(file_path); let path = Path::new(file_path);
let mut tagged_file = lofty::read_from_path(path) let mut tagged_file =
.map_err(|e| format!("Failed to read FLAC file: {}", e))?; lofty::read_from_path(path).map_err(|e| format!("Failed to read FLAC file: {}", e))?;
// Add picture to primary tag // Add picture to primary tag
if let Some(tag) = tagged_file.primary_tag_mut() { if let Some(tag) = tagged_file.primary_tag_mut() {
@@ -232,7 +228,7 @@ pub async fn embed_artwork(
pub fn sanitize_filename(name: &str) -> String { pub fn sanitize_filename(name: &str) -> String {
// Remove or replace invalid characters // Remove or replace invalid characters
let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']; let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
let mut sanitized = name let mut sanitized = name
.chars() .chars()
.map(|c| { .map(|c| {
@@ -269,14 +265,11 @@ pub fn sanitize_filename(name: &str) -> String {
} }
/// Download and save album cover art as a file /// Download and save album cover art as a file
pub async fn save_album_artwork( pub async fn save_album_artwork(album_dir: &Path, artwork_url: &str) -> Result<(), String> {
album_dir: &Path,
artwork_url: &str,
) -> Result<(), String> {
log::info!("Downloading album artwork to: {:?}", album_dir); log::info!("Downloading album artwork to: {:?}", album_dir);
let cover_path = album_dir.join("cover.jpg"); let cover_path = album_dir.join("cover.jpg");
// Skip if cover already exists // Skip if cover already exists
if cover_path.exists() { if cover_path.exists() {
log::debug!("Cover art already exists at {:?}", cover_path); log::debug!("Cover art already exists at {:?}", cover_path);
@@ -364,8 +357,7 @@ pub fn organize_cached_file(
}; };
// Move file // Move file
std::fs::rename(temp, &final_path) std::fs::rename(temp, &final_path).map_err(|e| format!("Failed to move file: {}", e))?;
.map_err(|e| format!("Failed to move file: {}", e))?;
Ok(final_path.to_string_lossy().to_string()) Ok(final_path.to_string_lossy().to_string())
} }
+55 -32
View File
@@ -2,14 +2,17 @@
//! //!
//! Handles migration of old numeric-named FLAC files to new organized structure //! Handles migration of old numeric-named FLAC files to new organized structure
use serde::Serialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use serde::Serialize;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use super::metadata::{
embed_artwork, fetch_complete_metadata, organize_cached_file, save_album_artwork,
write_flac_tags,
};
use crate::api::QobuzClient; use crate::api::QobuzClient;
use crate::library::database::LibraryDatabase; use crate::library::database::LibraryDatabase;
use super::metadata::{fetch_complete_metadata, write_flac_tags, embed_artwork, organize_cached_file, save_album_artwork};
#[derive(Default, Serialize, Clone, Debug)] #[derive(Default, Serialize, Clone, Debug)]
pub struct MigrationStatus { pub struct MigrationStatus {
@@ -31,7 +34,10 @@ pub struct MigrationError {
/// Detect legacy cached files (numeric FLAC files in tracks/ folder) /// Detect legacy cached files (numeric FLAC files in tracks/ folder)
pub fn detect_legacy_cached_files(tracks_dir: &Path) -> Result<Vec<u64>, String> { pub fn detect_legacy_cached_files(tracks_dir: &Path) -> Result<Vec<u64>, String> {
log::info!("Scanning for legacy cached files in: {}", tracks_dir.display()); log::info!(
"Scanning for legacy cached files in: {}",
tracks_dir.display()
);
if !tracks_dir.exists() { if !tracks_dir.exists() {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -94,7 +100,7 @@ async fn migrate_single_track(
// 4. Organize file into artist/album structure // 4. Organize file into artist/album structure
let new_path = organize_cached_file(track_id, &legacy_path_str, offline_root, &metadata)?; let new_path = organize_cached_file(track_id, &legacy_path_str, offline_root, &metadata)?;
// 5. Save album artwork as cover.jpg // 5. Save album artwork as cover.jpg
let artwork_path = if let Some(artwork_url) = &metadata.artwork_url { let artwork_path = if let Some(artwork_url) = &metadata.artwork_url {
if let Some(parent_dir) = std::path::Path::new(&new_path).parent() { if let Some(parent_dir) = std::path::Path::new(&new_path).parent() {
@@ -111,7 +117,7 @@ async fn migrate_single_track(
} else { } else {
None None
}; };
// 6. Extract audio properties from FLAC file // 6. Extract audio properties from FLAC file
use lofty::AudioFile; use lofty::AudioFile;
let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) { let (bit_depth, sample_rate) = match lofty::read_from_path(&new_path) {
@@ -122,37 +128,44 @@ async fn migrate_single_track(
(bit_depth, sample_rate) (bit_depth, sample_rate)
} }
Err(e) => { Err(e) => {
log::warn!("Failed to read audio properties for track {}: {}", track_id, e); log::warn!(
"Failed to read audio properties for track {}: {}",
track_id,
e
);
(None, None) (None, None)
} }
}; };
// 7. Insert into local library DB // 7. Insert into local library DB
let lib_opt__ = library_db.lock().await; let lib_opt__ = library_db.lock().await;
let lib_guard = lib_opt__.as_ref().ok_or("No active session - please log in")?; let lib_guard = lib_opt__
.as_ref()
.ok_or("No active session - please log in")?;
let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist); let album_artist = metadata.album_artist.as_ref().unwrap_or(&metadata.artist);
let album_group_key = format!("{}|{}", metadata.album, album_artist); let album_group_key = format!("{}|{}", metadata.album, album_artist);
lib_guard.insert_qobuz_cached_track_with_grouping( lib_guard
track_id, .insert_qobuz_cached_track_with_grouping(
&metadata.title, track_id,
&metadata.artist, &metadata.title,
Some(&metadata.album), &metadata.artist,
metadata.album_artist.as_deref(), Some(&metadata.album),
metadata.track_number, metadata.album_artist.as_deref(),
metadata.disc_number, metadata.track_number,
metadata.year, metadata.disc_number,
metadata.duration_secs, metadata.year,
&new_path, metadata.duration_secs,
&album_group_key, &new_path,
&metadata.album, &album_group_key,
bit_depth, &metadata.album,
sample_rate, bit_depth,
artwork_path.as_deref(), sample_rate,
) artwork_path.as_deref(),
.map_err(|e| format!("Failed to insert to library DB: {}", e))?; )
.map_err(|e| format!("Failed to insert to library DB: {}", e))?;
log::info!("Track {} migrated successfully to: {}", track_id, new_path); log::info!("Track {} migrated successfully to: {}", track_id, new_path);
Ok(new_path) Ok(new_path)
} }
@@ -191,13 +204,19 @@ pub async fn migrate_legacy_cached_files(
&offline_root, &offline_root,
&*client_guard, &*client_guard,
library_db.clone(), library_db.clone(),
).await { )
.await
{
Ok(_) => { Ok(_) => {
status.successful += 1; status.successful += 1;
// Delete legacy file after successful migration // Delete legacy file after successful migration
if let Err(e) = std::fs::remove_file(&legacy_path) { if let Err(e) = std::fs::remove_file(&legacy_path) {
log::warn!("Failed to delete legacy file {}: {}", legacy_path.display(), e); log::warn!(
"Failed to delete legacy file {}: {}",
legacy_path.display(),
e
);
} }
} }
Err(e) => { Err(e) => {
@@ -206,7 +225,11 @@ pub async fn migrate_legacy_cached_files(
track_id, track_id,
error_message: e, error_message: e,
}); });
log::error!("Failed to migrate track {}: {}", track_id, status.errors.last().unwrap().error_message); log::error!(
"Failed to migrate track {}: {}",
track_id,
status.errors.last().unwrap().error_message
);
} }
} }
+10 -5
View File
@@ -9,20 +9,22 @@
pub mod commands; pub mod commands;
pub mod db; pub mod db;
pub mod downloader; pub mod downloader;
pub mod path_validator;
pub mod metadata; pub mod metadata;
pub mod migration; pub mod migration;
pub mod path_validator;
use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use tokio::sync::{Mutex, Semaphore}; use tokio::sync::{Mutex, Semaphore};
use serde::{Deserialize, Serialize};
pub use db::OfflineCacheDb; pub use db::OfflineCacheDb;
pub use downloader::StreamFetcher; pub use downloader::StreamFetcher;
pub use metadata::{sanitize_filename, CompleteTrackMetadata};
pub use migration::{
detect_legacy_cached_files, migrate_legacy_cached_files, MigrationError, MigrationStatus,
};
pub use path_validator::{is_offline_root_available, validate_path, PathStatus}; pub use path_validator::{is_offline_root_available, validate_path, PathStatus};
pub use metadata::{CompleteTrackMetadata, sanitize_filename};
pub use migration::{MigrationStatus, MigrationError, detect_legacy_cached_files, migrate_legacy_cached_files};
/// Cache status for a track in offline storage /// Cache status for a track in offline storage
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -219,7 +221,10 @@ impl OfflineCacheState {
.map_err(|e| format!("Failed to open download library connection: {}", e))?; .map_err(|e| format!("Failed to open download library connection: {}", e))?;
let mut guard = self.library_db.lock().await; let mut guard = self.library_db.lock().await;
*guard = Some(lib_db); *guard = Some(lib_db);
log::info!("Offline cache: separate library DB connection opened at {:?}", db_path); log::info!(
"Offline cache: separate library DB connection opened at {:?}",
db_path
);
Ok(()) Ok(())
} }
+11 -13
View File
@@ -2,9 +2,9 @@
//! //!
//! Handles path validation, permission checking, and mount status verification. //! Handles path validation, permission checking, and mount status verification.
use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PathStatus { pub enum PathStatus {
@@ -116,10 +116,7 @@ pub struct MoveReport {
} }
/// Move cached files from old path to new path /// Move cached files from old path to new path
pub fn move_cached_files_to_new_path( pub fn move_cached_files_to_new_path(old_root: &str, new_root: &str) -> Result<MoveReport, String> {
old_root: &str,
new_root: &str,
) -> Result<MoveReport, String> {
let old_path = Path::new(old_root); let old_path = Path::new(old_root);
let new_path = Path::new(new_root); let new_path = Path::new(new_root);
@@ -130,8 +127,7 @@ pub fn move_cached_files_to_new_path(
} }
// Create new root if it doesn't exist // Create new root if it doesn't exist
fs::create_dir_all(new_path) fs::create_dir_all(new_path).map_err(|e| format!("Failed to create new directory: {}", e))?;
.map_err(|e| format!("Failed to create new directory: {}", e))?;
let mut report = MoveReport { let mut report = MoveReport {
total_files: 0, total_files: 0,
@@ -153,8 +149,7 @@ pub fn move_cached_files_to_new_path(
// Create parent directories in new location // Create parent directories in new location
if let Some(parent) = new_file.parent() { if let Some(parent) = new_file.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
.map_err(|e| format!("Failed to create directory: {}", e))?;
} }
// Move file // Move file
@@ -164,7 +159,9 @@ pub fn move_cached_files_to_new_path(
} }
Err(e) => { Err(e) => {
log::warn!("Failed to move file {:?}: {}", old_file, e); log::warn!("Failed to move file {:?}: {}", old_file, e);
report.failed_files.push(old_file.to_string_lossy().to_string()); report
.failed_files
.push(old_file.to_string_lossy().to_string());
} }
} }
} }
@@ -180,8 +177,7 @@ fn collect_flac_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
return Ok(files); return Ok(files);
} }
let entries = fs::read_dir(dir) let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {}", e))?;
.map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
@@ -213,7 +209,9 @@ const CACHE_DURATION: Duration = Duration::from_secs(30);
/// Check mount status with caching (30s cache) /// Check mount status with caching (30s cache)
pub fn is_offline_root_available(path: &str) -> Result<bool, String> { pub fn is_offline_root_available(path: &str) -> Result<bool, String> {
let mut cache = MOUNT_CACHE.lock().map_err(|e| format!("Cache lock error: {}", e))?; let mut cache = MOUNT_CACHE
.lock()
.map_err(|e| format!("Cache lock error: {}", e))?;
if let Some(cached) = cache.as_ref() { if let Some(cached) = cache.as_ref() {
if cached.path == path { if cached.path == path {
+11 -4
View File
@@ -10,8 +10,8 @@
//! - Artist Top Songs: next ranked track //! - Artist Top Songs: next ranked track
//! - Single track: no context, playback ends when track finishes //! - Single track: no context, playback ends when track finishes
use std::sync::Mutex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Mutex;
/// Type of playback context /// Type of playback context
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -154,7 +154,10 @@ impl ContextManager {
pub fn set_context(&self, context: PlaybackContext) { pub fn set_context(&self, context: PlaybackContext) {
let mut current = self.current.lock().unwrap(); let mut current = self.current.lock().unwrap();
*current = Some(context); *current = Some(context);
log::info!("Playback context set: {:?}", current.as_ref().map(|c| &c.label)); log::info!(
"Playback context set: {:?}",
current.as_ref().map(|c| &c.label)
);
} }
/// Clear the current context (for single track playback) /// Clear the current context (for single track playback)
@@ -181,7 +184,7 @@ impl ContextManager {
.unwrap() .unwrap()
.as_ref() .as_ref()
.and_then(|ctx| ctx.next_track_id()) .and_then(|ctx| ctx.next_track_id())
} }
/// Get multiple upcoming track IDs from the current context /// Get multiple upcoming track IDs from the current context
pub fn upcoming_track_ids(&self, count: usize) -> Vec<u64> { pub fn upcoming_track_ids(&self, count: usize) -> Vec<u64> {
@@ -224,7 +227,11 @@ impl ContextManager {
if let Some(ctx) = current.as_mut() { if let Some(ctx) = current.as_mut() {
let count = new_track_ids.len(); let count = new_track_ids.len();
ctx.track_ids.extend(new_track_ids); ctx.track_ids.extend(new_track_ids);
log::debug!("Appended {} track IDs to context (total: {})", count, ctx.track_ids.len()); log::debug!(
"Appended {} track IDs to context (total: {})",
count,
ctx.track_ids.len()
);
} }
} }
} }
+1258 -939
View File
File diff suppressed because it is too large. Load diff
+17 -10
View File
@@ -16,26 +16,24 @@ use std::time::Duration;
/// 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: Sink },
sink: Sink,
},
/// Direct ALSA (hw: devices, bit-perfect) /// Direct ALSA (hw: devices, bit-perfect)
AlsaDirect { AlsaDirect {
stream: Arc<AlsaDirectStream>, stream: Arc<AlsaDirectStream>,
is_playing: Arc<AtomicBool>, is_playing: Arc<AtomicBool>,
should_stop: Arc<AtomicBool>, // Separate flag for full stop vs pause should_stop: Arc<AtomicBool>, // Separate flag for full stop vs pause
position_frames: Arc<AtomicU64>, position_frames: Arc<AtomicU64>,
duration_frames: Arc<AtomicU64>, duration_frames: Arc<AtomicU64>,
playback_thread: Option<thread::JoinHandle<()>>, playback_thread: Option<thread::JoinHandle<()>>,
hardware_volume: bool, // Use ALSA mixer for volume control hardware_volume: bool, // Use ALSA mixer for volume control
}, },
} }
impl PlaybackEngine { impl PlaybackEngine {
/// Create Rodio engine /// Create Rodio engine
pub fn new_rodio(stream_handle: &OutputStreamHandle) -> Result<Self, String> { pub fn new_rodio(stream_handle: &OutputStreamHandle) -> Result<Self, String> {
let sink = Sink::try_new(stream_handle) let sink =
.map_err(|e| format!("Failed to create Sink: {}", e))?; Sink::try_new(stream_handle).map_err(|e| format!("Failed to create Sink: {}", e))?;
Ok(Self::Rodio { sink }) Ok(Self::Rodio { sink })
} }
@@ -132,7 +130,10 @@ impl PlaybackEngine {
if buffer_f32.is_empty() { if buffer_f32.is_empty() {
// End of stream // End of stream
log::info!("[ALSA Direct Engine] Stream ended (total frames: {})", total_frames); log::info!(
"[ALSA Direct Engine] Stream ended (total frames: {})",
total_frames
);
natural_end = true; natural_end = true;
break 'playback; break 'playback;
} }
@@ -229,7 +230,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 {
// Try hardware mixer control // Try hardware mixer control
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -240,7 +245,9 @@ impl PlaybackEngine {
} }
} else { } else {
// Hardware volume disabled - volume control is handled by DAC/amplifier // Hardware volume disabled - volume control is handled by DAC/amplifier
log::debug!("[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"); log::debug!(
"[ALSA Direct Engine] Hardware volume control disabled (use DAC/amplifier)"
);
} }
} }
} }
+27 -28
View File
@@ -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)) => {
+1 -3
View File
@@ -11,9 +11,7 @@ use crate::playlist_import::providers::{detect_provider, fetch_playlist};
const ADD_CHUNK_SIZE: usize = 50; const ADD_CHUNK_SIZE: usize = 50;
const QOBUZ_PLAYLIST_TRACK_LIMIT: usize = 2000; const QOBUZ_PLAYLIST_TRACK_LIMIT: usize = 2000;
pub async fn preview_public_playlist( pub async fn preview_public_playlist(url: &str) -> Result<ImportPlaylist, PlaylistImportError> {
url: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let provider = detect_provider(url)?; let provider = detect_provider(url)?;
fetch_playlist(provider).await fetch_playlist(provider).await
} }
+20 -7
View File
@@ -31,7 +31,11 @@ pub async fn match_tracks(
let results: Arc<tokio::sync::Mutex<Vec<Option<TrackMatch>>>> = let results: Arc<tokio::sync::Mutex<Vec<Option<TrackMatch>>>> =
Arc::new(tokio::sync::Mutex::new(vec![None; tracks.len()])); Arc::new(tokio::sync::Mutex::new(vec![None; tracks.len()]));
let owned_tracks: Vec<(usize, ImportTrack)> = tracks.iter().enumerate().map(|(i, tr)| (i, tr.clone())).collect(); let owned_tracks: Vec<(usize, ImportTrack)> = tracks
.iter()
.enumerate()
.map(|(i, tr)| (i, tr.clone()))
.collect();
stream::iter(owned_tracks) stream::iter(owned_tracks)
.map(|(idx, track)| { .map(|(idx, track)| {
@@ -43,9 +47,7 @@ pub async fn match_tracks(
async move { async move {
let query = format!("{} {}", track.artist, track.title); let query = format!("{} {}", track.artist, track.title);
let search_result = client let search_result = client.search_tracks(&query, SEARCH_LIMIT, 0, None).await;
.search_tracks(&query, SEARCH_LIMIT, 0, None)
.await;
let match_entry = match search_result { let match_entry = match search_result {
Ok(search) => { Ok(search) => {
@@ -161,7 +163,14 @@ fn score_candidate(track: &ImportTrack, candidate: &Track) -> f32 {
} }
let title_score = similarity(&track.title, &candidate.title); let title_score = similarity(&track.title, &candidate.title);
let artist_score = similarity(&track.artist, candidate.performer.as_ref().map(|a| a.name.as_str()).unwrap_or("")); let artist_score = similarity(
&track.artist,
candidate
.performer
.as_ref()
.map(|a| a.name.as_str())
.unwrap_or(""),
);
let album_score = track let album_score = track
.album .album
.as_ref() .as_ref()
@@ -174,9 +183,13 @@ fn score_candidate(track: &ImportTrack, candidate: &Track) -> f32 {
}) })
.unwrap_or(0.0); .unwrap_or(0.0);
let mut score = title_score * TITLE_WEIGHT + artist_score * ARTIST_WEIGHT + album_score * ALBUM_WEIGHT; let mut score =
title_score * TITLE_WEIGHT + artist_score * ARTIST_WEIGHT + album_score * ALBUM_WEIGHT;
if let (Some(import_duration), Some(candidate_duration)) = (track.duration_ms, Some((candidate.duration as u64).saturating_mul(1000))) { if let (Some(import_duration), Some(candidate_duration)) = (
track.duration_ms,
Some((candidate.duration as u64).saturating_mul(1000)),
) {
let diff = if import_duration > candidate_duration { let diff = if import_duration > candidate_duration {
import_duration - candidate_duration import_duration - candidate_duration
} else { } else {
+3 -1
View File
@@ -8,5 +8,7 @@ pub mod providers;
pub use errors::PlaylistImportError; pub use errors::PlaylistImportError;
pub use importer::{import_public_playlist, preview_public_playlist}; pub use importer::{import_public_playlist, preview_public_playlist};
pub use models::{ImportPlaylist, ImportProgress, ImportProvider, ImportSummary, ImportTrack, TrackMatch}; pub use models::{
ImportPlaylist, ImportProgress, ImportProvider, ImportSummary, ImportTrack, TrackMatch,
};
pub use providers::ProviderCredentials; pub use providers::ProviderCredentials;
@@ -25,8 +25,14 @@ pub fn parse_playlist_id(url: &str) -> Option<(String, String)> {
} }
} }
pub async fn fetch_playlist(storefront: &str, playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> { pub async fn fetch_playlist(
let url = format!("https://music.apple.com/{}/playlist/{}", storefront, playlist_id); storefront: &str,
playlist_id: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let url = format!(
"https://music.apple.com/{}/playlist/{}",
storefront, playlist_id
);
let html = reqwest::get(&url) let html = reqwest::get(&url)
.await .await
.map_err(|e| PlaylistImportError::Http(e.to_string()))? .map_err(|e| PlaylistImportError::Http(e.to_string()))?
@@ -34,17 +40,20 @@ pub async fn fetch_playlist(storefront: &str, playlist_id: &str) -> Result<Impor
.await .await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?; .map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let name = extract_meta(&html, "og:title").unwrap_or_else(|| "Apple Music Playlist".to_string()); let name =
extract_meta(&html, "og:title").unwrap_or_else(|| "Apple Music Playlist".to_string());
let description = extract_meta(&html, "og:description").filter(|v| !v.is_empty()); let description = extract_meta(&html, "og:description").filter(|v| !v.is_empty());
let json_text = extract_script(&html, "serialized-server-data") let json_text = extract_script(&html, "serialized-server-data").ok_or_else(|| {
.ok_or_else(|| PlaylistImportError::Parse("Apple Music serialized-server-data not found".to_string()))?; PlaylistImportError::Parse("Apple Music serialized-server-data not found".to_string())
})?;
let data: Value = serde_json::from_str(&json_text) let data: Value =
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; serde_json::from_str(&json_text).map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let items = find_track_items(&data) let items = find_track_items(&data).ok_or_else(|| {
.ok_or_else(|| PlaylistImportError::Parse("Apple Music track list not found".to_string()))?; PlaylistImportError::Parse("Apple Music track list not found".to_string())
})?;
let mut tracks = Vec::new(); let mut tracks = Vec::new();
for item in items { for item in items {
@@ -58,9 +67,7 @@ pub async fn fetch_playlist(storefront: &str, playlist_id: &str) -> Result<Impor
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
let duration_ms = item let duration_ms = item.get("duration").and_then(|v| v.as_u64());
.get("duration")
.and_then(|v| v.as_u64());
let provider_id = item let provider_id = item
.get("contentDescriptor") .get("contentDescriptor")
.and_then(|v| v.get("identifiers")) .and_then(|v| v.get("identifiers"))
+24 -17
View File
@@ -17,10 +17,19 @@ pub struct ProviderCredentials {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderKind { pub enum ProviderKind {
Spotify { playlist_id: String }, Spotify {
AppleMusic { storefront: String, playlist_id: String }, playlist_id: String,
Tidal { playlist_id: String }, },
Deezer { playlist_id: String }, AppleMusic {
storefront: String,
playlist_id: String,
},
Tidal {
playlist_id: String,
},
Deezer {
playlist_id: String,
},
} }
pub fn detect_provider(url: &str) -> Result<ProviderKind, PlaylistImportError> { pub fn detect_provider(url: &str) -> Result<ProviderKind, PlaylistImportError> {
@@ -28,7 +37,10 @@ pub fn detect_provider(url: &str) -> Result<ProviderKind, PlaylistImportError> {
return Ok(ProviderKind::Spotify { playlist_id: id }); return Ok(ProviderKind::Spotify { playlist_id: id });
} }
if let Some((storefront, id)) = apple::parse_playlist_id(url) { if let Some((storefront, id)) = apple::parse_playlist_id(url) {
return Ok(ProviderKind::AppleMusic { storefront, playlist_id: id }); return Ok(ProviderKind::AppleMusic {
storefront,
playlist_id: id,
});
} }
if let Some(id) = tidal::parse_playlist_id(url) { if let Some(id) = tidal::parse_playlist_id(url) {
return Ok(ProviderKind::Tidal { playlist_id: id }); return Ok(ProviderKind::Tidal { playlist_id: id });
@@ -41,19 +53,14 @@ pub fn detect_provider(url: &str) -> Result<ProviderKind, PlaylistImportError> {
} }
/// Fetch playlist (proxy handles credentials) /// Fetch playlist (proxy handles credentials)
pub async fn fetch_playlist( pub async fn fetch_playlist(kind: ProviderKind) -> Result<ImportPlaylist, PlaylistImportError> {
kind: ProviderKind,
) -> Result<ImportPlaylist, PlaylistImportError> {
match kind { match kind {
ProviderKind::Spotify { playlist_id } => { ProviderKind::Spotify { playlist_id } => spotify::fetch_playlist(&playlist_id).await,
spotify::fetch_playlist(&playlist_id).await ProviderKind::AppleMusic {
} storefront,
ProviderKind::AppleMusic { storefront, playlist_id } => { playlist_id,
apple::fetch_playlist(&storefront, &playlist_id).await } => apple::fetch_playlist(&storefront, &playlist_id).await,
} ProviderKind::Tidal { playlist_id } => tidal::fetch_playlist(&playlist_id).await,
ProviderKind::Tidal { playlist_id } => {
tidal::fetch_playlist(&playlist_id).await
}
ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await, ProviderKind::Deezer { playlist_id } => deezer::fetch_playlist(&playlist_id).await,
} }
} }
@@ -35,9 +35,7 @@ pub fn parse_playlist_id(url: &str) -> Option<String> {
None None
} }
pub async fn fetch_playlist( pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
playlist_id: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
// Try API with token, retry once with fresh token on failure // Try API with token, retry once with fresh token on failure
for attempt in 1..=2 { for attempt in 1..=2 {
match get_app_token().await { match get_app_token().await {
@@ -59,7 +57,9 @@ pub async fn fetch_playlist(
} }
} }
log::warn!("Spotify: API failed after 2 attempts, falling back to embed (limited to ~100 tracks)"); log::warn!(
"Spotify: API failed after 2 attempts, falling back to embed (limited to ~100 tracks)"
);
fetch_playlist_from_embed(playlist_id).await fetch_playlist_from_embed(playlist_id).await
} }
@@ -99,7 +99,11 @@ async fn fetch_playlist_with_token(
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as usize; .unwrap_or(0) as usize;
log::info!("Spotify: playlist '{}' reports {} tracks", name, expected_total); log::info!(
"Spotify: playlist '{}' reports {} tracks",
name,
expected_total
);
let mut tracks = Vec::new(); let mut tracks = Vec::new();
let mut offset = 0u32; let mut offset = 0u32;
@@ -127,7 +131,9 @@ async fn fetch_playlist_with_token(
let items = response let items = response
.get("items") .get("items")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.ok_or_else(|| PlaylistImportError::Parse("Spotify tracks missing items".to_string()))?; .ok_or_else(|| {
PlaylistImportError::Parse("Spotify tracks missing items".to_string())
})?;
for item in items { for item in items {
let track = match item.get("track") { let track = match item.get("track") {
@@ -146,9 +152,7 @@ async fn fetch_playlist_with_token(
.and_then(|v| v.get("name")) .and_then(|v| v.get("name"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|v| v.to_string()); .map(|v| v.to_string());
let duration_ms = track let duration_ms = track.get("duration_ms").and_then(|v| v.as_u64());
.get("duration_ms")
.and_then(|v| v.as_u64());
let isrc = track let isrc = track
.get("external_ids") .get("external_ids")
.and_then(|v| v.get("isrc")) .and_then(|v| v.get("isrc"))
@@ -176,15 +180,9 @@ async fn fetch_playlist_with_token(
} }
// Use both "next" field and total count to decide pagination // Use both "next" field and total count to decide pagination
let has_next = response let has_next = response.get("next").map(|v| !v.is_null()).unwrap_or(false);
.get("next")
.map(|v| !v.is_null())
.unwrap_or(false);
let response_total = response let response_total = response.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
log::debug!( log::debug!(
"Spotify: page offset={}, got {} items, total so far={}, response.total={}, has_next={}", "Spotify: page offset={}, got {} items, total so far={}, response.total={}, has_next={}",
@@ -198,7 +196,10 @@ async fn fetch_playlist_with_token(
// Safety: also stop if the page returned 0 items to prevent infinite loop // Safety: also stop if the page returned 0 items to prevent infinite loop
if items.is_empty() { if items.is_empty() {
log::warn!("Spotify: empty page at offset {}, stopping pagination", offset); log::warn!(
"Spotify: empty page at offset {}, stopping pagination",
offset
);
break; break;
} }
@@ -227,7 +228,9 @@ async fn fetch_playlist_with_token(
}) })
} }
async fn fetch_playlist_from_embed(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> { async fn fetch_playlist_from_embed(
playlist_id: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let url = format!("https://open.spotify.com/embed/playlist/{}", playlist_id); let url = format!("https://open.spotify.com/embed/playlist/{}", playlist_id);
let html = reqwest::get(&url) let html = reqwest::get(&url)
.await .await
@@ -236,11 +239,12 @@ async fn fetch_playlist_from_embed(playlist_id: &str) -> Result<ImportPlaylist,
.await .await
.map_err(|e| PlaylistImportError::Http(e.to_string()))?; .map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let json_text = extract_script(&html, "__NEXT_DATA__") let json_text = extract_script(&html, "__NEXT_DATA__").ok_or_else(|| {
.ok_or_else(|| PlaylistImportError::Parse("Spotify embed missing __NEXT_DATA__".to_string()))?; PlaylistImportError::Parse("Spotify embed missing __NEXT_DATA__".to_string())
})?;
let data: Value = serde_json::from_str(&json_text) let data: Value =
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; serde_json::from_str(&json_text).map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
let entity = data let entity = data
.get("props") .get("props")
@@ -274,15 +278,16 @@ async fn fetch_playlist_from_embed(playlist_id: &str) -> Result<ImportPlaylist,
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
let duration_ms = track let duration_ms = track.get("duration").and_then(|v| v.as_u64());
.get("duration") let uri = track.get("uri").and_then(|v| v.as_str()).unwrap_or("");
.and_then(|v| v.as_u64()); let provider_id = uri
let uri = track .split(':')
.get("uri") .last()
.and_then(|v| v.as_str()) .filter(|v| !v.is_empty())
.unwrap_or(""); .map(|v| v.to_string());
let provider_id = uri.split(':').last().filter(|v| !v.is_empty()).map(|v| v.to_string()); let provider_url = provider_id
let provider_url = provider_id.as_ref().map(|id| format!("https://open.spotify.com/track/{}", id)); .as_ref()
.map(|id| format!("https://open.spotify.com/track/{}", id));
tracks.push(ImportTrack { tracks.push(ImportTrack {
title, title,
@@ -36,9 +36,7 @@ pub fn parse_playlist_id(url: &str) -> Option<String> {
None None
} }
pub async fn fetch_playlist( pub async fn fetch_playlist(playlist_id: &str) -> Result<ImportPlaylist, PlaylistImportError> {
playlist_id: &str,
) -> Result<ImportPlaylist, PlaylistImportError> {
let token = get_app_token().await?; let token = get_app_token().await?;
let country_code = env::var("TIDAL_COUNTRY_CODE").unwrap_or_else(|_| "US".to_string()); let country_code = env::var("TIDAL_COUNTRY_CODE").unwrap_or_else(|_| "US".to_string());
@@ -53,10 +51,16 @@ pub async fn fetch_playlist(
.map_err(|e| PlaylistImportError::Http(e.to_string()))?; .map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; let body = resp
.text()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(PlaylistImportError::Http(format!("Tidal playlist fetch failed: {} - {}", status, body))); return Err(PlaylistImportError::Http(format!(
"Tidal playlist fetch failed: {} - {}",
status, body
)));
} }
let meta: Value = serde_json::from_str(&body) let meta: Value = serde_json::from_str(&body)
@@ -115,10 +119,16 @@ async fn fetch_track_ids(
.map_err(|e| PlaylistImportError::Http(e.to_string()))?; .map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; let body = resp
.text()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(PlaylistImportError::Http(format!("Tidal track IDs fetch failed: {} - {}", status, body))); return Err(PlaylistImportError::Http(format!(
"Tidal track IDs fetch failed: {} - {}",
status, body
)));
} }
let response: Value = serde_json::from_str(&body) let response: Value = serde_json::from_str(&body)
@@ -179,10 +189,16 @@ async fn fetch_tracks_by_ids(
.map_err(|e| PlaylistImportError::Http(e.to_string()))?; .map_err(|e| PlaylistImportError::Http(e.to_string()))?;
let status = resp.status(); let status = resp.status();
let body = resp.text().await.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; let body = resp
.text()
.await
.map_err(|e| PlaylistImportError::Parse(e.to_string()))?;
if !status.is_success() { if !status.is_success() {
return Err(PlaylistImportError::Http(format!("Tidal tracks fetch failed: {} - {}", status, body))); return Err(PlaylistImportError::Http(format!(
"Tidal tracks fetch failed: {} - {}",
status, body
)));
} }
let response: Value = serde_json::from_str(&body) let response: Value = serde_json::from_str(&body)
+25 -5
View File
@@ -226,7 +226,11 @@ fn open_plex_cache_db() -> Result<Connection, String> {
.map_err(|e| format!("Failed to initialize Plex cache schema: {}", e))?; .map_err(|e| format!("Failed to initialize Plex cache schema: {}", e))?;
// Migration: add track_number, disc_number, album_key columns if missing // Migration: add track_number, disc_number, album_key columns if missing
for col in &["track_number INTEGER", "disc_number INTEGER", "album_key TEXT"] { for col in &[
"track_number INTEGER",
"disc_number INTEGER",
"album_key TEXT",
] {
let stmt = format!("ALTER TABLE plex_cache_tracks ADD COLUMN {col}"); let stmt = format!("ALTER TABLE plex_cache_tracks ADD COLUMN {col}");
let _ = conn.execute(&stmt, []); let _ = conn.execute(&stmt, []);
} }
@@ -262,9 +266,17 @@ fn open_plex_cache_db() -> Result<Connection, String> {
for (rating_key, artist_raw, album_raw) in &rows { for (rating_key, artist_raw, album_raw) in &rows {
let artist = decode_xml_entities(artist_raw.trim()); let artist = decode_xml_entities(artist_raw.trim());
let artist = if artist.is_empty() { "Unknown Artist".to_string() } else { artist }; let artist = if artist.is_empty() {
"Unknown Artist".to_string()
} else {
artist
};
let album = decode_xml_entities(album_raw.trim()); let album = decode_xml_entities(album_raw.trim());
let album = if album.is_empty() { "Unknown Album".to_string() } else { album }; let album = if album.is_empty() {
"Unknown Album".to_string()
} else {
album
};
let album = normalize_album_title(Some(&artist), &album); let album = normalize_album_title(Some(&artist), &album);
let key = plex_album_key(&artist, &album); let key = plex_album_key(&artist, &album);
let _ = conn.execute( let _ = conn.execute(
@@ -1313,9 +1325,17 @@ pub fn plex_cache_save_tracks(
.or_else(|| saved.and_then(|s| s.2)); .or_else(|| saved.and_then(|s| s.2));
// Compute album_key for this track // Compute album_key for this track
let track_artist = track.artist.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty()) let track_artist = track
.artist
.as_deref()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.unwrap_or("Unknown Artist"); .unwrap_or("Unknown Artist");
let track_album_raw = track.album.as_deref().map(|v| v.trim()).filter(|v| !v.is_empty()) let track_album_raw = track
.album
.as_deref()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.unwrap_or("Unknown Album"); .unwrap_or("Unknown Album");
let track_album_normalized = normalize_album_title(Some(track_artist), track_album_raw); let track_album_normalized = normalize_album_title(Some(track_artist), track_album_raw);
let track_album_key = plex_album_key(track_artist, &track_album_normalized); let track_album_key = plex_album_key(track_artist, &track_album_normalized);
+90 -16
View File
@@ -261,7 +261,11 @@ impl QueueManager {
state.current_index = Some(curr_idx - 1); state.current_index = Some(curr_idx - 1);
} else if index == curr_idx { } else if index == curr_idx {
if curr_idx >= state.tracks.len() { if curr_idx >= state.tracks.len() {
state.current_index = if state.tracks.is_empty() { None } else { Some(state.tracks.len() - 1) }; state.current_index = if state.tracks.is_empty() {
None
} else {
Some(state.tracks.len() - 1)
};
} }
} }
} }
@@ -298,7 +302,11 @@ impl QueueManager {
return None; return None;
} }
log::info!("remove_upcoming_track: upcoming_index={} -> actual_index={}", upcoming_index, actual_index); log::info!(
"remove_upcoming_track: upcoming_index={} -> actual_index={}",
upcoming_index,
actual_index
);
let removed = state.tracks.remove(actual_index); let removed = state.tracks.remove(actual_index);
@@ -308,7 +316,11 @@ impl QueueManager {
state.current_index = Some(curr_idx - 1); state.current_index = Some(curr_idx - 1);
} else if actual_index == curr_idx { } else if actual_index == curr_idx {
if curr_idx >= state.tracks.len() { if curr_idx >= state.tracks.len() {
state.current_index = if state.tracks.is_empty() { None } else { Some(state.tracks.len() - 1) }; state.current_index = if state.tracks.is_empty() {
None
} else {
Some(state.tracks.len() - 1)
};
} }
} }
} }
@@ -379,7 +391,9 @@ impl QueueManager {
/// Get current track /// Get current track
pub fn current_track(&self) -> Option<QueueTrack> { pub fn current_track(&self) -> Option<QueueTrack> {
let state = self.state.lock().unwrap(); let state = self.state.lock().unwrap();
state.current_index.and_then(|idx| state.tracks.get(idx).cloned()) state
.current_index
.and_then(|idx| state.tracks.get(idx).cloned())
} }
/// Get next track without advancing /// Get next track without advancing
@@ -390,7 +404,9 @@ impl QueueManager {
} }
if state.repeat == RepeatMode::One { if state.repeat == RepeatMode::One {
return state.current_index.and_then(|idx| state.tracks.get(idx).cloned()); return state
.current_index
.and_then(|idx| state.tracks.get(idx).cloned());
} }
let next_idx = if state.shuffle { let next_idx = if state.shuffle {
@@ -480,7 +496,9 @@ impl QueueManager {
} }
if state.repeat == RepeatMode::One { if state.repeat == RepeatMode::One {
return state.current_index.and_then(|idx| state.tracks.get(idx).cloned()); return state
.current_index
.and_then(|idx| state.tracks.get(idx).cloned());
} }
let next_idx = if state.shuffle { let next_idx = if state.shuffle {
@@ -613,18 +631,24 @@ impl QueueManager {
pub fn get_state(&self) -> QueueState { pub fn get_state(&self) -> QueueState {
let state = self.state.lock().unwrap(); let state = self.state.lock().unwrap();
let current_track = state.current_index.and_then(|idx| state.tracks.get(idx).cloned()); let current_track = state
.current_index
.and_then(|idx| state.tracks.get(idx).cloned());
// Get upcoming tracks (after current) // Get upcoming tracks (after current)
let upcoming: Vec<QueueTrack> = if let Some(curr_idx) = state.current_index { let upcoming: Vec<QueueTrack> = if let Some(curr_idx) = state.current_index {
if state.shuffle { if state.shuffle {
state.shuffle_order.iter() state
.shuffle_order
.iter()
.skip(state.shuffle_position + 1) .skip(state.shuffle_position + 1)
.take(20) .take(20)
.filter_map(|&idx| state.tracks.get(idx).cloned()) .filter_map(|&idx| state.tracks.get(idx).cloned())
.collect() .collect()
} else { } else {
state.tracks.iter() state
.tracks
.iter()
.skip(curr_idx + 1) .skip(curr_idx + 1)
.take(20) .take(20)
.cloned() .cloned()
@@ -635,7 +659,9 @@ impl QueueManager {
}; };
// Get history tracks (recent first) // Get history tracks (recent first)
let history_tracks: Vec<QueueTrack> = state.history.iter() let history_tracks: Vec<QueueTrack> = state
.history
.iter()
.rev() .rev()
.take(10) .take(10)
.filter_map(|&idx| state.tracks.get(idx).cloned()) .filter_map(|&idx| state.tracks.get(idx).cloned())
@@ -757,7 +783,15 @@ mod tests {
let result = queue.move_track(0, 3); let result = queue.move_track(0, 3);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![2, 3, 1, 4, 5]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![2, 3, 1, 4, 5]
);
} }
#[test] #[test]
@@ -773,7 +807,15 @@ mod tests {
let result = queue.move_track(0, 3); let result = queue.move_track(0, 3);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![3, 4, 2, 5]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![3, 4, 2, 5]
);
} }
#[test] #[test]
@@ -787,7 +829,15 @@ mod tests {
let result = queue.move_track(3, 0); let result = queue.move_track(3, 0);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![4, 1, 2, 3, 5]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![4, 1, 2, 3, 5]
);
} }
#[test] #[test]
@@ -802,7 +852,15 @@ mod tests {
let result = queue.move_track(3, 0); let result = queue.move_track(3, 0);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![5, 2, 3, 4]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![5, 2, 3, 4]
);
} }
#[test] #[test]
@@ -816,7 +874,15 @@ mod tests {
let result = queue.move_track(2, 3); let result = queue.move_track(2, 3);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![1, 2, 3, 4, 5]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![1, 2, 3, 4, 5]
);
} }
#[test] #[test]
@@ -831,6 +897,14 @@ mod tests {
let result = queue.move_track(0, 1); let result = queue.move_track(0, 1);
assert!(result, "move_track should succeed"); assert!(result, "move_track should succeed");
assert_eq!(queue.get_state().upcoming.iter().map(|track| track.id).collect::<Vec<u64>>(), vec![2, 3, 4, 5]); assert_eq!(
queue
.get_state()
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<u64>>(),
vec![2, 3, 4, 5]
);
} }
} }
+39 -15
View File
@@ -49,7 +49,11 @@ pub struct RadioPoolBuilder<'a> {
impl<'a> RadioPoolBuilder<'a> { impl<'a> RadioPoolBuilder<'a> {
pub fn new(db: &'a RadioDb, client: &'a QobuzClient, options: BuildRadioOptions) -> Self { pub fn new(db: &'a RadioDb, client: &'a QobuzClient, options: BuildRadioOptions) -> Self {
Self { db, client, options } Self {
db,
client,
options,
}
} }
fn is_qobuz_owned(owner_name: &str) -> bool { fn is_qobuz_owned(owner_name: &str) -> bool {
@@ -89,15 +93,23 @@ impl<'a> RadioPoolBuilder<'a> {
self.options.reseed_every, self.options.reseed_every,
)?; )?;
self.build_pool_for_seed_artist(&session.id, artist_id).await?; self.build_pool_for_seed_artist(&session.id, artist_id)
.await?;
Ok(session) Ok(session)
} }
pub async fn create_track_radio(&self, track_id: u64, artist_id: u64) -> Result<RadioSession, String> { pub async fn create_track_radio(
&self,
track_id: u64,
artist_id: u64,
) -> Result<RadioSession, String> {
let rng_seed = Self::derive_rng_seed(self.options.rng_seed, track_id); let rng_seed = Self::derive_rng_seed(self.options.rng_seed, track_id);
let session = self.db.create_session( let session = self.db.create_session(
RadioSeed::Track { track_id, artist_id }, RadioSeed::Track {
track_id,
artist_id,
},
rng_seed, rng_seed,
self.options.artist_spacing, self.options.artist_spacing,
self.options.reseed_every, self.options.reseed_every,
@@ -115,12 +127,17 @@ impl<'a> RadioPoolBuilder<'a> {
.insert_pool_track(&session.id, track.id, track_artist_id, "seed_track", 0)?; .insert_pool_track(&session.id, track.id, track_artist_id, "seed_track", 0)?;
} }
self.build_pool_for_seed_artist(&session.id, artist_id).await?; self.build_pool_for_seed_artist(&session.id, artist_id)
.await?;
Ok(session) Ok(session)
} }
async fn build_pool_for_seed_artist(&self, session_id: &str, seed_artist_id: u64) -> Result<(), String> { async fn build_pool_for_seed_artist(
&self,
session_id: &str,
seed_artist_id: u64,
) -> Result<(), String> {
// 1) Curated artist playlists (distance 1) // 1) Curated artist playlists (distance 1)
let artist_detail = self let artist_detail = self
.client .client
@@ -143,10 +160,7 @@ impl<'a> RadioPoolBuilder<'a> {
.await .await
.map_err(|e| format!("Failed to fetch playlist {}: {}", playlist.id, e))?; .map_err(|e| format!("Failed to fetch playlist {}: {}", playlist.id, e))?;
let tracks = playlist let tracks = playlist.tracks.map(|t| t.items).unwrap_or_default();
.tracks
.map(|t| t.items)
.unwrap_or_default();
for t in tracks.into_iter().take(self.options.playlist_track_limit) { for t in tracks.into_iter().take(self.options.playlist_track_limit) {
if !Self::is_music_track(&t) { if !Self::is_music_track(&t) {
@@ -181,7 +195,8 @@ impl<'a> RadioPoolBuilder<'a> {
.await .await
.map_err(|e| format!("Failed to fetch similar artists: {}", e))?; .map_err(|e| format!("Failed to fetch similar artists: {}", e))?;
let mut first_degree_artist_ids: Vec<u64> = similar.items.into_iter().map(|a| a.id).collect(); let mut first_degree_artist_ids: Vec<u64> =
similar.items.into_iter().map(|a| a.id).collect();
first_degree_artist_ids.retain(|id| *id != 0 && *id != seed_artist_id); first_degree_artist_ids.retain(|id| *id != 0 && *id != seed_artist_id);
first_degree_artist_ids.sort(); first_degree_artist_ids.sort();
first_degree_artist_ids.dedup(); first_degree_artist_ids.dedup();
@@ -204,7 +219,9 @@ impl<'a> RadioPoolBuilder<'a> {
// 4) Second-degree expansion (distance 2, bounded) // 4) Second-degree expansion (distance 2, bounded)
let pool_size = self.db.pool_size(session_id)?; let pool_size = self.db.pool_size(session_id)?;
if pool_size < self.options.min_pool_size_for_second_degree && self.options.second_degree_artist_limit > 0 { if pool_size < self.options.min_pool_size_for_second_degree
&& self.options.second_degree_artist_limit > 0
{
let mut added = 0u32; let mut added = 0u32;
for base_artist_id in first_degree_artist_ids.into_iter().take(2) { for base_artist_id in first_degree_artist_ids.into_iter().take(2) {
@@ -230,14 +247,21 @@ impl<'a> RadioPoolBuilder<'a> {
.client .client
.get_artist_tracks(a.id, self.options.second_degree_tracks_limit, 0) .get_artist_tracks(a.id, self.options.second_degree_tracks_limit, 0)
.await .await
.map_err(|e| format!("Failed to fetch second-degree artist tracks: {}", e))?; .map_err(|e| {
format!("Failed to fetch second-degree artist tracks: {}", e)
})?;
for t in tracks.items { for t in tracks.items {
if !Self::is_music_track(&t) { if !Self::is_music_track(&t) {
continue; continue;
} }
let artist_id = Self::track_artist_id(&t, a.id); let artist_id = Self::track_artist_id(&t, a.id);
self.db self.db.insert_pool_track(
.insert_pool_track(session_id, t.id, artist_id, "second_degree", 2)?; session_id,
t.id,
artist_id,
"second_degree",
2,
)?;
} }
added += 1; added += 1;
+1 -1
View File
@@ -5,6 +5,6 @@ pub mod engine;
pub use builder::{BuildRadioOptions, RadioPoolBuilder}; pub use builder::{BuildRadioOptions, RadioPoolBuilder};
pub use db::{RadioDb, RadioSeed, RadioSession, RadioTrackRef}; pub use db::{RadioDb, RadioSeed, RadioSession, RadioTrackRef};
pub use engine::RadioEngine; pub use engine::RadioEngine;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
+14 -3
View File
@@ -21,7 +21,13 @@ fn radio_no_repetition() {
for track_id in 1u64..=120u64 { for track_id in 1u64..=120u64 {
let artist_id = 1000 + (track_id % 10); let artist_id = 1000 + (track_id % 10);
let distance = if track_id % 7 == 0 { 2 } else if track_id % 3 == 0 { 1 } else { 0 }; let distance = if track_id % 7 == 0 {
2
} else if track_id % 3 == 0 {
1
} else {
0
};
db.insert_pool_track(&session.id, track_id, artist_id, "test_pool", distance) db.insert_pool_track(&session.id, track_id, artist_id, "test_pool", distance)
.unwrap(); .unwrap();
} }
@@ -89,7 +95,8 @@ fn radio_distance_constraint() {
.unwrap(); .unwrap();
for track_id in 1u64..=50u64 { for track_id in 1u64..=50u64 {
db.insert_pool_track(&session.id, track_id, 1, "ok", 2).unwrap(); db.insert_pool_track(&session.id, track_id, 1, "ok", 2)
.unwrap();
} }
for track_id in 1001u64..=1020u64 { for track_id in 1001u64..=1020u64 {
db.insert_pool_track_unchecked(&session.id, track_id, 2, "too_far", 3) db.insert_pool_track_unchecked(&session.id, track_id, 2, "too_far", 3)
@@ -99,7 +106,11 @@ fn radio_distance_constraint() {
let engine = RadioEngine::new(db); let engine = RadioEngine::new(db);
for _ in 0..50 { for _ in 0..50 {
let t = engine.next_track(&session.id).unwrap(); let t = engine.next_track(&session.id).unwrap();
assert!(t.distance <= 2, "Selected track with distance {}", t.distance); assert!(
t.distance <= 2,
"Selected track with distance {}",
t.distance
);
} }
} }
+86 -43
View File
@@ -48,7 +48,9 @@ pub async fn reco_log_event(
); );
let guard__ = state.db.lock().await; let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.insert_event(&event) db.insert_event(&event)
} }
@@ -66,7 +68,9 @@ pub async fn reco_get_home(
let limit_favorites = limit_favorites.unwrap_or(12); let limit_favorites = limit_favorites.unwrap_or(12);
let guard__ = state.db.lock().await; let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?; let recently_played_album_ids = db.get_recent_album_ids(limit_recent_albums)?;
let continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?; let continue_listening_track_ids = db.get_recent_track_ids(limit_continue_tracks)?;
@@ -100,7 +104,9 @@ pub async fn reco_train_scores(
let since_ts = now_ts.saturating_sub(lookback_days * 86_400); let since_ts = now_ts.saturating_sub(lookback_days * 86_400);
let mut guard__ = state.db.lock().await; let mut guard__ = state.db.lock().await;
let db = guard__.as_mut().ok_or("No active session - please log in")?; let db = guard__
.as_mut()
.ok_or("No active session - please log in")?;
let events = db.get_events_since(since_ts, Some(max_events))?; let events = db.get_events_since(since_ts, Some(max_events))?;
let total_events = events.len(); let total_events = events.len();
let total_favorite_events = events let total_favorite_events = events
@@ -157,7 +163,9 @@ pub async fn reco_get_home_ml(
let limit_favorites = limit_favorites.unwrap_or(12); let limit_favorites = limit_favorites.unwrap_or(12);
let guard__ = state.db.lock().await; let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
get_home_seeds_internal( get_home_seeds_internal(
db, db,
@@ -349,7 +357,9 @@ pub async fn get_playlist_suggestions(
) -> Result<Vec<u64>, String> { ) -> Result<Vec<u64>, String> {
let limit = limit.unwrap_or(10); let limit = limit.unwrap_or(10);
let guard__ = state.db.lock().await; let guard__ = state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
// Get user's top scored tracks (get more than needed for filtering) // Get user's top scored tracks (get more than needed for filtering)
let fetch_limit = (limit * 5).max(50); let fetch_limit = (limit * 5).max(50);
@@ -363,7 +373,8 @@ pub async fn get_playlist_suggestions(
// Build a set of tracks already in playlist for fast lookup // Build a set of tracks already in playlist for fast lookup
let existing: std::collections::HashSet<u64> = playlist_track_ids.iter().cloned().collect(); let existing: std::collections::HashSet<u64> = playlist_track_ids.iter().cloned().collect();
let playlist_artists: std::collections::HashSet<u64> = playlist_artist_ids.iter().cloned().collect(); let playlist_artists: std::collections::HashSet<u64> =
playlist_artist_ids.iter().cloned().collect();
// Score candidates: favorites get bonus, same-artist tracks get bonus // Score candidates: favorites get bonus, same-artist tracks get bonus
let mut candidates: HashMap<u64, f64> = HashMap::new(); let mut candidates: HashMap<u64, f64> = HashMap::new();
@@ -434,7 +445,9 @@ pub async fn reco_backfill_genres(
// Get album IDs first (scoped lock to avoid Send issue) // Get album IDs first (scoped lock to avoid Send issue)
let album_ids = { let album_ids = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_album_ids_without_genre(batch_size)? db.get_album_ids_without_genre(batch_size)?
}; };
@@ -470,11 +483,16 @@ pub async fn reco_backfill_genres(
if let Some(genre_opt) = genre_info { if let Some(genre_opt) = genre_info {
let genre_id = genre_opt.as_ref().map(|(id, _)| *id).unwrap_or(0); let genre_id = genre_opt.as_ref().map(|(id, _)| *id).unwrap_or(0);
let genre_name = genre_opt.as_ref().map(|(_, name)| name.as_str()).unwrap_or("none"); let genre_name = genre_opt
.as_ref()
.map(|(_, name)| name.as_str())
.unwrap_or("none");
// Re-acquire DB lock for update // Re-acquire DB lock for update
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
match db.update_genre_for_album(album_id, genre_id) { match db.update_genre_for_album(album_id, genre_id) {
Ok(updated) => { Ok(updated) => {
events_updated += updated; events_updated += updated;
@@ -482,7 +500,10 @@ pub async fn reco_backfill_genres(
if genre_id > 0 { if genre_id > 0 {
log::debug!( log::debug!(
"Updated {} events for album {} with genre {} ({})", "Updated {} events for album {} with genre {} ({})",
updated, album_id, genre_id, genre_name updated,
album_id,
genre_id,
genre_name
); );
} else { } else {
log::debug!("Album {} has no genre, set to 0", album_id); log::debug!("Album {} has no genre, set to 0", album_id);
@@ -502,7 +523,9 @@ pub async fn reco_backfill_genres(
// Check how many albums still need backfill // Check how many albums still need backfill
let albums_remaining = { let albums_remaining = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let remaining = db.get_album_ids_without_genre(1)?; let remaining = db.get_album_ids_without_genre(1)?;
if remaining.is_empty() { if remaining.is_empty() {
0 0
@@ -529,11 +552,11 @@ pub async fn reco_backfill_genres(
/// Check if genre backfill is needed /// Check if genre backfill is needed
#[tauri::command] #[tauri::command]
pub async fn reco_needs_genre_backfill( pub async fn reco_needs_genre_backfill(reco_state: State<'_, RecoState>) -> Result<bool, String> {
reco_state: State<'_, RecoState>,
) -> Result<bool, String> {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
let albums = db.get_album_ids_without_genre(1)?; let albums = db.get_album_ids_without_genre(1)?;
Ok(!albums.is_empty()) Ok(!albums.is_empty())
} }
@@ -610,13 +633,10 @@ fn track_to_display_meta(track: &Track) -> TrackDisplayMeta {
.map(|a| get_image(&a.image)) .map(|a| get_image(&a.image))
.unwrap_or_default(), .unwrap_or_default(),
album_id: track.album.as_ref().map(|a| a.id.clone()), album_id: track.album.as_ref().map(|a| a.id.clone()),
artist_id: track.performer.as_ref().and_then(|p| { artist_id: track
if p.id > 0 { .performer
Some(p.id) .as_ref()
} else { .and_then(|p| if p.id > 0 { Some(p.id) } else { None }),
None
}
}),
duration: format_duration(track.duration), duration: format_duration(track.duration),
duration_seconds: track.duration, duration_seconds: track.duration,
hires: track.hires_streamable, hires: track.hires_streamable,
@@ -630,7 +650,11 @@ fn artist_to_card_meta(artist: &Artist, play_count: Option<u32>) -> ArtistCardMe
ArtistCardMeta { ArtistCardMeta {
id: artist.id, id: artist.id,
name: artist.name.clone(), name: artist.name.clone(),
image: artist.image.as_ref().map(|img| get_image(img)).filter(|s| !s.is_empty()), image: artist
.image
.as_ref()
.map(|img| get_image(img))
.filter(|s| !s.is_empty()),
play_count, play_count,
} }
} }
@@ -736,7 +760,9 @@ pub async fn reco_get_home_resolved(
// Step 1: Get seeds (IDs) from reco DB // Step 1: Get seeds (IDs) from reco DB
let seeds = { let seeds = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
get_home_seeds_internal( get_home_seeds_internal(
db, db,
limit_recent_albums, limit_recent_albums,
@@ -757,11 +783,7 @@ pub async fn reco_get_home_resolved(
ids ids
}; };
let all_artist_ids: Vec<u64> = seeds let all_artist_ids: Vec<u64> = seeds.top_artist_ids.iter().map(|s| s.artist_id).collect();
.top_artist_ids
.iter()
.map(|s| s.artist_id)
.collect();
// Build play_count map for artists // Build play_count map for artists
let artist_play_counts: HashMap<u64, u32> = seeds let artist_play_counts: HashMap<u64, u32> = seeds
@@ -771,12 +793,16 @@ pub async fn reco_get_home_resolved(
.collect(); .collect();
// Step 3: Resolve albums (recent + favorite) // Step 3: Resolve albums (recent + favorite)
let recently_played_albums = let recently_played_albums = resolve_albums(
resolve_albums(&seeds.recently_played_album_ids, &reco_state, &app_state, &cache_state).await?; &seeds.recently_played_album_ids,
&reco_state,
&app_state,
&cache_state,
)
.await?;
// Step 4: Resolve tracks (continue + favorite) // Step 4: Resolve tracks (continue + favorite)
let all_tracks = let all_tracks = resolve_tracks(&all_track_ids, &reco_state, &app_state, &cache_state).await?;
resolve_tracks(&all_track_ids, &reco_state, &app_state, &cache_state).await?;
// Build track lookup // Build track lookup
let track_map: HashMap<u64, &TrackDisplayMeta> = let track_map: HashMap<u64, &TrackDisplayMeta> =
@@ -809,8 +835,14 @@ pub async fn reco_get_home_resolved(
resolve_albums(&favorite_album_ids, &reco_state, &app_state, &cache_state).await?; resolve_albums(&favorite_album_ids, &reco_state, &app_state, &cache_state).await?;
// Step 6: Resolve artists // Step 6: Resolve artists
let top_artists = let top_artists = resolve_artists(
resolve_artists(&all_artist_ids, &artist_play_counts, &reco_state, &app_state, &cache_state).await?; &all_artist_ids,
&artist_play_counts,
&reco_state,
&app_state,
&cache_state,
)
.await?;
Ok(HomeResolved { Ok(HomeResolved {
recently_played_albums, recently_played_albums,
@@ -834,7 +866,9 @@ async fn resolve_albums(
// Tier 1: reco meta cache (no TTL) // Tier 1: reco meta cache (no TTL)
let meta_hits = { let meta_hits = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_album_metas(ids)? db.get_album_metas(ids)?
}; };
let meta_map: HashMap<String, AlbumCardMeta> = let meta_map: HashMap<String, AlbumCardMeta> =
@@ -851,7 +885,9 @@ async fn resolve_albums(
if !missing_ids.is_empty() { if !missing_ids.is_empty() {
let cached = { let cached = {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
cache.get_albums(&missing_ids, None)? cache.get_albums(&missing_ids, None)?
}; };
for (album_id, json_str) in cached { for (album_id, json_str) in cached {
@@ -958,7 +994,9 @@ async fn resolve_tracks(
// Tier 1: reco meta // Tier 1: reco meta
let meta_hits = { let meta_hits = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_track_metas(ids)? db.get_track_metas(ids)?
}; };
let meta_map: HashMap<u64, TrackDisplayMeta> = let meta_map: HashMap<u64, TrackDisplayMeta> =
@@ -975,7 +1013,9 @@ async fn resolve_tracks(
if !missing_ids.is_empty() { if !missing_ids.is_empty() {
let cached = { let cached = {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
cache.get_tracks(&missing_ids, None)? cache.get_tracks(&missing_ids, None)?
}; };
for (track_id, json_str) in cached { for (track_id, json_str) in cached {
@@ -1079,11 +1119,12 @@ async fn resolve_artists(
// Tier 1: reco meta // Tier 1: reco meta
let meta_hits = { let meta_hits = {
let guard__ = reco_state.db.lock().await; let guard__ = reco_state.db.lock().await;
let db = guard__.as_ref().ok_or("No active session - please log in")?; let db = guard__
.as_ref()
.ok_or("No active session - please log in")?;
db.get_artist_metas(ids)? db.get_artist_metas(ids)?
}; };
let meta_map: HashMap<u64, ArtistCardMeta> = let meta_map: HashMap<u64, ArtistCardMeta> = meta_hits.into_iter().map(|m| (m.id, m)).collect();
meta_hits.into_iter().map(|m| (m.id, m)).collect();
let missing_ids: Vec<u64> = ids let missing_ids: Vec<u64> = ids
.iter() .iter()
@@ -1101,7 +1142,9 @@ async fn resolve_artists(
if !missing_ids.is_empty() { if !missing_ids.is_empty() {
let cached = { let cached = {
let guard__ = cache_state.cache.lock().await; let guard__ = cache_state.cache.lock().await;
let cache = guard__.as_ref().ok_or("No active session - please log in")?; let cache = guard__
.as_ref()
.ok_or("No active session - please log in")?;
cache.get_artists(&missing_ids, &locale, None)? cache.get_artists(&missing_ids, &locale, None)?
}; };
for (artist_id, json_str) in cached { for (artist_id, json_str) in cached {
+4 -1
View File
@@ -359,7 +359,10 @@ pub enum RuntimeEvent {
/// Runtime initialized (client ready) /// Runtime initialized (client ready)
RuntimeInitialized, RuntimeInitialized,
/// Authentication state changed /// Authentication state changed
AuthChanged { logged_in: bool, user_id: Option<u64> }, AuthChanged {
logged_in: bool,
user_id: Option<u64>,
},
/// Per-user session activated /// Per-user session activated
UserSessionActivated { user_id: u64 }, UserSessionActivated { user_id: u64 },
/// Per-user session deactivated /// Per-user session deactivated
+78 -30
View File
@@ -24,20 +24,28 @@ use crate::user_data::UserDataPaths;
/// Note: This is the core logic extracted from `activate_user_session` command. /// Note: This is the core logic extracted from `activate_user_session` command.
/// It can be called from runtime_bootstrap, v2_login, or the command itself. /// It can be called from runtime_bootstrap, v2_login, or the command itself.
pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<(), String> { pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<(), String> {
log::info!("[SessionLifecycle] Activating session for user_id={}", user_id); log::info!(
"[SessionLifecycle] Activating session for user_id={}",
user_id
);
// Get all required states from AppHandle // Get all required states from AppHandle
let user_paths = app.state::<UserDataPaths>(); let user_paths = app.state::<UserDataPaths>();
let session_store = app.state::<crate::session_store::SessionStoreState>(); let session_store = app.state::<crate::session_store::SessionStoreState>();
let favorites_cache = app.state::<crate::config::favorites_cache::FavoritesCacheState>(); let favorites_cache = app.state::<crate::config::favorites_cache::FavoritesCacheState>();
let subscription_state = app.state::<crate::config::subscription_state::SubscriptionStateState>(); let subscription_state =
let playback_prefs = app.state::<crate::config::playback_preferences::PlaybackPreferencesState>(); app.state::<crate::config::subscription_state::SubscriptionStateState>();
let favorites_prefs = app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>(); let playback_prefs =
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
let favorites_prefs =
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>(); let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>(); let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>(); let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
let remote_control_settings = app.state::<crate::config::remote_control_settings::RemoteControlSettingsState>(); let remote_control_settings =
let allowed_origins = app.state::<crate::config::remote_control_settings::AllowedOriginsState>(); app.state::<crate::config::remote_control_settings::RemoteControlSettingsState>();
let allowed_origins =
app.state::<crate::config::remote_control_settings::AllowedOriginsState>();
// NOTE: legal_settings is GLOBAL - not per-user, not initialized/torn down here // NOTE: legal_settings is GLOBAL - not per-user, not initialized/torn down here
let updates = app.state::<crate::updates::UpdatesState>(); let updates = app.state::<crate::updates::UpdatesState>();
let library = app.state::<crate::library::commands::LibraryState>(); let library = app.state::<crate::library::commands::LibraryState>();
@@ -106,7 +114,9 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
let user_name = legacy_client.get_user_name().await; let user_name = legacy_client.get_user_name().await;
let enabled = legacy_client.is_enabled().await; let enabled = legacy_client.is_enabled().await;
drop(legacy_client); drop(legacy_client);
listenbrainz_v2.init_with_credentials(token, user_name, enabled).await; listenbrainz_v2
.init_with_credentials(token, user_name, enabled)
.await;
log::info!("[SessionLifecycle] ListenBrainz V2 state synced from legacy"); log::info!("[SessionLifecycle] ListenBrainz V2 state synced from legacy");
} }
@@ -126,11 +136,18 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
// Type-alias states (per-user settings) // Type-alias states (per-user settings)
// NOTE: LegalSettingsState is GLOBAL (not per-user) - initialized at app startup // NOTE: LegalSettingsState is GLOBAL (not per-user) - initialized at app startup
use crate::config::{ use crate::config::{
subscription_state::SubscriptionStateStore, download_settings::DownloadSettingsStore, subscription_state::SubscriptionStateStore,
download_settings::DownloadSettingsStore,
}; };
crate::commands::user_session::init_type_alias_state(&*subscription_state, &data_dir, SubscriptionStateStore::new_at)?; crate::commands::user_session::init_type_alias_state(
crate::commands::user_session::init_type_alias_state(&*download_settings, &data_dir, DownloadSettingsStore::new_at)?; &*subscription_state,
&data_dir,
SubscriptionStateStore::new_at,
)?;
crate::commands::user_session::init_type_alias_state(
&*download_settings,
&data_dir,
DownloadSettingsStore::new_at,
)?;
// Cache-dir stores // Cache-dir stores
offline_cache.init_at(&cache_dir).await?; offline_cache.init_at(&cache_dir).await?;
@@ -144,8 +161,11 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
.as_secs() as i64; .as_secs() as i64;
let should_purge = { let should_purge = {
let guard = subscription_state.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = subscription_state
guard.as_ref() .lock()
.map_err(|e| format!("Lock error: {}", e))?;
guard
.as_ref()
.and_then(|s| s.should_purge_offline_cache(now).ok()) .and_then(|s| s.should_purge_offline_cache(now).ok())
.unwrap_or(false) .unwrap_or(false)
}; };
@@ -155,10 +175,14 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
if let Err(e) = crate::offline_cache::commands::purge_all_cached_files( if let Err(e) = crate::offline_cache::commands::purge_all_cached_files(
offline_cache.inner(), offline_cache.inner(),
library.inner(), library.inner(),
).await { )
.await
{
log::error!("[SessionLifecycle] Failed to purge offline cache: {}", e); log::error!("[SessionLifecycle] Failed to purge offline cache: {}", e);
} else { } else {
let guard = subscription_state.lock().map_err(|e| format!("Lock error: {}", e))?; let guard = subscription_state
.lock()
.map_err(|e| format!("Lock error: {}", e))?;
if let Some(store) = guard.as_ref() { if let Some(store) = guard.as_ref() {
let _ = store.mark_offline_cache_purged(now); let _ = store.mark_offline_cache_purged(now);
} }
@@ -171,9 +195,7 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
} }
// Start visualizer FFT thread (idempotent) // Start visualizer FFT thread (idempotent)
app.state::<crate::AppState>() app.state::<crate::AppState>().visualizer.start(app.clone());
.visualizer
.start(app.clone());
// Start remote control API server if enabled // Start remote control API server if enabled
let app_clone = app.clone(); let app_clone = app.clone();
@@ -184,12 +206,21 @@ pub async fn activate_session(app: &tauri::AppHandle, user_id: u64) -> Result<()
}); });
// Update runtime state to reflect session activation // Update runtime state to reflect session activation
runtime_manager.manager().set_session_activated(true, user_id).await; runtime_manager
.manager()
.set_session_activated(true, user_id)
.await;
// Emit event for clients // Emit event for clients
let _ = app.emit("runtime:event", RuntimeEvent::UserSessionActivated { user_id }); let _ = app.emit(
"runtime:event",
RuntimeEvent::UserSessionActivated { user_id },
);
log::info!("[SessionLifecycle] Session activated for user_id={}", user_id); log::info!(
"[SessionLifecycle] Session activated for user_id={}",
user_id
);
Ok(()) Ok(())
} }
@@ -203,14 +234,19 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
let user_paths = app.state::<UserDataPaths>(); let user_paths = app.state::<UserDataPaths>();
let session_store = app.state::<crate::session_store::SessionStoreState>(); let session_store = app.state::<crate::session_store::SessionStoreState>();
let favorites_cache = app.state::<crate::config::favorites_cache::FavoritesCacheState>(); let favorites_cache = app.state::<crate::config::favorites_cache::FavoritesCacheState>();
let subscription_state = app.state::<crate::config::subscription_state::SubscriptionStateState>(); let subscription_state =
let playback_prefs = app.state::<crate::config::playback_preferences::PlaybackPreferencesState>(); app.state::<crate::config::subscription_state::SubscriptionStateState>();
let favorites_prefs = app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>(); let playback_prefs =
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
let favorites_prefs =
app.state::<crate::config::favorites_preferences::FavoritesPreferencesState>();
let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>(); let download_settings = app.state::<crate::config::download_settings::DownloadSettingsState>();
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>(); let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>(); let tray_settings = app.state::<crate::config::tray_settings::TraySettingsState>();
let remote_control_settings = app.state::<crate::config::remote_control_settings::RemoteControlSettingsState>(); let remote_control_settings =
let allowed_origins = app.state::<crate::config::remote_control_settings::AllowedOriginsState>(); app.state::<crate::config::remote_control_settings::RemoteControlSettingsState>();
let allowed_origins =
app.state::<crate::config::remote_control_settings::AllowedOriginsState>();
// NOTE: legal_settings is GLOBAL - not per-user, not initialized/torn down here // NOTE: legal_settings is GLOBAL - not per-user, not initialized/torn down here
let updates = app.state::<crate::updates::UpdatesState>(); let updates = app.state::<crate::updates::UpdatesState>();
let library = app.state::<crate::library::commands::LibraryState>(); let library = app.state::<crate::library::commands::LibraryState>();
@@ -268,7 +304,10 @@ pub async fn deactivate_session(app: &tauri::AppHandle) -> Result<(), String> {
// Update runtime state - clear BOTH auth and session // Update runtime state - clear BOTH auth and session
runtime_manager.manager().set_legacy_auth(false, None).await; runtime_manager.manager().set_legacy_auth(false, None).await;
runtime_manager.manager().set_session_activated(false, 0).await; runtime_manager
.manager()
.set_session_activated(false, 0)
.await;
runtime_manager.manager().set_corebridge_auth(false).await; runtime_manager.manager().set_corebridge_auth(false).await;
// Emit event for clients // Emit event for clients
@@ -309,7 +348,8 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
let offline = app.state::<crate::offline::OfflineState>(); let offline = app.state::<crate::offline::OfflineState>();
let offline_cache = app.state::<crate::offline_cache::OfflineCacheState>(); let offline_cache = app.state::<crate::offline_cache::OfflineCacheState>();
let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>(); let audio_settings = app.state::<crate::config::audio_settings::AudioSettingsState>();
let playback_prefs = app.state::<crate::config::playback_preferences::PlaybackPreferencesState>(); let playback_prefs =
app.state::<crate::config::playback_preferences::PlaybackPreferencesState>();
library.init_at(&data_dir).await?; library.init_at(&data_dir).await?;
offline.init_at(&data_dir)?; offline.init_at(&data_dir)?;
@@ -321,10 +361,18 @@ pub async fn activate_offline_session(app: &tauri::AppHandle) -> Result<(), Stri
// Mark session as activated for offline use // Mark session as activated for offline use
// Note: legacy_auth remains false, corebridge_auth remains false // Note: legacy_auth remains false, corebridge_auth remains false
// But session_activated is true so queue commands work // But session_activated is true so queue commands work
runtime_manager.manager().set_session_activated(true, OFFLINE_USER_ID).await; runtime_manager
.manager()
.set_session_activated(true, OFFLINE_USER_ID)
.await;
// Emit event // Emit event
let _ = app.emit("runtime:event", RuntimeEvent::UserSessionActivated { user_id: OFFLINE_USER_ID }); let _ = app.emit(
"runtime:event",
RuntimeEvent::UserSessionActivated {
user_id: OFFLINE_USER_ID,
},
);
log::info!("[SessionLifecycle] Offline session activated"); log::info!("[SessionLifecycle] Offline session activated");
Ok(()) Ok(())
+33 -14
View File
@@ -70,16 +70,20 @@ impl SongLinkClient {
if !deezer_response.status().is_success() { if !deezer_response.status().is_success() {
return Err(ShareError::OdesliError(format!( return Err(ShareError::OdesliError(format!(
"Deezer API returned {} for ISRC {}", deezer_response.status(), isrc "Deezer API returned {} for ISRC {}",
deezer_response.status(),
isrc
))); )));
} }
let body: serde_json::Value = deezer_response.json().await let body: serde_json::Value = deezer_response.json().await.map_err(|e| {
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?; ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e))
})?;
if body.get("error").is_some() { if body.get("error").is_some() {
return Err(ShareError::OdesliError(format!( return Err(ShareError::OdesliError(format!(
"Could not find track with ISRC {} on Deezer", isrc "Could not find track with ISRC {} on Deezer",
isrc
))); )));
} }
@@ -95,11 +99,13 @@ impl SongLinkClient {
let result = SongLinkResponse { let result = SongLinkResponse {
page_url, page_url,
title: body.get("title").and_then(|v| v.as_str()).map(String::from), title: body.get("title").and_then(|v| v.as_str()).map(String::from),
artist: body.get("artist") artist: body
.get("artist")
.and_then(|a| a.get("name")) .and_then(|a| a.get("name"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
thumbnail_url: body.get("album") thumbnail_url: body
.get("album")
.and_then(|a| a.get("cover_xl")) .and_then(|a| a.get("cover_xl"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
@@ -130,16 +136,20 @@ impl SongLinkClient {
if !deezer_response.status().is_success() { if !deezer_response.status().is_success() {
return Err(ShareError::OdesliError(format!( return Err(ShareError::OdesliError(format!(
"Deezer API returned {} for UPC {}", deezer_response.status(), upc "Deezer API returned {} for UPC {}",
deezer_response.status(),
upc
))); )));
} }
let body: serde_json::Value = deezer_response.json().await let body: serde_json::Value = deezer_response.json().await.map_err(|e| {
.map_err(|e| ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e)))?; ShareError::OdesliError(format!("Failed to parse Deezer response: {}", e))
})?;
if body.get("error").is_some() { if body.get("error").is_some() {
return Err(ShareError::OdesliError(format!( return Err(ShareError::OdesliError(format!(
"Could not find album with UPC {} on Deezer", upc "Could not find album with UPC {} on Deezer",
upc
))); )));
} }
@@ -155,11 +165,15 @@ impl SongLinkClient {
let result = SongLinkResponse { let result = SongLinkResponse {
page_url, page_url,
title: body.get("title").and_then(|v| v.as_str()).map(String::from), title: body.get("title").and_then(|v| v.as_str()).map(String::from),
artist: body.get("artist") artist: body
.get("artist")
.and_then(|a| a.get("name")) .and_then(|a| a.get("name"))
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from), .map(String::from),
thumbnail_url: body.get("cover_xl").and_then(|v| v.as_str()).map(String::from), thumbnail_url: body
.get("cover_xl")
.and_then(|v| v.as_str())
.map(String::from),
platforms: HashMap::new(), platforms: HashMap::new(),
identifier: upc.to_string(), identifier: upc.to_string(),
content_type: ContentType::Album.as_str().to_string(), content_type: ContentType::Album.as_str().to_string(),
@@ -170,7 +184,11 @@ impl SongLinkClient {
} }
/// Get song.link URL by URL (fallback when ISRC/UPC are missing) /// Get song.link URL by URL (fallback when ISRC/UPC are missing)
pub async fn get_by_url(&self, url: &str, content_type: ContentType) -> Result<SongLinkResponse, ShareError> { pub async fn get_by_url(
&self,
url: &str,
content_type: ContentType,
) -> Result<SongLinkResponse, ShareError> {
let cache_key = format!("url:{}", url); let cache_key = format!("url:{}", url);
if let Some(cached) = self.get_from_cache(&cache_key) { if let Some(cached) = self.get_from_cache(&cache_key) {
@@ -193,7 +211,8 @@ impl SongLinkClient {
// Provide a friendlier message for common errors // Provide a friendlier message for common errors
if status.as_u16() == 400 && text.contains("could_not_resolve_entity") { if status.as_u16() == 400 && text.contains("could_not_resolve_entity") {
return Err(ShareError::OdesliError( return Err(ShareError::OdesliError(
"Track not found on any supported platform. Try a track with an ISRC code.".to_string() "Track not found on any supported platform. Try a track with an ISRC code."
.to_string(),
)); ));
} }
return Err(ShareError::OdesliError(format!( return Err(ShareError::OdesliError(format!(
+13 -2
View File
@@ -67,14 +67,25 @@ impl TauriAdapter {
log::error!("Failed to emit legacy auth:logged-out: {}", e); log::error!("Failed to emit legacy auth:logged-out: {}", e);
} }
} }
CoreEvent::Error { code, message, recoverable } => { CoreEvent::Error {
code,
message,
recoverable,
} => {
#[derive(serde::Serialize, Clone)] #[derive(serde::Serialize, Clone)]
struct LegacyError<'a> { struct LegacyError<'a> {
code: &'a str, code: &'a str,
message: &'a str, message: &'a str,
recoverable: bool, recoverable: bool,
} }
if let Err(e) = self.app_handle.emit("error", LegacyError { code, message, recoverable: *recoverable }) { if let Err(e) = self.app_handle.emit(
"error",
LegacyError {
code,
message,
recoverable: *recoverable,
},
) {
log::error!("Failed to emit legacy error: {}", e); log::error!("Failed to emit legacy error: {}", e);
} }
} }
+3 -3
View File
@@ -24,7 +24,8 @@ 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 = PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzkriegfc.qbz.png"); let icon_path =
PathBuf::from("/app/share/icons/hicolor/32x32/apps/com.blitzkriegfc.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) {
@@ -39,8 +40,7 @@ fn load_tray_icon() -> Image<'static> {
} }
// Default: decode embedded PNG // Default: decode embedded PNG
let img = image::load_from_memory(TRAY_ICON_PNG) let img = image::load_from_memory(TRAY_ICON_PNG).expect("Failed to decode tray icon PNG");
.expect("Failed to decode tray icon PNG");
let (width, height) = img.dimensions(); let (width, height) = img.dimensions();
let rgba = img.into_rgba8().into_raw(); let rgba = img.into_rgba8().into_raw();
Image::new_owned(rgba, width, height) Image::new_owned(rgba, width, height)
+18 -5
View File
@@ -24,24 +24,35 @@ impl UserDataPaths {
/// Set the current user after login /// Set the current user after login
pub fn set_user(&self, user_id: u64) { pub fn set_user(&self, user_id: u64) {
*self.user_id.write().expect("UserDataPaths write lock poisoned") = Some(user_id); *self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = Some(user_id);
log::info!("UserDataPaths: active user set to {}", user_id); log::info!("UserDataPaths: active user set to {}", user_id);
} }
/// Clear the current user on logout /// Clear the current user on logout
pub fn clear_user(&self) { pub fn clear_user(&self) {
*self.user_id.write().expect("UserDataPaths write lock poisoned") = None; *self
.user_id
.write()
.expect("UserDataPaths write lock poisoned") = None;
log::info!("UserDataPaths: active user cleared"); log::info!("UserDataPaths: active user cleared");
} }
/// Get the current user ID, if set /// Get the current user ID, if set
pub fn current_user_id(&self) -> Option<u64> { pub fn current_user_id(&self) -> Option<u64> {
*self.user_id.read().expect("UserDataPaths read lock poisoned") *self
.user_id
.read()
.expect("UserDataPaths read lock poisoned")
} }
/// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/ /// Get the user-scoped data directory: ~/.local/share/qbz/users/{uid}/
pub fn user_data_dir(&self) -> Result<PathBuf, String> { pub fn user_data_dir(&self) -> Result<PathBuf, String> {
let uid = self.user_id.read() let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))? .map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?; .ok_or("No active user - please log in")?;
@@ -56,7 +67,9 @@ impl UserDataPaths {
/// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/ /// Get the user-scoped cache directory: ~/.cache/qbz/users/{uid}/
pub fn user_cache_dir(&self) -> Result<PathBuf, String> { pub fn user_cache_dir(&self) -> Result<PathBuf, String> {
let uid = self.user_id.read() let uid = self
.user_id
.read()
.map_err(|e| format!("UserDataPaths read lock error: {}", e))? .map_err(|e| format!("UserDataPaths read lock error: {}", e))?
.ok_or("No active user - please log in")?; .ok_or("No active user - please log in")?;
+22 -26
View File
@@ -3,17 +3,17 @@
//! Runs on a dedicated thread, completely separate from audio playback. //! Runs on a dedicated thread, completely separate from audio playback.
//! Uses spectrum-analyzer crate for efficient FFT computation. //! Uses spectrum-analyzer crate for efficient FFT computation.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use qbz_audio::SpectralAnalyzer; use qbz_audio::SpectralAnalyzer;
use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit};
use spectrum_analyzer::scaling::divide_by_N_sqrt; use spectrum_analyzer::scaling::divide_by_N_sqrt;
use spectrum_analyzer::windows::hann_window; use spectrum_analyzer::windows::hann_window;
use spectrum_analyzer::{samples_fft_to_spectrum, FrequencyLimit};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use super::{NUM_BARS, FFT_SIZE, TARGET_FPS, RingBuffer}; use super::{RingBuffer, FFT_SIZE, NUM_BARS, TARGET_FPS};
/// Shared state for the visualizer thread /// Shared state for the visualizer thread
pub struct VisualizerState { pub struct VisualizerState {
@@ -128,16 +128,14 @@ fn run_fft_loop(state: VisualizerState, app_handle: AppHandle) {
if new > smoothed[i] { if new > smoothed[i] {
smoothed[i] = smoothed[i] * 0.3 + new * 0.7; // Fast attack smoothed[i] = smoothed[i] * 0.3 + new * 0.7; // Fast attack
} else { } else {
smoothed[i] = smoothed[i] * SMOOTHING + new * (1.0 - SMOOTHING); // Slow decay smoothed[i] = smoothed[i] * SMOOTHING + new * (1.0 - SMOOTHING);
// Slow decay
} }
output[i] = smoothed[i]; output[i] = smoothed[i];
} }
// Send to frontend as binary data // Send to frontend as binary data
let bytes: Vec<u8> = output let bytes: Vec<u8> = output.iter().flat_map(|f| f.to_le_bytes()).collect();
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
let _ = app_handle.emit("viz:data", bytes); let _ = app_handle.emit("viz:data", bytes);
@@ -163,17 +161,17 @@ fn run_fft_loop(state: VisualizerState, app_handle: AppHandle) {
let compressed = (rms * 6.0).powf(0.5).clamp(0.0, 1.0); let compressed = (rms * 6.0).powf(0.5).clamp(0.0, 1.0);
// Smooth: fast attack, slow decay // Smooth: fast attack, slow decay
if compressed > smoothed_energy[band_idx] { if compressed > smoothed_energy[band_idx] {
smoothed_energy[band_idx] = smoothed_energy[band_idx] * 0.2 + compressed * 0.8; smoothed_energy[band_idx] =
smoothed_energy[band_idx] * 0.2 + compressed * 0.8;
} else { } else {
smoothed_energy[band_idx] = smoothed_energy[band_idx] * 0.85 + compressed * 0.15; smoothed_energy[band_idx] =
smoothed_energy[band_idx] * 0.85 + compressed * 0.15;
} }
energy_bands[band_idx] = smoothed_energy[band_idx]; energy_bands[band_idx] = smoothed_energy[band_idx];
} }
let energy_bytes: Vec<u8> = energy_bands let energy_bytes: Vec<u8> =
.iter() energy_bands.iter().flat_map(|f| f.to_le_bytes()).collect();
.flat_map(|f| f.to_le_bytes())
.collect();
let _ = app_handle.emit("viz:energy", energy_bytes); let _ = app_handle.emit("viz:energy", energy_bytes);
// --- Transient Detection: detect sharp RMS jumps --- // --- Transient Detection: detect sharp RMS jumps ---
@@ -192,7 +190,11 @@ fn run_fft_loop(state: VisualizerState, app_handle: AppHandle) {
cnt += 1; cnt += 1;
} }
} }
let band_rms = if cnt > 0 { (sum_sq / cnt as f32).sqrt() } else { 0.0 }; let band_rms = if cnt > 0 {
(sum_sq / cnt as f32).sqrt()
} else {
0.0
};
// Bass/sub-bass weighted 2x for beat detection // Bass/sub-bass weighted 2x for beat detection
let weight = if band_idx < 2 { 2.0 } else { 1.0 }; let weight = if band_idx < 2 { 2.0 } else { 1.0 };
raw_sum += (band_rms * 6.0).powf(0.5).clamp(0.0, 1.0) * weight; raw_sum += (band_rms * 6.0).powf(0.5).clamp(0.0, 1.0) * weight;
@@ -227,13 +229,11 @@ fn run_fft_loop(state: VisualizerState, app_handle: AppHandle) {
let step = stereo_pairs / WAVEFORM_POINTS; // 512/256 = 2 let step = stereo_pairs / WAVEFORM_POINTS; // 512/256 = 2
for i in 0..WAVEFORM_POINTS { for i in 0..WAVEFORM_POINTS {
let base = i * step * 2; // index into interleaved buffer let base = i * step * 2; // index into interleaved buffer
waveform_buf[i] = samples[base]; // L waveform_buf[i] = samples[base]; // L
waveform_buf[WAVEFORM_POINTS + i] = samples[base + 1]; // R waveform_buf[WAVEFORM_POINTS + i] = samples[base + 1]; // R
} }
let waveform_bytes: Vec<u8> = waveform_buf let waveform_bytes: Vec<u8> =
.iter() waveform_buf.iter().flat_map(|f| f.to_le_bytes()).collect();
.flat_map(|f| f.to_le_bytes())
.collect();
let _ = app_handle.emit("viz:waveform", waveform_bytes); let _ = app_handle.emit("viz:waveform", waveform_bytes);
} }
@@ -293,11 +293,7 @@ fn map_to_log_bars(spectrum: &spectrum_analyzer::FrequencySpectrum, output: &mut
} }
// Average magnitude for this bar // Average magnitude for this bar
let avg = if count > 0 { let avg = if count > 0 { sum / count as f32 } else { 0.0 };
sum / count as f32
} else {
0.0
};
// Apply dynamic range compression and normalize // Apply dynamic range compression and normalize
// This makes quiet passages more visible while preventing clipping // This makes quiet passages more visible while preventing clipping
+5 -2
View File
@@ -5,8 +5,8 @@
//! on a dedicated thread. //! on a dedicated thread.
mod fft_processor; mod fft_processor;
pub use fft_processor::{start_visualizer_thread, VisualizerState};
pub use qbz_audio::{RingBuffer, TappedSource, VisualizerTap}; pub use qbz_audio::{RingBuffer, TappedSource, VisualizerTap};
pub use fft_processor::{VisualizerState, start_visualizer_thread};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tauri::AppHandle; use tauri::AppHandle;
@@ -60,7 +60,10 @@ impl Visualizer {
/// Enable or disable visualization /// Enable or disable visualization
pub fn set_enabled(&self, enabled: bool) { pub fn set_enabled(&self, enabled: bool) {
self.tap.set_enabled(enabled); self.tap.set_enabled(enabled);
log::info!("Visualizer {}", if enabled { "enabled" } else { "disabled" }); log::info!(
"Visualizer {}",
if enabled { "enabled" } else { "disabled" }
);
} }
/// Check if visualization is enabled /// Check if visualization is enabled
+5 -4
View File
@@ -7,7 +7,10 @@ use qbz_nix_lib::{api::QobuzClient, credentials};
// These tests must never print credentials or tokens. // These tests must never print credentials or tokens.
fn live_tests_enabled() -> bool { fn live_tests_enabled() -> bool {
matches!(std::env::var("QBZ_RUN_QOBUZ_LIVE_TESTS").as_deref(), Ok("1")) matches!(
std::env::var("QBZ_RUN_QOBUZ_LIVE_TESTS").as_deref(),
Ok("1")
)
} }
#[tokio::test] #[tokio::test]
@@ -17,8 +20,7 @@ async fn qobuz_login_and_favorites_smoke() {
return; return;
} }
let Some(creds) = credentials::load_qobuz_credentials() let Some(creds) = credentials::load_qobuz_credentials().expect("Failed to load credentials")
.expect("Failed to load credentials")
else { else {
return; return;
}; };
@@ -37,4 +39,3 @@ async fn qobuz_login_and_favorites_smoke() {
.await .await
.expect("get_favorites failed"); .expect("get_favorites failed");
} }