From b7048fae7435ab8ae61e573da6ee425839860ad7 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:10:42 -0600 Subject: [PATCH 01/14] feat: register qbz:// URI scheme for OAuth callbacks Add URI scheme handler registration for OAuth flow implementation: - Register x-scheme-handler/qbz in .desktop files - Add %u parameter to Exec for URL handling - Add OpenURI portal permission in Flatpak manifest This enables OAuth PKCE flows for Spotify and other providers to redirect back to the application after browser authentication. Files modified: - packaging/arch/qbz.desktop - packaging/aur/qbz.desktop - packaging/flatpak/com.blitzkriegfc.qbz.desktop - packaging/flatpak/com.blitzkriegfc.qbz.yml --- packaging/arch/qbz.desktop | 3 ++- packaging/aur/qbz.desktop | 3 ++- packaging/flatpak/com.blitzkriegfc.qbz.desktop | 3 ++- packaging/flatpak/com.blitzkriegfc.qbz.yml | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packaging/arch/qbz.desktop b/packaging/arch/qbz.desktop index 9b1b4330f..c12d37cc8 100644 --- a/packaging/arch/qbz.desktop +++ b/packaging/arch/qbz.desktop @@ -1,8 +1,9 @@ [Desktop Entry] Name=QBZ -Exec=qbz +Exec=qbz %u Type=Application Icon=qbz Categories=Audio;Music;Player; Terminal=false StartupWMClass=QBZ +MimeType=x-scheme-handler/qbz; diff --git a/packaging/aur/qbz.desktop b/packaging/aur/qbz.desktop index 26c00b58f..cea41f522 100644 --- a/packaging/aur/qbz.desktop +++ b/packaging/aur/qbz.desktop @@ -1,10 +1,11 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz +Exec=qbz %u Type=Application Icon=qbz Categories=Audio;Music;Player;AudioVideo; Terminal=false StartupWMClass=qbz-nix Keywords=music;audio;qobuz;hifi;streaming; +MimeType=x-scheme-handler/qbz; diff --git a/packaging/flatpak/com.blitzkriegfc.qbz.desktop b/packaging/flatpak/com.blitzkriegfc.qbz.desktop index a120a6bb3..ed6f7537c 100644 --- a/packaging/flatpak/com.blitzkriegfc.qbz.desktop +++ b/packaging/flatpak/com.blitzkriegfc.qbz.desktop @@ -1,8 +1,9 @@ [Desktop Entry] Name=QBZ -Exec=qbz +Exec=qbz %u Type=Application Icon=com.blitzkriegfc.qbz Categories=Audio;Music;Player; Terminal=false StartupWMClass=QBZ +MimeType=x-scheme-handler/qbz; diff --git a/packaging/flatpak/com.blitzkriegfc.qbz.yml b/packaging/flatpak/com.blitzkriegfc.qbz.yml index e0214c131..c52ca612f 100644 --- a/packaging/flatpak/com.blitzkriegfc.qbz.yml +++ b/packaging/flatpak/com.blitzkriegfc.qbz.yml @@ -26,6 +26,8 @@ finish-args: - --talk-name=org.mpris.MediaPlayer2.* # Own MPRIS name to register as media player (not just talk to others) - --own-name=org.mpris.MediaPlayer2.qbz_nix + # OAuth browser authentication + - --talk-name=org.freedesktop.portal.OpenURI modules: # intltool required for libdbusmenu From 3a49e3f6dd9a8a0f5fbffc080bb202e4807b5a93 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 16 Jan 2026 20:46:01 -0600 Subject: [PATCH 02/14] Remove embedded Last.fm API secret for Flathub compliance - Remove DEFAULT_API_SECRET constant and get_api_secret() function - Update Default impl to require user-provided secret - Update has_embedded_credentials() to only check API key - API secret will be handled by Cloudflare Workers proxy --- src-tauri/src/lastfm/mod.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/lastfm/mod.rs b/src-tauri/src/lastfm/mod.rs index 672b08df9..315fd3b7d 100644 --- a/src-tauri/src/lastfm/mod.rs +++ b/src-tauri/src/lastfm/mod.rs @@ -23,12 +23,12 @@ where const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; // Last.fm API credentials +// Only API key can be embedded - secret must be user-provided for Flathub compliance // Checked in order: // 1. Compile-time environment variables (for release builds) // 2. Runtime environment variables (for development with .env) // Note: Supports both LAST_FM_* and LASTFM_* naming conventions const DEFAULT_API_KEY: Option<&str> = option_env!("LAST_FM_API_KEY"); -const DEFAULT_API_SECRET: Option<&str> = option_env!("LAST_FM_API_SHARED_SECRET"); /// Get API key from compile-time or runtime environment fn get_api_key() -> Option { @@ -38,14 +38,6 @@ fn get_api_key() -> Option { .or_else(|| std::env::var("LASTFM_API_KEY").ok()) } -/// Get API secret from compile-time or runtime environment -fn get_api_secret() -> Option { - DEFAULT_API_SECRET - .map(String::from) - .or_else(|| std::env::var("LAST_FM_API_SHARED_SECRET").ok()) - .or_else(|| std::env::var("LASTFM_API_SECRET").ok()) -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastFmSession { pub name: String, @@ -81,17 +73,19 @@ pub struct LastFmClient { impl Default for LastFmClient { fn default() -> Self { + // API key can be embedded, but secret must be user-provided Self::new( get_api_key().unwrap_or_default(), - get_api_secret().unwrap_or_default(), + String::new(), // Empty secret - user must provide via set_credentials ) } } impl LastFmClient { - /// Check if embedded (build-time or runtime) credentials are available + /// Check if embedded API key is available + /// Note: API secret is never embedded and must be user-provided pub fn has_embedded_credentials() -> bool { - get_api_key().is_some() && get_api_secret().is_some() + get_api_key().is_some() } } From 179a6b7c54704dc71824660cdb0e2790143a7bfa Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:09:18 -0600 Subject: [PATCH 03/14] Refactor Last.fm integration to use Cloudflare Workers proxy - Update LASTFM_API_URL to point to proxy endpoint - Remove MD5 signature generation (proxy handles it) - Remove api_key and api_secret from LastFmClient (proxy manages credentials) - Simplify client to only handle session_key - Update get_token() to return (token, authUrl) tuple from proxy - Update all methods (get_session, scrobble, update_now_playing) to use proxy - Add User-Agent header 'QBZ/1.0.0' for proxy validation - Update bootstrap.ts to skip credential restoration (proxy handles it) - Maintain backward compatibility with set_credentials as no-op --- src-tauri/src/commands/lastfm.rs | 9 +- src-tauri/src/lastfm/mod.rs | 217 +++++++++---------------------- src/lib/app/bootstrap.ts | 12 +- 3 files changed, 66 insertions(+), 172 deletions(-) diff --git a/src-tauri/src/commands/lastfm.rs b/src-tauri/src/commands/lastfm.rs index 3b687a2b1..946abb33c 100644 --- a/src-tauri/src/commands/lastfm.rs +++ b/src-tauri/src/commands/lastfm.rs @@ -50,13 +50,8 @@ pub async fn lastfm_get_auth_url(state: State<'_, AppState>) -> Result<(String, log::info!("Command: lastfm_get_auth_url"); let client = state.lastfm.lock().await; - if !client.has_credentials() { - return Err("Last.fm API credentials not configured. Please set API key and secret in settings.".to_string()); - } - - let token = client.get_token().await?; - let url = client.get_auth_url(&token); - Ok((token, url)) + // Proxy handles credentials, always available + client.get_token().await } /// Complete Last.fm authentication with token diff --git a/src-tauri/src/lastfm/mod.rs b/src-tauri/src/lastfm/mod.rs index 315fd3b7d..7dd2c7cf5 100644 --- a/src-tauri/src/lastfm/mod.rs +++ b/src-tauri/src/lastfm/mod.rs @@ -1,11 +1,10 @@ //! Last.fm integration module //! -//! Handles Last.fm authentication and scrobbling +//! Handles Last.fm authentication and scrobbling via Cloudflare Workers proxy -use md5::{Digest, Md5}; use reqwest::Client; use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::BTreeMap; +use serde_json::json; /// Deserialize integer (0/1) as boolean - Last.fm API returns subscriber as number fn deserialize_int_bool<'de, D>(deserializer: D) -> Result @@ -20,23 +19,8 @@ where } } -const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; - -// Last.fm API credentials -// Only API key can be embedded - secret must be user-provided for Flathub compliance -// Checked in order: -// 1. Compile-time environment variables (for release builds) -// 2. Runtime environment variables (for development with .env) -// Note: Supports both LAST_FM_* and LASTFM_* naming conventions -const DEFAULT_API_KEY: Option<&str> = option_env!("LAST_FM_API_KEY"); - -/// Get API key from compile-time or runtime environment -fn get_api_key() -> Option { - DEFAULT_API_KEY - .map(String::from) - .or_else(|| std::env::var("LAST_FM_API_KEY").ok()) - .or_else(|| std::env::var("LASTFM_API_KEY").ok()) -} +// Cloudflare Workers proxy URL - handles API credentials and signature generation +const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastFmSession { @@ -54,6 +38,8 @@ struct AuthGetSessionResponse { #[derive(Debug, Deserialize)] struct AuthGetTokenResponse { token: String, + #[serde(rename = "authUrl")] + auth_url: Option, } #[derive(Debug, Deserialize)] @@ -64,37 +50,36 @@ enum LastFmResponse { } /// Last.fm API client +/// Uses Cloudflare Workers proxy to handle API credentials and signature generation pub struct LastFmClient { client: Client, - api_key: String, - api_secret: String, session_key: Option, } impl Default for LastFmClient { fn default() -> Self { - // API key can be embedded, but secret must be user-provided - Self::new( - get_api_key().unwrap_or_default(), - String::new(), // Empty secret - user must provide via set_credentials - ) + Self::new() } } impl LastFmClient { - /// Check if embedded API key is available - /// Note: API secret is never embedded and must be user-provided + /// Check if embedded credentials are available (always true - proxy handles them) pub fn has_embedded_credentials() -> bool { - get_api_key().is_some() + true } -} -impl LastFmClient { - pub fn new(api_key: String, api_secret: String) -> Self { + pub fn new() -> Self { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("QBZ/1.0.0"), + ); + Self { - client: Client::new(), - api_key, - api_secret, + client: Client::builder() + .default_headers(headers) + .build() + .unwrap_or_else(|_| Client::new()), session_key: None, } } @@ -104,61 +89,28 @@ impl LastFmClient { } pub fn is_authenticated(&self) -> bool { - self.session_key.is_some() && !self.api_key.is_empty() + self.session_key.is_some() } + /// Compatibility: proxy handles credentials, this is a no-op pub fn has_credentials(&self) -> bool { - !self.api_key.is_empty() && !self.api_secret.is_empty() + true } - pub fn set_credentials(&mut self, api_key: String, api_secret: String) { - self.api_key = api_key; - self.api_secret = api_secret; + /// Compatibility: proxy handles credentials, this is a no-op + pub fn set_credentials(&mut self, _api_key: String, _api_secret: String) { + // No-op: proxy handles credentials } - /// Generate API signature for authenticated requests - fn generate_signature(&self, params: &BTreeMap<&str, &str>) -> String { - let mut sig_string = String::new(); - for (key, value) in params { - sig_string.push_str(key); - sig_string.push_str(value); - } - sig_string.push_str(&self.api_secret); - - let mut hasher = Md5::new(); - hasher.update(sig_string.as_bytes()); - format!("{:x}", hasher.finalize()) - } - - /// Get authorization URL for user to visit - pub fn get_auth_url(&self, token: &str) -> String { - format!( - "https://www.last.fm/api/auth/?api_key={}&token={}", - self.api_key, token - ) - } - - /// Get a request token for authentication - pub async fn get_token(&self) -> Result { - if !self.has_credentials() { - return Err("Last.fm API credentials not configured".to_string()); - } - - let mut params = BTreeMap::new(); - params.insert("method", "auth.getToken"); - params.insert("api_key", &self.api_key); - - let sig = self.generate_signature(¶ms); + /// Get a request token and authorization URL for authentication + /// Returns: (token, auth_url) + pub async fn get_token(&self) -> Result<(String, String), String> { + let url = format!("{}/auth.getToken", LASTFM_PROXY_URL); let response = self .client - .get(LASTFM_API_URL) - .query(&[ - ("method", "auth.getToken"), - ("api_key", &self.api_key), - ("api_sig", &sig), - ("format", "json"), - ]) + .post(&url) + .json(&json!({})) .send() .await .map_err(|e| format!("Failed to get token: {}", e))?; @@ -169,37 +121,26 @@ impl LastFmClient { .map_err(|e| format!("Failed to parse response: {}", e))?; match data { - LastFmResponse::Success(r) => Ok(r.token), + LastFmResponse::Success(r) => { + let auth_url = r.auth_url.unwrap_or_else(|| { + format!("https://www.last.fm/api/auth/?token={}", r.token) + }); + Ok((r.token, auth_url)) + } LastFmResponse::Error { message, .. } => Err(message), } } /// Get session key after user has authorized pub async fn get_session(&mut self, token: &str) -> Result { - if !self.has_credentials() { - return Err("Last.fm API credentials not configured".to_string()); - } - log::info!("Getting Last.fm session with token: {}...", &token[..token.len().min(8)]); - let mut params = BTreeMap::new(); - params.insert("method", "auth.getSession"); - params.insert("api_key", &self.api_key); - params.insert("token", token); - - let sig = self.generate_signature(¶ms); - log::debug!("Generated signature for auth.getSession"); + let url = format!("{}/auth.getSession", LASTFM_PROXY_URL); let response = self .client - .get(LASTFM_API_URL) - .query(&[ - ("method", "auth.getSession"), - ("api_key", &self.api_key), - ("token", token), - ("api_sig", &sig), - ("format", "json"), - ]) + .post(&url) + .json(&json!({ "token": token })) .send() .await .map_err(|e| format!("Failed to get session: {}", e))?; @@ -240,40 +181,23 @@ impl LastFmClient { .as_ref() .ok_or("Not authenticated with Last.fm")?; - let timestamp_str = timestamp.to_string(); - let api_key = &self.api_key; + let url = format!("{}/track.scrobble", LASTFM_PROXY_URL); + + let mut body = json!({ + "sk": session_key, + "artist": artist, + "track": track, + "timestamp": timestamp.to_string(), + }); - let mut params = BTreeMap::new(); - params.insert("method", "track.scrobble"); - params.insert("api_key", api_key.as_str()); - params.insert("sk", session_key.as_str()); - params.insert("artist", artist); - params.insert("track", track); - params.insert("timestamp", ×tamp_str); if let Some(album_name) = album { - params.insert("album", album_name); - } - - let sig = self.generate_signature(¶ms); - - let mut form_params = vec![ - ("method", "track.scrobble"), - ("api_key", api_key.as_str()), - ("sk", session_key.as_str()), - ("artist", artist), - ("track", track), - ("timestamp", ×tamp_str), - ("api_sig", &sig), - ("format", "json"), - ]; - if let Some(album_name) = album { - form_params.push(("album", album_name)); + body["album"] = json!(album_name); } let response = self .client - .post(LASTFM_API_URL) - .form(&form_params) + .post(&url) + .json(&body) .send() .await .map_err(|e| format!("Failed to scrobble: {}", e))?; @@ -299,37 +223,22 @@ impl LastFmClient { .as_ref() .ok_or("Not authenticated with Last.fm")?; - let api_key = &self.api_key; + let url = format!("{}/track.updateNowPlaying", LASTFM_PROXY_URL); + + let mut body = json!({ + "sk": session_key, + "artist": artist, + "track": track, + }); - let mut params = BTreeMap::new(); - params.insert("method", "track.updateNowPlaying"); - params.insert("api_key", api_key.as_str()); - params.insert("sk", session_key.as_str()); - params.insert("artist", artist); - params.insert("track", track); if let Some(album_name) = album { - params.insert("album", album_name); - } - - let sig = self.generate_signature(¶ms); - - let mut form_params = vec![ - ("method", "track.updateNowPlaying"), - ("api_key", api_key.as_str()), - ("sk", session_key.as_str()), - ("artist", artist), - ("track", track), - ("api_sig", &sig), - ("format", "json"), - ]; - if let Some(album_name) = album { - form_params.push(("album", album_name)); + body["album"] = json!(album_name); } let response = self .client - .post(LASTFM_API_URL) - .form(&form_params) + .post(&url) + .json(&body) .send() .await .map_err(|e| format!("Failed to update now playing: {}", e))?; diff --git a/src/lib/app/bootstrap.ts b/src/lib/app/bootstrap.ts index 0fd4dc2f3..6902ca0d5 100644 --- a/src/lib/app/bootstrap.ts +++ b/src/lib/app/bootstrap.ts @@ -47,19 +47,9 @@ export async function applySavedZoom(): Promise { */ export async function restoreLastfmSession(): Promise { try { - const savedApiKey = localStorage.getItem('qbz-lastfm-api-key'); - const savedApiSecret = localStorage.getItem('qbz-lastfm-api-secret'); const savedSessionKey = localStorage.getItem('qbz-lastfm-session-key'); - // Restore credentials if user-provided - if (savedApiKey && savedApiSecret) { - await invoke('lastfm_set_credentials', { - apiKey: savedApiKey, - apiSecret: savedApiSecret - }); - } - - // Restore session if available + // Restore session if available (proxy handles credentials) if (savedSessionKey) { await invoke('lastfm_set_session', { sessionKey: savedSessionKey }); console.log('Last.fm session restored on startup'); From 9099123091bef9a0eee443812696c2823a4f6bce Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:19:51 -0600 Subject: [PATCH 04/14] Refactor Spotify and Tidal playlist import to use proxy - Update Spotify to use Cloudflare Workers proxy endpoint - Update Tidal to use Cloudflare Workers proxy endpoint - Remove embedded CLIENT_ID and CLIENT_SECRET constants - Remove Base64 encoding and client credentials flow (proxy handles it) - Add User-Agent 'QBZ/1.0.0' for proxy validation - Maintain backward compatibility with user_creds parameter (ignored) --- .../src/playlist_import/providers/spotify.rs | 49 ++++++--------- .../src/playlist_import/providers/tidal.rs | 62 +++++++------------ 2 files changed, 40 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/playlist_import/providers/spotify.rs b/src-tauri/src/playlist_import/providers/spotify.rs index 8b0928536..6bd8ee5ae 100644 --- a/src-tauri/src/playlist_import/providers/spotify.rs +++ b/src-tauri/src/playlist_import/providers/spotify.rs @@ -1,22 +1,15 @@ //! Spotify playlist import -use std::env; - -use base64::engine::general_purpose::STANDARD; -use base64::Engine; use serde_json::Value; use crate::playlist_import::errors::PlaylistImportError; use crate::playlist_import::models::{ImportPlaylist, ImportProvider, ImportTrack}; use crate::playlist_import::providers::ProviderCredentials; -const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token"; +// Cloudflare Workers proxy URL - handles credentials +const SPOTIFY_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/spotify"; const SPOTIFY_API_BASE: &str = "https://api.spotify.com/v1"; -// Compile-time embedded credentials (from build environment) -const EMBEDDED_CLIENT_ID: Option<&str> = option_env!("SPOTIFY_API_CLIENT_ID"); -const EMBEDDED_CLIENT_SECRET: Option<&str> = option_env!("SPOTIFY_API_CLIENT_SECRET"); - pub fn parse_playlist_id(url: &str) -> Option { if let Some(rest) = url.strip_prefix("spotify:playlist:") { if !rest.is_empty() { @@ -256,28 +249,22 @@ async fn fetch_playlist_from_embed(playlist_id: &str) -> Result) -> Result { - // Priority: user-provided > embedded > runtime env vars - let client_id = user_creds - .as_ref() - .and_then(|c| c.client_id.clone()) - .or_else(|| EMBEDDED_CLIENT_ID.map(String::from)) - .or_else(|| env::var("SPOTIFY_API_CLIENT_ID").ok()) - .ok_or_else(|| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_ID".to_string()))?; - let client_secret = user_creds - .as_ref() - .and_then(|c| c.client_secret.clone()) - .or_else(|| EMBEDDED_CLIENT_SECRET.map(String::from)) - .or_else(|| env::var("SPOTIFY_API_CLIENT_SECRET").ok()) - .ok_or_else(|| PlaylistImportError::MissingCredentials("SPOTIFY_API_CLIENT_SECRET".to_string()))?; +async fn get_app_token(_user_creds: Option) -> Result { + // Proxy handles credentials - user_creds ignored (compatibility) + let url = format!("{}/token", SPOTIFY_PROXY_URL); - let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret)); - - let response: Value = reqwest::Client::new() - .post(SPOTIFY_TOKEN_URL) - .header("Authorization", format!("Basic {}", auth)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body("grant_type=client_credentials") + let response: Value = reqwest::Client::builder() + .default_headers({ + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("QBZ/1.0.0"), + ); + headers + }) + .build() + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .get(&url) .send() .await .map_err(|e| PlaylistImportError::Http(e.to_string()))? @@ -289,7 +276,7 @@ async fn get_app_token(user_creds: Option) -> Result) -> String { diff --git a/src-tauri/src/playlist_import/providers/tidal.rs b/src-tauri/src/playlist_import/providers/tidal.rs index d2b9133f1..7be61cdea 100644 --- a/src-tauri/src/playlist_import/providers/tidal.rs +++ b/src-tauri/src/playlist_import/providers/tidal.rs @@ -3,8 +3,6 @@ use std::env; use std::time::Duration; -use base64::engine::general_purpose::STANDARD; -use base64::Engine; use serde_json::Value; use tokio::time::sleep; @@ -14,13 +12,10 @@ use crate::playlist_import::providers::ProviderCredentials; const RATE_LIMIT_DELAY_MS: u64 = 200; // Delay between API calls to avoid 429 -const TIDAL_TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token"; +// Cloudflare Workers proxy URL - handles credentials +const TIDAL_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/tidal"; const TIDAL_API_BASE: &str = "https://openapi.tidal.com/v2"; -// Compile-time embedded credentials (from build environment) -const EMBEDDED_CLIENT_ID: Option<&str> = option_env!("TIDAL_API_CLIENT_ID"); -const EMBEDDED_CLIENT_SECRET: Option<&str> = option_env!("TIDAL_API_CLIENT_SECRET"); - pub fn parse_playlist_id(url: &str) -> Option { if !url.contains("tidal.com") { return None; @@ -336,45 +331,32 @@ fn parse_duration_ms(value: &str) -> Option { } } -async fn get_app_token(user_creds: Option) -> Result { - // Priority: user-provided > embedded > runtime env vars - let client_id = user_creds - .as_ref() - .and_then(|c| c.client_id.clone()) - .or_else(|| EMBEDDED_CLIENT_ID.map(String::from)) - .or_else(|| env::var("TIDAL_API_CLIENT_ID").ok()) - .ok_or_else(|| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_ID".to_string()))?; - let client_secret = user_creds - .as_ref() - .and_then(|c| c.client_secret.clone()) - .or_else(|| EMBEDDED_CLIENT_SECRET.map(String::from)) - .or_else(|| env::var("TIDAL_API_CLIENT_SECRET").ok()) - .ok_or_else(|| PlaylistImportError::MissingCredentials("TIDAL_API_CLIENT_SECRET".to_string()))?; +async fn get_app_token(_user_creds: Option) -> Result { + // Proxy handles credentials - user_creds ignored (compatibility) + let url = format!("{}/token", TIDAL_PROXY_URL); - let auth = STANDARD.encode(format!("{}:{}", client_id, client_secret)); - - let resp = reqwest::Client::new() - .post(TIDAL_TOKEN_URL) - .header("Authorization", format!("Basic {}", auth)) - .header("Content-Type", "application/x-www-form-urlencoded") - .body("grant_type=client_credentials") + let response: Value = reqwest::Client::builder() + .default_headers({ + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("QBZ/1.0.0"), + ); + headers + }) + .build() + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .get(&url) .send() .await - .map_err(|e| PlaylistImportError::Http(e.to_string()))?; - - let status = resp.status(); - let body = resp.text().await.map_err(|e| PlaylistImportError::Parse(e.to_string()))?; - - if !status.is_success() { - return Err(PlaylistImportError::Http(format!("Tidal auth failed: {} - {}", status, body))); - } - - let response: Value = serde_json::from_str(&body) - .map_err(|e| PlaylistImportError::Parse(format!("Invalid JSON: {} - body: {}", e, &body[..body.len().min(200)])))?; + .map_err(|e| PlaylistImportError::Http(e.to_string()))? + .json() + .await + .map_err(|e| PlaylistImportError::Parse(e.to_string()))?; response .get("access_token") .and_then(|v| v.as_str()) .map(|v| v.to_string()) - .ok_or_else(|| PlaylistImportError::Parse(format!("Tidal token missing access_token. Response: {}", body))) + .ok_or_else(|| PlaylistImportError::Parse("Tidal proxy missing access_token".to_string())) } From dd70f0a8c45e55a435512c4ec9cacc195eec908c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:31:10 -0600 Subject: [PATCH 05/14] Refactor Discogs integration to use Cloudflare Workers proxy - Update DISCOGS_API_URL to point to proxy endpoint - Remove embedded CONSUMER_KEY and CONSUMER_SECRET constants - Simplify DiscogsClient to only handle HTTP requests - Update search_release() to use proxy /search endpoint - Update search_artist() to use proxy /search endpoint - Update download_image() to use proxy /image endpoint - Add User-Agent 'QBZ/1.0.0' for proxy validation - Maintain backward compatibility with with_user_credentials (no-op) --- src-tauri/src/discogs/mod.rs | 89 +++++++++++++----------------------- 1 file changed, 32 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/discogs/mod.rs b/src-tauri/src/discogs/mod.rs index caa42b89f..89bfab967 100644 --- a/src-tauri/src/discogs/mod.rs +++ b/src-tauri/src/discogs/mod.rs @@ -1,6 +1,6 @@ //! Discogs API client for fetching album artwork //! -//! Uses the Discogs database API to search for releases and download cover images. +//! Uses Cloudflare Workers proxy to search the Discogs database and download cover images. use reqwest::Client; use serde::Deserialize; @@ -8,11 +8,12 @@ use std::fs; use std::path::Path; use std::time::Duration; +// Cloudflare Workers proxy URL - handles credentials +const DISCOGS_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/discogs"; + /// Discogs API client pub struct DiscogsClient { client: Client, - consumer_key: Option, - consumer_secret: Option, } /// Search result from Discogs API @@ -31,46 +32,35 @@ pub struct SearchResult { pub result_type: String, } -// Compile-time embedded credentials (from build environment) -const EMBEDDED_CONSUMER_KEY: Option<&str> = option_env!("DISCOGS_API_CLIENT_KEY"); -const EMBEDDED_CONSUMER_SECRET: Option<&str> = option_env!("DISCOGS_API_CLIENT_SECRET"); - impl DiscogsClient { - /// Create a new Discogs client with optional credentials - /// Priority: user-provided > embedded > runtime env vars + /// Create a new Discogs client (proxy handles credentials) pub fn new() -> Self { Self::with_user_credentials(None, None) } - /// Create a new Discogs client with user-provided credentials (override) + /// Create a new Discogs client - compatibility method (proxy handles credentials) pub fn with_user_credentials( - user_key: Option, - user_secret: Option, + _user_key: Option, + _user_secret: Option, ) -> Self { - // Priority: user-provided > embedded > runtime env vars - let consumer_key = user_key - .or_else(|| EMBEDDED_CONSUMER_KEY.map(String::from)) - .or_else(|| std::env::var("DISCOGS_API_CLIENT_KEY").ok()); - let consumer_secret = user_secret - .or_else(|| EMBEDDED_CONSUMER_SECRET.map(String::from)) - .or_else(|| std::env::var("DISCOGS_API_CLIENT_SECRET").ok()); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static("QBZ/1.0.0"), + ); let client = Client::builder() .timeout(Duration::from_secs(10)) - .user_agent("QBZ/0.1.0 +https://github.com/vicrodh/qbz") + .default_headers(headers) .build() .expect("Failed to create HTTP client"); - Self { - client, - consumer_key, - consumer_secret, - } + Self { client } } - /// Check if credentials are configured + /// Check if credentials are configured (always true - proxy handles credentials) pub fn has_credentials(&self) -> bool { - self.consumer_key.is_some() && self.consumer_secret.is_some() + true } /// Search for album artwork and download if found @@ -81,11 +71,6 @@ impl DiscogsClient { album: &str, cache_dir: &Path, ) -> Option { - if !self.has_credentials() { - log::debug!("Discogs credentials not configured, skipping artwork fetch"); - return None; - } - // Search for the release let cover_url = self.search_release(artist, album).await?; @@ -109,16 +94,12 @@ impl DiscogsClient { /// Search for a release and return the cover image URL async fn search_release(&self, artist: &str, album: &str) -> Option { - let key = self.consumer_key.as_ref()?; - let secret = self.consumer_secret.as_ref()?; - // Build search query let query = format!("{} {}", artist, album); let url = format!( - "https://api.discogs.com/database/search?q={}&type=release&key={}&secret={}", - urlencoding::encode(&query), - key, - secret + "{}/search?q={}&type=release", + DISCOGS_PROXY_URL, + urlencoding::encode(&query) ); log::debug!("Searching Discogs for: {} - {}", artist, album); @@ -156,18 +137,10 @@ impl DiscogsClient { /// Search for artists and return search results pub async fn search_artist(&self, query: &str) -> Result { - if !self.has_credentials() { - return Err("Discogs credentials not configured".to_string()); - } - - let key = self.consumer_key.as_ref().unwrap(); - let secret = self.consumer_secret.as_ref().unwrap(); - let url = format!( - "https://api.discogs.com/database/search?q={}&type=artist&key={}&secret={}", - urlencoding::encode(query), - key, - secret + "{}/search?q={}&type=artist", + DISCOGS_PROXY_URL, + urlencoding::encode(query) ); log::debug!("Searching Discogs for artist: {}", query); @@ -186,17 +159,19 @@ impl DiscogsClient { } /// Download an image to the cache directory - async fn download_image(&self, url: &str, path: &Path) -> Option<()> { - log::debug!("Downloading Discogs artwork: {}", url); + async fn download_image(&self, image_url: &str, path: &Path) -> Option<()> { + log::debug!("Downloading Discogs artwork: {}", image_url); - let key = self.consumer_key.as_ref()?; - let secret = self.consumer_secret.as_ref()?; + // Use proxy to download image with authentication + let proxy_url = format!( + "{}/image?url={}", + DISCOGS_PROXY_URL, + urlencoding::encode(image_url) + ); - // Discogs requires auth even for image downloads let response = self .client - .get(url) - .header("Authorization", format!("Discogs key={}, secret={}", key, secret)) + .get(&proxy_url) .send() .await .ok()?; From dab5c28727fa1551b1504a812d4d79d84993867e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:12:14 -0600 Subject: [PATCH 06/14] Add Discogs artwork selection UI in album edit modal Backend: - Add DiscogsImageOption struct for artwork options - Add discogs_search_artwork command to search multiple images - Add discogs_download_artwork command to download selected image - Extend DiscogsClient with search_artwork_options() method - Extend DiscogsClient with download_artwork_from_url() method - Register new commands in lib.rs Frontend: - Add Discogs image selection to album edit modal - Add 'Fetch from Discogs' button with Discogs logo - Display grid of artwork options from Discogs search results - Allow user to select preferred artwork option - Download and save selected artwork on Save - Add comprehensive CSS styling for Discogs UI elements - Adjust Discogs logo color filter for proper contrast User can now click 'Fetch from Discogs', browse multiple artwork options, select one, and save it as the album's cover art. --- src-tauri/src/discogs/mod.rs | 99 ++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/library/commands.rs | 29 +++ .../components/views/LocalLibraryView.svelte | 182 +++++++++++++++++- static/discogs_icon.svg | 12 ++ 5 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 static/discogs_icon.svg diff --git a/src-tauri/src/discogs/mod.rs b/src-tauri/src/discogs/mod.rs index 89bfab967..55565dc10 100644 --- a/src-tauri/src/discogs/mod.rs +++ b/src-tauri/src/discogs/mod.rs @@ -32,6 +32,16 @@ pub struct SearchResult { pub result_type: String, } +/// Image option for artwork selection +#[derive(Debug, Deserialize, serde::Serialize, Clone)] +pub struct DiscogsImageOption { + pub url: String, + pub width: u32, + pub height: u32, + #[serde(rename = "type")] + pub image_type: String, +} + impl DiscogsClient { /// Create a new Discogs client (proxy handles credentials) pub fn new() -> Self { @@ -135,6 +145,95 @@ impl DiscogsClient { None } + /// Search for album artwork options + /// Returns up to 10 image options from the first matching release + pub async fn search_artwork_options( + &self, + artist: &str, + album: &str, + ) -> Result, String> { + // Build search query + let query = format!("{} {}", artist, album); + let url = format!( + "{}/search?q={}&type=release", + DISCOGS_PROXY_URL, + urlencoding::encode(&query) + ); + + log::debug!("Searching Discogs artwork options for: {} - {}", artist, album); + + let response = self.client.get(&url).send().await + .map_err(|e| format!("Failed to search Discogs: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Discogs search failed with status: {}", response.status())); + } + + let search: SearchResponse = response.json().await + .map_err(|e| format!("Failed to parse Discogs response: {}", e))?; + + // Collect image options from search results + let mut images = Vec::new(); + + for result in search.results.iter().take(10) { + if result.result_type == "release" || result.result_type == "master" { + // Add cover image if available + if let Some(cover) = &result.cover_image { + if !cover.is_empty() && !cover.contains("spacer.gif") { + images.push(DiscogsImageOption { + url: cover.clone(), + width: 600, // Discogs standard size + height: 600, + image_type: "primary".to_string(), + }); + } + } + + // Add thumbnail as alternative + if let Some(thumb) = &result.thumb { + if !thumb.is_empty() && !thumb.contains("spacer.gif") { + images.push(DiscogsImageOption { + url: thumb.clone(), + width: 150, // Discogs thumb size + height: 150, + image_type: "secondary".to_string(), + }); + } + } + } + } + + if images.is_empty() { + return Err("No artwork found on Discogs".to_string()); + } + + // Return up to 10 unique images + images.truncate(10); + Ok(images) + } + + /// Download image from URL and return local path + pub async fn download_artwork_from_url( + &self, + image_url: &str, + cache_dir: &Path, + artist: &str, + album: &str, + ) -> Result { + // Generate cache filename + let filename = format!( + "discogs_{:x}.jpg", + Self::simple_hash(&format!("{}_{}", artist, album)) + ); + let cache_path = cache_dir.join(&filename); + + // Download the image + self.download_image(image_url, &cache_path).await + .ok_or_else(|| "Failed to download image".to_string())?; + + Ok(cache_path.to_string_lossy().to_string()) + } + /// Search for artists and return search results pub async fn search_artist(&self, query: &str) -> Result { let url = format!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4674c032d..64bed5647 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -417,6 +417,8 @@ pub fn run() { // Discogs artwork commands library::commands::discogs_has_credentials, library::commands::discogs_search_artist, + library::commands::discogs_search_artwork, + library::commands::discogs_download_artwork, library::commands::library_fetch_missing_artwork, library::commands::library_fetch_album_artwork, library::commands::library_set_album_artwork, diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index f87a67487..88ccce790 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -1712,3 +1712,32 @@ pub async fn playlist_get_offline_available( Ok(playlists.iter().map(|p| p.qobuz_playlist_id).collect()) } + +// === Discogs Artwork === + +/// Search Discogs for artwork options +#[tauri::command] +pub async fn discogs_search_artwork( + artist: String, + album: String, +) -> Result, String> { + log::info!("Command: discogs_search_artwork {} - {}", artist, album); + + let client = DiscogsClient::new(); + client.search_artwork_options(&artist, &album).await +} + +/// Download and save Discogs artwork +#[tauri::command] +pub async fn discogs_download_artwork( + image_url: String, + artist: String, + album: String, +) -> Result { + log::info!("Command: discogs_download_artwork from {}", image_url); + + let cache_dir = get_artwork_cache_dir(); + let client = DiscogsClient::new(); + + client.download_artwork_from_url(&image_url, &cache_dir, &artist, &album).await +} diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index e5d306020..ff33c0c25 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -113,6 +113,13 @@ lastScan: number | null; } + interface DiscogsImageOption { + url: string; + width: number; + height: number; + image_type: string; + } + interface Props { onAlbumClick?: (album: LocalAlbum) => void; onQobuzArtistClick?: (artistId: number) => void; @@ -181,6 +188,9 @@ let showAlbumEditModal = $state(false); let editingAlbumTitle = $state(''); let editingAlbumHidden = $state(false); + let discogsImageOptions = $state([]); + let selectedDiscogsImage = $state(null); + let fetchingDiscogsImages = $state(false); // Folder selection state (by folder ID) let selectedFolders = $state>(new Set()); @@ -809,20 +819,61 @@ if (!selectedAlbum) return; editingAlbumTitle = selectedAlbum.title; editingAlbumHidden = false; + discogsImageOptions = []; + selectedDiscogsImage = null; showAlbumEditModal = true; } + async function fetchDiscogsArtwork() { + if (!selectedAlbum || fetchingDiscogsImages) return; + + try { + fetchingDiscogsImages = true; + discogsImageOptions = []; + selectedDiscogsImage = null; + + const options = await invoke('discogs_search_artwork', { + artist: selectedAlbum.artist, + album: selectedAlbum.title + }); + + discogsImageOptions = options; + console.log(`Found ${options.length} Discogs artwork options`); + } catch (err) { + console.error('Failed to fetch Discogs artwork:', err); + alert(`Failed to fetch Discogs artwork: ${err}`); + } finally { + fetchingDiscogsImages = false; + } + } + async function saveAlbumEdit() { if (!selectedAlbum) return; try { + // If a Discogs image was selected, download and set it + if (selectedDiscogsImage) { + const localPath = await invoke('discogs_download_artwork', { + imageUrl: selectedDiscogsImage, + artist: selectedAlbum.artist, + album: selectedAlbum.title + }); + + await invoke('library_set_album_artwork', { + albumGroupKey: selectedAlbum.id, + artworkPath: localPath + }); + + applyAlbumArtworkUpdate(selectedAlbum.id, localPath); + } + await invoke('library_set_album_hidden', { albumGroupKey: selectedAlbum.id, hidden: editingAlbumHidden }); showAlbumEditModal = false; - + if (editingAlbumHidden) { clearLocalAlbum(); navGoBack(); @@ -2148,17 +2199,46 @@ {/if} - +
+ + +
+ {#if discogsImageOptions.length > 0} +
+ +
+ {#each discogsImageOptions as option, i} + + {/each} +
+

Click an image to select it, then click Save

+
+ {/if} +
@@ -3655,10 +3663,26 @@ left: 0; right: 0; padding: 4px 6px; - background: rgba(0, 0, 0, 0.7); + background: rgba(0, 0, 0, 0.8); color: white; font-size: 10px; text-align: center; + display: flex; + flex-direction: column; + gap: 2px; + } + + .discogs-option .release-title { + font-weight: 500; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .discogs-option .image-dims { + opacity: 0.8; + font-size: 9px; } .modal-footer { From 80ba2bf57a008efc58fdfa247527e19524d70a3f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:57:56 -0600 Subject: [PATCH 10/14] Fix Discogs artwork selection UI issues - Added carousel navigation for image options (4 per page) - Show page indicator and navigation buttons only when >4 images - Homogenized button sizes (Change Cover now matches Fetch from Discogs) - Fixed Save functionality with better error logging - Reset Discogs state when modal closes - Import ChevronLeft/ChevronRight icons for carousel navigation Users can now navigate through all available artwork options and successfully save selected images from Discogs. --- .../components/views/LocalLibraryView.svelte | 119 +++++++++++++++++- 1 file changed, 114 insertions(+), 5 deletions(-) diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index cd989289a..5041f3abe 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -5,7 +5,7 @@ import { HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw, Settings, ArrowLeft, X, Play, AlertCircle, ImageDown, Upload, Search, LayoutGrid, List, Edit3, - Network, Power, PowerOff + Network, Power, PowerOff, ChevronLeft, ChevronRight } from 'lucide-svelte'; import FolderSettingsModal from '../FolderSettingsModal.svelte'; import { t } from '$lib/i18n'; @@ -194,6 +194,8 @@ let discogsImageOptions = $state([]); let selectedDiscogsImage = $state(null); let fetchingDiscogsImages = $state(false); + let discogsImagePage = $state(0); + const IMAGES_PER_PAGE = 4; // Folder selection state (by folder ID) let selectedFolders = $state>(new Set()); @@ -834,6 +836,7 @@ fetchingDiscogsImages = true; discogsImageOptions = []; selectedDiscogsImage = null; + discogsImagePage = 0; const options = await invoke('discogs_search_artwork', { artist: selectedAlbum.artist, @@ -851,23 +854,54 @@ } } + // Computed values for Discogs image pagination + const paginatedDiscogsImages = $derived( + discogsImageOptions.slice( + discogsImagePage * IMAGES_PER_PAGE, + (discogsImagePage + 1) * IMAGES_PER_PAGE + ) + ); + + const hasMoreDiscogsPages = $derived( + discogsImageOptions.length > (discogsImagePage + 1) * IMAGES_PER_PAGE + ); + + const hasPrevDiscogsPages = $derived(discogsImagePage > 0); + + function nextDiscogsPage() { + if (hasMoreDiscogsPages) { + discogsImagePage++; + } + } + + function prevDiscogsPage() { + if (hasPrevDiscogsPages) { + discogsImagePage--; + } + } + async function saveAlbumEdit() { if (!selectedAlbum) return; try { // If a Discogs image was selected, download and set it if (selectedDiscogsImage) { + console.log('Downloading Discogs artwork from:', selectedDiscogsImage); + const localPath = await invoke('discogs_download_artwork', { imageUrl: selectedDiscogsImage, artist: selectedAlbum.artist, album: selectedAlbum.title }); + console.log('Downloaded to:', localPath); + await invoke('library_set_album_artwork', { albumGroupKey: selectedAlbum.id, artworkPath: localPath }); + console.log('Set album artwork successfully'); applyAlbumArtworkUpdate(selectedAlbum.id, localPath); } @@ -876,6 +910,11 @@ hidden: editingAlbumHidden }); + // Reset Discogs state + discogsImageOptions = []; + selectedDiscogsImage = null; + discogsImagePage = 0; + showAlbumEditModal = false; if (editingAlbumHidden) { @@ -2206,7 +2245,7 @@ {/if}
+ + {discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)} + + +
+ {/if} +
- {#each discogsImageOptions as option, i} + {#each paginatedDiscogsImages as option, i}