Merge feature/api-keys-refactor: Remove embedded API keys and add Discogs artwork selector

This major refactor achieves Flathub compliance by removing all embedded
API credentials from the binary. All API requests now go through a
Cloudflare Workers proxy for secure credential management.

New Features:
- Discogs artwork selection UI with multi-release support
- Catalog number support for audiophile libraries
- Carousel navigation for artwork options
- Release metadata display (title, year)

Technical Changes:
- Cloudflare Workers proxy for Last.fm, Spotify, Tidal, Discogs
- Database migration adds catalog_number column
- URI scheme registration (qbz://) for OAuth flows
- Rate limiting and User-Agent validation server-side

See CHANGELOG-api-keys-refactor.md for complete details.
See POST-MERGE-CLEANUP.md for removing legacy API Keys UI.
This commit is contained in:
vicrodh committed 2026-01-17 16:48:40 -06:00
commit 9516a1c1fa
18 files changed
+2264 -325

No files matched your search

+2 -7
View File
@@ -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
+244 -55
View File
@@ -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<String>,
consumer_secret: Option<String>,
}
/// Search result from Discogs API
@@ -31,46 +32,66 @@ 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");
/// 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,
pub release_title: Option<String>,
pub release_year: Option<u32>,
}
/// Release details from Discogs API
#[derive(Debug, Deserialize)]
struct ReleaseDetails {
id: u64,
title: String,
year: Option<u32>,
images: Option<Vec<ReleaseImage>>,
}
/// Image from release details
#[derive(Debug, Deserialize)]
struct ReleaseImage {
uri: String,
width: u32,
height: u32,
#[serde(rename = "type")]
image_type: String,
}
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<String>,
user_secret: Option<String>,
_user_key: Option<String>,
_user_secret: Option<String>,
) -> 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 +102,6 @@ impl DiscogsClient {
album: &str,
cache_dir: &Path,
) -> Option<String> {
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 +125,12 @@ impl DiscogsClient {
/// Search for a release and return the cover image URL
async fn search_release(&self, artist: &str, album: &str) -> Option<String> {
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);
@@ -154,20 +166,195 @@ impl DiscogsClient {
None
}
/// Search for artists and return search results
pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> {
if !self.has_credentials() {
return Err("Discogs credentials not configured".to_string());
/// Get detailed release information including all images
async fn get_release_details(&self, release_id: u64) -> Result<ReleaseDetails, String> {
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
log::debug!("Fetching Discogs release details for ID: {}", release_id);
let response = self.client.get(&url).send().await
.map_err(|e| format!("Failed to fetch release details: {}", e))?;
if !response.status().is_success() {
return Err(format!("Failed to fetch release details: {}", response.status()));
}
let key = self.consumer_key.as_ref().unwrap();
let secret = self.consumer_secret.as_ref().unwrap();
let details: ReleaseDetails = response.json().await
.map_err(|e| format!("Failed to parse release details: {}", e))?;
Ok(details)
}
/// Search for album artwork options
/// Returns up to 10 image options, with detailed images from top 2 releases interleaved
/// If catalog_number is provided, searches by that first, then falls back to artist + album
pub async fn search_artwork_options(
&self,
artist: &str,
album: &str,
catalog_number: Option<&str>,
) -> Result<Vec<DiscogsImageOption>, String> {
// Build search query - prefer catalog number if available
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
catno.to_string()
} else {
format!("{} {}", artist, album)
};
let url = format!(
"https://api.discogs.com/database/search?q={}&type=artist&key={}&secret={}",
urlencoding::encode(query),
key,
secret
"{}/search?q={}&type=release",
DISCOGS_PROXY_URL,
urlencoding::encode(&query)
);
log::debug!(
"Searching Discogs artwork options for: {} - {} (catalog: {:?})",
artist,
album,
catalog_number
);
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))?;
// Get IDs of top 2 relevant releases
let mut release_ids: Vec<u64> = Vec::new();
let mut other_results: Vec<&SearchResult> = Vec::new();
for result in search.results.iter().take(20) {
if result.result_type == "release" || result.result_type == "master" {
if release_ids.len() < 2 {
release_ids.push(result.id);
} else {
other_results.push(result);
}
}
}
if release_ids.is_empty() {
return Err("No releases found on Discogs".to_string());
}
let mut all_images = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
// Fetch detailed images from top 2 releases
for (idx, release_id) in release_ids.iter().enumerate() {
match self.get_release_details(*release_id).await {
Ok(details) => {
if let Some(images) = details.images {
let mut count = 0;
for img in images {
if !img.uri.is_empty()
&& !img.uri.contains("spacer.gif")
&& seen_urls.insert(img.uri.clone())
&& count < 4
{
all_images.push(DiscogsImageOption {
url: img.uri,
width: img.width,
height: img.height,
image_type: img.image_type,
release_title: Some(details.title.clone()),
release_year: details.year,
});
count += 1;
}
}
log::debug!("Added {} images from release #{} ({})", count, idx + 1, details.title);
}
}
Err(e) => {
log::warn!("Failed to fetch details for release {}: {}", release_id, e);
}
}
}
// Add up to 2 more images from other search results
for result in other_results.iter().take(10) {
if all_images.len() >= 10 {
break;
}
// Prefer cover image
let image_url = if let Some(cover) = &result.cover_image {
if !cover.is_empty() && !cover.contains("spacer.gif") {
Some((cover.clone(), 600, 600, "primary".to_string()))
} else {
None
}
} else {
None
};
let image_url = image_url.or_else(|| {
result.thumb.as_ref().and_then(|thumb| {
if !thumb.is_empty() && !thumb.contains("spacer.gif") {
Some((thumb.clone(), 150, 150, "secondary".to_string()))
} else {
None
}
})
});
if let Some((url, width, height, img_type)) = image_url {
if seen_urls.insert(url.clone()) {
all_images.push(DiscogsImageOption {
url,
width,
height,
image_type: img_type,
release_title: Some(result.title.clone()),
release_year: None,
});
}
}
}
if all_images.is_empty() {
return Err("No artwork found on Discogs".to_string());
}
// Return up to 10 unique images
all_images.truncate(10);
log::info!("Returning {} artwork options from Discogs", all_images.len());
Ok(all_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<String, String> {
// 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<SearchResponse, String> {
let url = format!(
"{}/search?q={}&type=artist",
DISCOGS_PROXY_URL,
urlencoding::encode(query)
);
log::debug!("Searching Discogs for artist: {}", query);
@@ -186,17 +373,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()?;
+63 -160
View File
@@ -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<bool, D::Error>
@@ -20,31 +19,8 @@ where
}
}
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
// Last.fm API credentials
// 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<String> {
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())
}
/// Get API secret from compile-time or runtime environment
fn get_api_secret() -> Option<String> {
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())
}
// 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 {
@@ -62,6 +38,8 @@ struct AuthGetSessionResponse {
#[derive(Debug, Deserialize)]
struct AuthGetTokenResponse {
token: String,
#[serde(rename = "authUrl")]
auth_url: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -72,35 +50,36 @@ enum LastFmResponse<T> {
}
/// 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<String>,
}
impl Default for LastFmClient {
fn default() -> Self {
Self::new(
get_api_key().unwrap_or_default(),
get_api_secret().unwrap_or_default(),
)
Self::new()
}
}
impl LastFmClient {
/// Check if embedded (build-time or runtime) credentials are available
/// Check if embedded credentials are available (always true - proxy handles them)
pub fn has_embedded_credentials() -> bool {
get_api_key().is_some() && get_api_secret().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,
}
}
@@ -110,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<String, String> {
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(&params);
/// 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))?;
@@ -175,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<LastFmSession, String> {
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(&params);
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))?;
@@ -246,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", &timestamp_str);
if let Some(album_name) = album {
params.insert("album", album_name);
}
let sig = self.generate_signature(&params);
let mut form_params = vec![
("method", "track.scrobble"),
("api_key", api_key.as_str()),
("sk", session_key.as_str()),
("artist", artist),
("track", track),
("timestamp", &timestamp_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))?;
@@ -305,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(&params);
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))?;
+2
View File
@@ -443,6 +443,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,
+37
View File
@@ -1738,3 +1738,40 @@ 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,
catalog_number: Option<String>,
) -> Result<Vec<crate::discogs::DiscogsImageOption>, String> {
log::info!(
"Command: discogs_search_artwork {} - {} (catalog: {:?})",
artist,
album,
catalog_number
);
let client = DiscogsClient::new();
client
.search_artwork_options(&artist, &album, catalog_number.as_deref())
.await
}
/// Download and save Discogs artwork
#[tauri::command]
pub async fn discogs_download_artwork(
image_url: String,
artist: String,
album: String,
) -> Result<String, String> {
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
}
+1
View File
@@ -257,6 +257,7 @@ pub fn cue_to_tracks(
disc_number: inferred_disc,
year: None,
genre: None,
catalog_number: None,
duration_secs: duration,
format: format.clone(),
bit_depth: properties.bit_depth,
+38 -11
View File
@@ -327,6 +327,24 @@ impl LibraryDatabase {
).map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
}
// Migration: Add catalog_number column to local_tracks
let has_catalog_number: bool = self
.conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('local_tracks') WHERE name = 'catalog_number'",
[],
|row| row.get::<_, i64>(0),
)
.map(|count| count > 0)
.unwrap_or(false);
if !has_catalog_number {
log::info!("Running migration: adding catalog_number to local_tracks");
self.conn
.execute_batch("ALTER TABLE local_tracks ADD COLUMN catalog_number TEXT;")
.map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
}
Ok(())
}
@@ -531,11 +549,11 @@ impl LibraryDatabase {
.execute(
r#"INSERT OR REPLACE INTO local_tracks
(file_path, title, artist, album, album_artist, track_number,
disc_number, year, genre, duration_secs, format, bit_depth,
disc_number, year, genre, catalog_number, duration_secs, format, bit_depth,
sample_rate, channels, file_size_bytes, cue_file_path,
cue_start_secs, cue_end_secs, artwork_path, last_modified, indexed_at,
album_group_key, album_group_title)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
params![
track.file_path,
track.title,
@@ -546,6 +564,7 @@ impl LibraryDatabase {
track.disc_number,
track.year,
track.genre,
track.catalog_number,
track.duration_secs,
track.format.to_string(),
track.bit_depth,
@@ -645,6 +664,7 @@ impl LibraryDatabase {
ELSE MIN(artist)
END as artist,
MIN(year) as year,
MIN(catalog_number) as catalog_number,
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork,
COUNT(*) as track_count,
SUM(duration_secs) as total_duration,
@@ -658,6 +678,7 @@ impl LibraryDatabase {
COALESCE(album_group_title, album) as title,
COALESCE(album_artist, artist) as artist,
year,
catalog_number,
artwork_path,
duration_secs,
format,
@@ -682,6 +703,7 @@ impl LibraryDatabase {
ELSE MIN(artist)
END as artist,
MIN(year) as year,
MIN(catalog_number) as catalog_number,
MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork,
COUNT(*) as track_count,
SUM(duration_secs) as total_duration,
@@ -695,6 +717,7 @@ impl LibraryDatabase {
COALESCE(album_group_title, album) as title,
COALESCE(album_artist, artist) as artist,
year,
catalog_number,
artwork_path,
duration_secs,
format,
@@ -723,25 +746,26 @@ impl LibraryDatabase {
let group_key: String = row.get(0)?;
let album: String = row.get(1)?;
let artist: String = row.get(2)?;
let artwork_path: Option<String> = row.get(4)?;
let artwork_path: Option<String> = row.get(5)?;
log::info!("Album {} by {}: artwork_path = {:?}", album, artist, artwork_path);
Ok(LocalAlbum {
id: group_key.clone(),
title: album,
artist,
year: row.get(3)?,
catalog_number: row.get(4)?,
artwork_path,
track_count: row.get(5)?,
total_duration_secs: row.get(6)?,
track_count: row.get(6)?,
total_duration_secs: row.get(7)?,
format: Self::parse_format(
&row.get::<_, Option<String>>(7)?.unwrap_or_default(),
&row.get::<_, Option<String>>(8)?.unwrap_or_default(),
),
bit_depth: row.get(8)?,
sample_rate: row.get::<_, Option<u32>>(9)?.unwrap_or(44100),
bit_depth: row.get(9)?,
sample_rate: row.get::<_, Option<u32>>(10)?.unwrap_or(44100),
directory_path: row
.get::<_, Option<String>>(10)?
.get::<_, Option<String>>(11)?
.unwrap_or_else(|| group_key.clone()),
})
})
@@ -991,6 +1015,7 @@ impl LibraryDatabase {
disc_number: row.get(7)?,
year: row.get(8)?,
genre: row.get(9)?,
catalog_number: row.get(26).ok().flatten(),
duration_secs: row.get(10)?,
format: Self::parse_format(&row.get::<_, String>(11)?),
bit_depth: row.get(12)?,
@@ -1594,6 +1619,7 @@ impl LibraryDatabase {
disc_number: row.get(9)?,
year: row.get(10)?,
genre: row.get(11)?,
catalog_number: None,
duration_secs: row.get(12)?,
format: Self::parse_format(&row.get::<_, String>(13)?),
bit_depth: row.get(14)?,
@@ -1642,6 +1668,7 @@ impl LibraryDatabase {
disc_number: row.get(9)?,
year: row.get(10)?,
genre: row.get(11)?,
catalog_number: None,
duration_secs: row.get(12)?,
format: Self::parse_format(&row.get::<_, String>(13)?),
bit_depth: row.get(14)?,
+2
View File
@@ -325,6 +325,7 @@ impl MetadataExtractor {
.or(inferred_disc),
year: tag.year().map(|y| y as u32),
genre: tag.genre().map(|s| s.to_string()),
catalog_number: tag.get_string(&ItemKey::CatalogNumber).map(|s| s.to_string()),
duration_secs,
format,
bit_depth,
@@ -363,6 +364,7 @@ impl MetadataExtractor {
disc_number: inferred_disc,
year: None,
genre: None,
catalog_number: None,
duration_secs,
format,
bit_depth,
+3
View File
@@ -51,6 +51,7 @@ pub struct LocalTrack {
pub disc_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub catalog_number: Option<String>,
// Audio properties
pub duration_secs: u64,
@@ -88,6 +89,7 @@ impl Default for LocalTrack {
disc_number: None,
year: None,
genre: None,
catalog_number: None,
duration_secs: 0,
format: AudioFormat::Unknown,
bit_depth: None,
@@ -120,6 +122,7 @@ pub struct LocalAlbum {
pub title: String,
pub artist: String,
pub year: Option<u32>,
pub catalog_number: Option<String>,
pub artwork_path: Option<String>,
pub track_count: u32,
pub total_duration_secs: u64,
@@ -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<String> {
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<ImportPlaylist,
})
}
async fn get_app_token(user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
// 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<ProviderCredentials>) -> Result<String, PlaylistImportError> {
// 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<ProviderCredentials>) -> Result<String
.get("access_token")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.ok_or_else(|| PlaylistImportError::Parse("Spotify token missing access_token".to_string()))
.ok_or_else(|| PlaylistImportError::Parse("Spotify proxy missing access_token".to_string()))
}
fn join_artists(value: Option<&Value>) -> String {
@@ -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<String> {
if !url.contains("tidal.com") {
return None;
@@ -336,45 +331,32 @@ fn parse_duration_ms(value: &str) -> Option<u64> {
}
}
async fn get_app_token(user_creds: Option<ProviderCredentials>) -> Result<String, PlaylistImportError> {
// 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<ProviderCredentials>) -> Result<String, PlaylistImportError> {
// 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()))
}