diff --git a/.env.example b/.env.example index 21cfe44f9..ba0cb921a 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,10 @@ LAST_FM_API_KEY=your_api_key_here LAST_FM_API_SHARED_SECRET=your_shared_secret_here LAST_FM_APP_NAME=your_app_name LAST_FM_APP_REGISTER=your_lastfm_username + +# Discogs API Configuration +# Create your own Discogs application at: https://www.discogs.com/settings/developers +# These credentials are used for fetching album artwork + +DISCOGS_API_CLIENT_KEY=your_consumer_key_here +DISCOGS_API_CLIENT_SECRET=your_consumer_secret_here diff --git a/README.md b/README.md index 8d758aaa7..0eb3bd26d 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,22 @@ LAST_FM_APP_REGISTER=your_lastfm_username The application will work without Last.fm credentials, but scrobbling will be disabled. +#### Discogs Integration + +To enable automatic album artwork fetching from Discogs when building from source: + +1. Go to [Discogs Developer Settings](https://www.discogs.com/settings/developers) +2. Create a new application +3. Copy your Consumer Key and Consumer Secret +4. Update your `.env` file: + +```env +DISCOGS_API_CLIENT_KEY=your_consumer_key_here +DISCOGS_API_CLIENT_SECRET=your_consumer_secret_here +``` + +The application will work without Discogs credentials, but automatic artwork fetching for local library will be disabled. + ## Project Structure ``` diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a83aad391..78045e122 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,7 +63,13 @@ walkdir = "2" lofty = "0.18" rusqlite = { version = "0.31", features = ["bundled"] } dirs = "5" -tauri-plugin-dialog = "2.5.0" +tauri-plugin-dialog = { version = "2.5.0", default-features = false, features = ["xdg-portal"] } + +# URL encoding +urlencoding = "2" + +# Environment variables +dotenvy = "0.15" # Storage # sled = "0.34" diff --git a/src-tauri/src/discogs/mod.rs b/src-tauri/src/discogs/mod.rs new file mode 100644 index 000000000..8ef3a0976 --- /dev/null +++ b/src-tauri/src/discogs/mod.rs @@ -0,0 +1,202 @@ +//! Discogs API client for fetching album artwork +//! +//! Uses the Discogs database API to search for releases and download cover images. + +use reqwest::Client; +use serde::Deserialize; +use std::fs; +use std::path::Path; +use std::time::Duration; + +/// Discogs API client +pub struct DiscogsClient { + client: Client, + consumer_key: Option, + consumer_secret: Option, +} + +/// Search result from Discogs API +#[derive(Debug, Deserialize)] +struct SearchResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct SearchResult { + id: u64, + cover_image: Option, + thumb: Option, + title: String, + #[serde(rename = "type")] + result_type: String, +} + +impl DiscogsClient { + /// Create a new Discogs client with optional credentials + pub fn new() -> Self { + let consumer_key = std::env::var("DISCOGS_API_CLIENT_KEY").ok(); + let consumer_secret = std::env::var("DISCOGS_API_CLIENT_SECRET").ok(); + + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent("QBZ/0.1.0 +https://github.com/vicrodh/qbz") + .build() + .expect("Failed to create HTTP client"); + + Self { + client, + consumer_key, + consumer_secret, + } + } + + /// Check if credentials are configured + pub fn has_credentials(&self) -> bool { + self.consumer_key.is_some() && self.consumer_secret.is_some() + } + + /// Search for album artwork and download if found + /// Returns the path to the downloaded image or None + pub async fn fetch_artwork( + &self, + artist: &str, + 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?; + + // Generate cache filename + let filename = format!( + "discogs_{:x}.jpg", + Self::simple_hash(&format!("{}_{}", artist, album)) + ); + let cache_path = cache_dir.join(&filename); + + // Return cached if exists + if cache_path.exists() { + return Some(cache_path.to_string_lossy().to_string()); + } + + // Download the image + self.download_image(&cover_url, &cache_path).await?; + + Some(cache_path.to_string_lossy().to_string()) + } + + /// 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 + ); + + log::debug!("Searching Discogs for: {} - {}", artist, album); + + let response = self.client.get(&url).send().await.ok()?; + + if !response.status().is_success() { + log::warn!("Discogs search failed with status: {}", response.status()); + return None; + } + + let search: SearchResponse = response.json().await.ok()?; + + // Find first result with a cover image + for result in search.results { + if result.result_type == "release" || result.result_type == "master" { + if let Some(cover) = result.cover_image { + if !cover.is_empty() && !cover.contains("spacer.gif") { + log::debug!("Found Discogs cover for {} - {}", artist, album); + return Some(cover); + } + } + // Fallback to thumbnail + if let Some(thumb) = result.thumb { + if !thumb.is_empty() && !thumb.contains("spacer.gif") { + return Some(thumb); + } + } + } + } + + log::debug!("No Discogs cover found for {} - {}", artist, album); + None + } + + /// Download an image to the cache directory + async fn download_image(&self, url: &str, path: &Path) -> Option<()> { + log::debug!("Downloading Discogs artwork: {}", url); + + let key = self.consumer_key.as_ref()?; + let secret = self.consumer_secret.as_ref()?; + + // Discogs requires auth even for image downloads + let response = self + .client + .get(url) + .header("Authorization", format!("Discogs key={}, secret={}", key, secret)) + .send() + .await + .ok()?; + + if !response.status().is_success() { + log::warn!("Failed to download Discogs image: {}", response.status()); + return None; + } + + let bytes = response.bytes().await.ok()?; + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).ok()?; + } + + fs::write(path, &bytes).ok()?; + + log::info!("Saved Discogs artwork to: {}", path.display()); + Some(()) + } + + /// Simple hash function for generating filenames + fn simple_hash(s: &str) -> u64 { + let mut hash: u64 = 5381; + for byte in s.bytes() { + hash = hash.wrapping_mul(33).wrapping_add(byte as u64); + } + hash + } +} + +impl Default for DiscogsClient { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash() { + let hash1 = DiscogsClient::simple_hash("Artist_Album"); + let hash2 = DiscogsClient::simple_hash("Artist_Album"); + assert_eq!(hash1, hash2); + + let hash3 = DiscogsClient::simple_hash("Different_Album"); + assert_ne!(hash1, hash3); + } +} diff --git a/src-tauri/src/lastfm/mod.rs b/src-tauri/src/lastfm/mod.rs index 0332b945f..7f6ea902e 100644 --- a/src-tauri/src/lastfm/mod.rs +++ b/src-tauri/src/lastfm/mod.rs @@ -10,11 +10,26 @@ use std::collections::BTreeMap; const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/"; // Last.fm API credentials -// Set via environment variables at build time, or users can enter their own -// To set: LASTFM_API_KEY=xxx LASTFM_API_SECRET=yyy cargo build +// Checked in order: +// 1. Compile-time environment variables (for release builds) +// 2. Runtime environment variables (for development with .env) const DEFAULT_API_KEY: Option<&str> = option_env!("LASTFM_API_KEY"); const DEFAULT_API_SECRET: Option<&str> = option_env!("LASTFM_API_SECRET"); +/// 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("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("LASTFM_API_SECRET").ok()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LastFmSession { pub name: String, @@ -50,16 +65,16 @@ pub struct LastFmClient { impl Default for LastFmClient { fn default() -> Self { Self::new( - DEFAULT_API_KEY.unwrap_or("").to_string(), - DEFAULT_API_SECRET.unwrap_or("").to_string(), + get_api_key().unwrap_or_default(), + get_api_secret().unwrap_or_default(), ) } } impl LastFmClient { - /// Check if embedded (build-time) credentials are available + /// Check if embedded (build-time or runtime) credentials are available pub fn has_embedded_credentials() -> bool { - DEFAULT_API_KEY.is_some() && DEFAULT_API_SECRET.is_some() + get_api_key().is_some() && get_api_secret().is_some() } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc0cd3543..ad03c01a3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod api; pub mod cache; pub mod commands; pub mod config; +pub mod discogs; pub mod lastfm; pub mod library; pub mod media_controls; @@ -77,6 +78,10 @@ pub fn update_media_controls_metadata( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Load .env file if present (for development) + // Silently ignore if not found (production builds use compile-time env vars) + dotenvy::dotenv().ok(); + // Initialize logging env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() @@ -193,6 +198,10 @@ pub fn run() { library::commands::playlist_remove_local_track, library::commands::playlist_get_local_tracks, library::commands::playlist_clear_local_tracks, + // Discogs artwork commands + library::commands::discogs_has_credentials, + library::commands::library_fetch_missing_artwork, + library::commands::library_fetch_album_artwork, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index 62c2707bc..a590d403a 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use tauri::State; use tokio::sync::Mutex; +use crate::discogs::DiscogsClient; use crate::library::{ cue_to_tracks, get_artwork_cache_dir, CueParser, LibraryDatabase, LibraryScanner, LibraryStats, LocalAlbum, LocalArtist, LocalTrack, MetadataExtractor, ScanError, ScanProgress, ScanStatus, @@ -489,3 +490,83 @@ pub async fn playlist_clear_local_tracks( db.clear_playlist_local_tracks(playlist_id) .map_err(|e| e.to_string()) } + +// === Discogs Artwork === + +/// Check if Discogs credentials are configured +#[tauri::command] +pub fn discogs_has_credentials() -> bool { + let client = DiscogsClient::new(); + client.has_credentials() +} + +/// Fetch missing artwork from Discogs for albums without artwork +/// Returns number of albums updated +#[tauri::command] +pub async fn library_fetch_missing_artwork( + state: State<'_, LibraryState>, +) -> Result { + log::info!("Command: library_fetch_missing_artwork"); + + let discogs = DiscogsClient::new(); + if !discogs.has_credentials() { + return Err("Discogs credentials not configured".to_string()); + } + + let artwork_cache = get_artwork_cache_dir(); + let mut updated_count = 0u32; + + // Get all albums without artwork + let albums_without_artwork: Vec<(String, String)> = { + let db = state.db.lock().await; + db.get_albums_without_artwork() + .map_err(|e| e.to_string())? + }; + + log::info!("Found {} albums without artwork", albums_without_artwork.len()); + + for (artist, album) in albums_without_artwork { + // Try to fetch from Discogs + if let Some(artwork_path) = discogs.fetch_artwork(&artist, &album, &artwork_cache).await { + // Update all tracks in this album with the artwork + let db = state.db.lock().await; + if db.update_album_artwork(&album, &artist, &artwork_path).is_ok() { + updated_count += 1; + log::info!("Updated artwork for {} - {}", artist, album); + } + } + + // Small delay to respect rate limits (60 requests/min) + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + log::info!("Fetched artwork for {} albums from Discogs", updated_count); + Ok(updated_count) +} + +/// Fetch artwork for a specific album from Discogs +#[tauri::command] +pub async fn library_fetch_album_artwork( + artist: String, + album: String, + state: State<'_, LibraryState>, +) -> Result, String> { + log::info!("Command: library_fetch_album_artwork {} - {}", artist, album); + + let discogs = DiscogsClient::new(); + if !discogs.has_credentials() { + return Err("Discogs credentials not configured".to_string()); + } + + let artwork_cache = get_artwork_cache_dir(); + + if let Some(artwork_path) = discogs.fetch_artwork(&artist, &album, &artwork_cache).await { + // Update all tracks in this album with the artwork + let db = state.db.lock().await; + db.update_album_artwork(&album, &artist, &artwork_path) + .map_err(|e| e.to_string())?; + Ok(Some(artwork_path)) + } else { + Ok(None) + } +} diff --git a/src-tauri/src/library/database.rs b/src-tauri/src/library/database.rs index 48888da6c..a692d405e 100644 --- a/src-tauri/src/library/database.rs +++ b/src-tauri/src/library/database.rs @@ -357,6 +357,55 @@ impl LibraryDatabase { Ok(artists) } + /// Get albums without artwork (for Discogs fetching) + pub fn get_albums_without_artwork(&self) -> Result, LibraryError> { + let mut stmt = self + .conn + .prepare( + r#" + SELECT DISTINCT + COALESCE(album_artist, artist) as artist, + album + FROM local_tracks + WHERE artwork_path IS NULL OR artwork_path = '' + ORDER BY artist, album + "#, + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| LibraryError::Database(e.to_string()))?; + + let mut albums = Vec::new(); + for album in rows { + albums.push(album.map_err(|e| LibraryError::Database(e.to_string()))?); + } + Ok(albums) + } + + /// Update artwork path for all tracks in an album + pub fn update_album_artwork( + &self, + album: &str, + artist: &str, + artwork_path: &str, + ) -> Result<(), LibraryError> { + self.conn + .execute( + r#" + UPDATE local_tracks + SET artwork_path = ? + WHERE album = ? AND COALESCE(album_artist, artist) = ? + "#, + params![artwork_path, album, artist], + ) + .map_err(|e| LibraryError::Database(e.to_string()))?; + Ok(()) + } + /// Search tracks by title, artist, or album pub fn search(&self, query: &str, limit: u32) -> Result, LibraryError> { let pattern = format!("%{}%", query); diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index b9f30753a..6c91e1776 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -4,7 +4,7 @@ import { onMount } from 'svelte'; import { HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw, - Settings, X, Play, Plus, AlertCircle, Clock + Settings, X, Play, Plus, AlertCircle, Clock, ImageDown } from 'lucide-svelte'; import AlbumCard from '../AlbumCard.svelte'; import TrackRow from '../TrackRow.svelte'; @@ -96,6 +96,8 @@ let loading = $state(false); let scanning = $state(false); let error = $state(null); + let fetchingArtwork = $state(false); + let hasDiscogsCredentials = $state(false); // Album detail state (for viewing album tracks) let selectedAlbum = $state(null); @@ -104,8 +106,17 @@ onMount(() => { loadLibraryData(); loadFolders(); + checkDiscogsCredentials(); }); + async function checkDiscogsCredentials() { + try { + hasDiscogsCredentials = await invoke('discogs_has_credentials'); + } catch { + hasDiscogsCredentials = false; + } + } + async function loadLibraryData() { loading = true; error = null; @@ -255,6 +266,29 @@ } } + async function handleFetchMissingArtwork() { + if (!hasDiscogsCredentials) { + alert('Discogs credentials not configured. Please set up DISCOGS_API_CLIENT_KEY and DISCOGS_API_CLIENT_SECRET.'); + return; + } + + fetchingArtwork = true; + try { + const count = await invoke('library_fetch_missing_artwork'); + if (count > 0) { + alert(`Fetched artwork for ${count} albums from Discogs.`); + await loadLibraryData(); + } else { + alert('No albums needed artwork updates.'); + } + } catch (err) { + console.error('Failed to fetch artwork:', err); + alert(`Failed to fetch artwork: ${err}`); + } finally { + fetchingArtwork = false; + } + } + async function handleAlbumClick(album: LocalAlbum) { selectedAlbum = album; try { @@ -500,6 +534,20 @@ {/if}
+ {#if hasDiscogsCredentials} + + {:else} +
+ Configure Discogs API for automatic artwork fetching +
+ {/if}