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:
1 parent
f9313c2d80
commit
3a297c0b2b
78 files changed
+4593
-2697
No files matched your search
@@ -19,10 +19,7 @@ pub fn generate_signature(method: &str, params: &str, timestamp: u64, secret: &s
|
|||||||
|
|
||||||
/// Generate signature for track/getFileUrl endpoint
|
/// Generate signature for track/getFileUrl endpoint
|
||||||
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
|
pub fn sign_get_file_url(track_id: u64, format_id: u32, timestamp: u64, secret: &str) -> String {
|
||||||
let params = format!(
|
let params = format!("format_id{}intentstreamtrack_id{}", format_id, track_id);
|
||||||
"format_id{}intentstreamtrack_id{}",
|
|
||||||
format_id, track_id
|
|
||||||
);
|
|
||||||
generate_signature("trackgetFileUrl", ¶ms, timestamp, secret)
|
generate_signature("trackgetFileUrl", ¶ms, timestamp, secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +94,12 @@ pub fn parse_login_response(response: &serde_json::Value) -> Result<UserSession>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try common timestamp fields (seconds).
|
// Try common timestamp fields (seconds).
|
||||||
let ts_keys = ["end_date_ts", "expires_at_ts", "expiration_ts", "valid_until_ts"];
|
let ts_keys = [
|
||||||
|
"end_date_ts",
|
||||||
|
"expires_at_ts",
|
||||||
|
"expiration_ts",
|
||||||
|
"valid_until_ts",
|
||||||
|
];
|
||||||
for key in ts_keys {
|
for key in ts_keys {
|
||||||
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
|
if let Some(ts) = parameters.get(key).and_then(|v| v.as_i64()) {
|
||||||
if ts > 0 {
|
if ts > 0 {
|
||||||
|
|||||||
+23
-20
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+1
-7
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+550
-251
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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)",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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))?;
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) => {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user