feat(integrations): add Discogs artwork fetch and fix Last.fm env loading

- Add Discogs API client for fetching missing album artwork
- Add UI button in Local Library settings to fetch missing artwork
- Load .env file at runtime using dotenvy for development
- Fix Last.fm to check runtime env vars as fallback to compile-time
- Use XDG Portal for dialog plugin (cross-desktop compatibility)
- Update README and .env.example with Discogs configuration
This commit is contained in:
vicrodh committed 2026-01-09 14:30:35 -06:00
1 parent f6dbf2ee2a
commit 541f46e430
9 files changed
+478 -8

No files matched your search

+7 -1
View File
@@ -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"
+202
View File
@@ -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<String>,
consumer_secret: Option<String>,
}
/// Search result from Discogs API
#[derive(Debug, Deserialize)]
struct SearchResponse {
results: Vec<SearchResult>,
}
#[derive(Debug, Deserialize)]
struct SearchResult {
id: u64,
cover_image: Option<String>,
thumb: Option<String>,
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<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?;
// 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<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
);
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);
}
}
+21 -6
View File
@@ -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<String> {
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<String> {
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()
}
}
+9
View File
@@ -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");
+81
View File
@@ -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<u32, String> {
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<Option<String>, 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)
}
}
+49
View File
@@ -357,6 +357,55 @@ impl LibraryDatabase {
Ok(artists)
}
/// Get albums without artwork (for Discogs fetching)
pub fn get_albums_without_artwork(&self) -> Result<Vec<(String, String)>, 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<Vec<LocalTrack>, LibraryError> {
let pattern = format!("%{}%", query);