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:
1 parent
f6dbf2ee2a
commit
541f46e430
9 files changed
+478
-8
No files matched your search
@@ -6,3 +6,10 @@ LAST_FM_API_KEY=your_api_key_here
|
|||||||
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
|
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
|
||||||
LAST_FM_APP_NAME=your_app_name
|
LAST_FM_APP_NAME=your_app_name
|
||||||
LAST_FM_APP_REGISTER=your_lastfm_username
|
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
|
||||||
@@ -119,6 +119,22 @@ LAST_FM_APP_REGISTER=your_lastfm_username
|
|||||||
|
|
||||||
The application will work without Last.fm credentials, but scrobbling will be disabled.
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ walkdir = "2"
|
|||||||
lofty = "0.18"
|
lofty = "0.18"
|
||||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
dirs = "5"
|
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
|
# Storage
|
||||||
# sled = "0.34"
|
# sled = "0.34"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,26 @@ use std::collections::BTreeMap;
|
|||||||
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
const LASTFM_API_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||||
|
|
||||||
// Last.fm API credentials
|
// Last.fm API credentials
|
||||||
// Set via environment variables at build time, or users can enter their own
|
// Checked in order:
|
||||||
// To set: LASTFM_API_KEY=xxx LASTFM_API_SECRET=yyy cargo build
|
// 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_KEY: Option<&str> = option_env!("LASTFM_API_KEY");
|
||||||
const DEFAULT_API_SECRET: Option<&str> = option_env!("LASTFM_API_SECRET");
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LastFmSession {
|
pub struct LastFmSession {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -50,16 +65,16 @@ pub struct LastFmClient {
|
|||||||
impl Default for LastFmClient {
|
impl Default for LastFmClient {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(
|
Self::new(
|
||||||
DEFAULT_API_KEY.unwrap_or("").to_string(),
|
get_api_key().unwrap_or_default(),
|
||||||
DEFAULT_API_SECRET.unwrap_or("").to_string(),
|
get_api_secret().unwrap_or_default(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LastFmClient {
|
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 {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod api;
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod discogs;
|
||||||
pub mod lastfm;
|
pub mod lastfm;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod media_controls;
|
pub mod media_controls;
|
||||||
@@ -77,6 +78,10 @@ pub fn update_media_controls_metadata(
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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
|
// Initialize logging
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||||
.format_timestamp_millis()
|
.format_timestamp_millis()
|
||||||
@@ -193,6 +198,10 @@ pub fn run() {
|
|||||||
library::commands::playlist_remove_local_track,
|
library::commands::playlist_remove_local_track,
|
||||||
library::commands::playlist_get_local_tracks,
|
library::commands::playlist_get_local_tracks,
|
||||||
library::commands::playlist_clear_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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::discogs::DiscogsClient;
|
||||||
use crate::library::{
|
use crate::library::{
|
||||||
cue_to_tracks, get_artwork_cache_dir, CueParser, LibraryDatabase, LibraryScanner, LibraryStats,
|
cue_to_tracks, get_artwork_cache_dir, CueParser, LibraryDatabase, LibraryScanner, LibraryStats,
|
||||||
LocalAlbum, LocalArtist, LocalTrack, MetadataExtractor, ScanError, ScanProgress, ScanStatus,
|
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)
|
db.clear_playlist_local_tracks(playlist_id)
|
||||||
.map_err(|e| e.to_string())
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,6 +357,55 @@ impl LibraryDatabase {
|
|||||||
Ok(artists)
|
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
|
/// Search tracks by title, artist, or album
|
||||||
pub fn search(&self, query: &str, limit: u32) -> Result<Vec<LocalTrack>, LibraryError> {
|
pub fn search(&self, query: &str, limit: u32) -> Result<Vec<LocalTrack>, LibraryError> {
|
||||||
let pattern = format!("%{}%", query);
|
let pattern = format!("%{}%", query);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw,
|
HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw,
|
||||||
Settings, X, Play, Plus, AlertCircle, Clock
|
Settings, X, Play, Plus, AlertCircle, Clock, ImageDown
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import AlbumCard from '../AlbumCard.svelte';
|
import AlbumCard from '../AlbumCard.svelte';
|
||||||
import TrackRow from '../TrackRow.svelte';
|
import TrackRow from '../TrackRow.svelte';
|
||||||
@@ -96,6 +96,8 @@
|
|||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let scanning = $state(false);
|
let scanning = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let fetchingArtwork = $state(false);
|
||||||
|
let hasDiscogsCredentials = $state(false);
|
||||||
|
|
||||||
// Album detail state (for viewing album tracks)
|
// Album detail state (for viewing album tracks)
|
||||||
let selectedAlbum = $state<LocalAlbum | null>(null);
|
let selectedAlbum = $state<LocalAlbum | null>(null);
|
||||||
@@ -104,8 +106,17 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadLibraryData();
|
loadLibraryData();
|
||||||
loadFolders();
|
loadFolders();
|
||||||
|
checkDiscogsCredentials();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function checkDiscogsCredentials() {
|
||||||
|
try {
|
||||||
|
hasDiscogsCredentials = await invoke<boolean>('discogs_has_credentials');
|
||||||
|
} catch {
|
||||||
|
hasDiscogsCredentials = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLibraryData() {
|
async function loadLibraryData() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
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<number>('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) {
|
async function handleAlbumClick(album: LocalAlbum) {
|
||||||
selectedAlbum = album;
|
selectedAlbum = album;
|
||||||
try {
|
try {
|
||||||
@@ -500,6 +534,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
|
{#if hasDiscogsCredentials}
|
||||||
|
<button
|
||||||
|
class="secondary-btn"
|
||||||
|
onclick={handleFetchMissingArtwork}
|
||||||
|
disabled={fetchingArtwork}
|
||||||
|
>
|
||||||
|
<ImageDown size={14} class={fetchingArtwork ? 'spinning' : ''} />
|
||||||
|
<span>{fetchingArtwork ? 'Fetching...' : 'Fetch Missing Artwork'}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="discogs-hint">
|
||||||
|
<span>Configure Discogs API for automatic artwork fetching</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<button class="danger-btn" onclick={handleClearLibrary}>
|
<button class="danger-btn" onclick={handleClearLibrary}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
<span>Clear Library</span>
|
<span>Clear Library</span>
|
||||||
@@ -821,6 +869,43 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid var(--bg-tertiary);
|
border-top: 1px solid var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discogs-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.danger-btn {
|
.danger-btn {
|
||||||
|
|||||||
Reference in new issue
Block a user