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_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
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/";
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
let fetchingArtwork = $state(false);
|
||||
let hasDiscogsCredentials = $state(false);
|
||||
|
||||
// Album detail state (for viewing album tracks)
|
||||
let selectedAlbum = $state<LocalAlbum | null>(null);
|
||||
@@ -104,8 +106,17 @@
|
||||
onMount(() => {
|
||||
loadLibraryData();
|
||||
loadFolders();
|
||||
checkDiscogsCredentials();
|
||||
});
|
||||
|
||||
async function checkDiscogsCredentials() {
|
||||
try {
|
||||
hasDiscogsCredentials = await invoke<boolean>('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<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) {
|
||||
selectedAlbum = album;
|
||||
try {
|
||||
@@ -500,6 +534,20 @@
|
||||
{/if}
|
||||
|
||||
<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}>
|
||||
<Trash2 size={14} />
|
||||
<span>Clear Library</span>
|
||||
@@ -821,6 +869,43 @@
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user