Merge branch 'feature/local-library-tag-editor' into pre-release
This commit is contained in:
commit
49bb0cf349
37 files changed
+3924
-580
No files matched your search
@@ -22,6 +22,7 @@ pub mod radio;
|
||||
pub mod search;
|
||||
pub mod share;
|
||||
pub mod smart_playlists;
|
||||
pub mod remote_metadata;
|
||||
|
||||
pub use audio_backends::*;
|
||||
pub use audio_diagnostics::*;
|
||||
@@ -43,3 +44,4 @@ pub use radio::*;
|
||||
pub use search::*;
|
||||
pub use share::*;
|
||||
pub use smart_playlists::*;
|
||||
pub use remote_metadata::*;
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Tauri commands for remote metadata fetching (MusicBrainz, Discogs)
|
||||
//!
|
||||
//! These commands allow the Tag Editor to search for and fetch
|
||||
//! album metadata from external services.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::library::remote_metadata::{
|
||||
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataState, RemoteProvider,
|
||||
};
|
||||
|
||||
/// State wrapper for Tauri
|
||||
pub struct RemoteMetadataSharedState {
|
||||
pub inner: Arc<Mutex<RemoteMetadataState>>,
|
||||
}
|
||||
|
||||
/// Search for albums on a remote provider
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - "musicbrainz" or "discogs"
|
||||
/// * `query` - Album title to search for
|
||||
/// * `artist` - Optional artist name to narrow search
|
||||
/// * `limit` - Maximum results (1-25)
|
||||
#[tauri::command]
|
||||
pub async fn remote_metadata_search(
|
||||
provider: String,
|
||||
query: String,
|
||||
artist: Option<String>,
|
||||
limit: Option<usize>,
|
||||
state: State<'_, RemoteMetadataSharedState>,
|
||||
) -> Result<Vec<RemoteAlbumSearchResult>, String> {
|
||||
log::info!(
|
||||
"Command: remote_metadata_search provider={} query={} artist={:?}",
|
||||
provider,
|
||||
query,
|
||||
artist
|
||||
);
|
||||
|
||||
let provider = parse_provider(&provider)?;
|
||||
let limit = limit.unwrap_or(10).min(25);
|
||||
|
||||
let state_guard = state.inner.lock().await;
|
||||
state_guard
|
||||
.search_albums(provider, &query, artist.as_deref(), limit)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get full album metadata from a remote provider
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - "musicbrainz" or "discogs"
|
||||
/// * `provider_id` - The provider-specific ID (MBID or Discogs release ID)
|
||||
#[tauri::command]
|
||||
pub async fn remote_metadata_get_album(
|
||||
provider: String,
|
||||
provider_id: String,
|
||||
state: State<'_, RemoteMetadataSharedState>,
|
||||
) -> Result<RemoteAlbumMetadata, String> {
|
||||
log::info!(
|
||||
"Command: remote_metadata_get_album provider={} id={}",
|
||||
provider,
|
||||
provider_id
|
||||
);
|
||||
|
||||
let provider = parse_provider(&provider)?;
|
||||
|
||||
let state_guard = state.inner.lock().await;
|
||||
state_guard
|
||||
.get_album_metadata(provider, &provider_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
#[tauri::command]
|
||||
pub async fn remote_metadata_cache_stats(
|
||||
state: State<'_, RemoteMetadataSharedState>,
|
||||
) -> Result<crate::library::remote_metadata::CacheStats, String> {
|
||||
let state_guard = state.inner.lock().await;
|
||||
Ok(state_guard.cache.stats().await)
|
||||
}
|
||||
|
||||
/// Clear the remote metadata cache
|
||||
#[tauri::command]
|
||||
pub async fn remote_metadata_clear_cache(
|
||||
state: State<'_, RemoteMetadataSharedState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: remote_metadata_clear_cache");
|
||||
let state_guard = state.inner.lock().await;
|
||||
state_guard.cache.clear_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse provider string to enum
|
||||
fn parse_provider(provider: &str) -> Result<RemoteProvider, String> {
|
||||
match provider.to_lowercase().as_str() {
|
||||
"musicbrainz" | "mb" => Ok(RemoteProvider::MusicBrainz),
|
||||
"discogs" => Ok(RemoteProvider::Discogs),
|
||||
_ => Err(format!("Unknown provider: {}", provider)),
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub struct DiscogsImageOption {
|
||||
pub release_year: Option<u32>,
|
||||
}
|
||||
|
||||
/// Release details from Discogs API
|
||||
/// Release details from Discogs API (internal, for artwork)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseDetails {
|
||||
id: u64,
|
||||
@@ -63,6 +63,69 @@ struct ReleaseImage {
|
||||
image_type: String,
|
||||
}
|
||||
|
||||
// ============ Public Metadata Structures ============
|
||||
|
||||
/// Full release metadata from Discogs (for tag editor)
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct DiscogsReleaseMetadata {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub artists: Option<Vec<DiscogsArtist>>,
|
||||
pub year: Option<u32>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub styles: Option<Vec<String>>,
|
||||
pub labels: Option<Vec<DiscogsLabel>>,
|
||||
pub tracklist: Option<Vec<DiscogsTrack>>,
|
||||
pub country: Option<String>,
|
||||
/// URL to view on Discogs
|
||||
pub uri: Option<String>,
|
||||
}
|
||||
|
||||
/// Artist in Discogs release
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct DiscogsArtist {
|
||||
pub name: String,
|
||||
pub id: Option<u64>,
|
||||
/// Join phrase (e.g., " & ", " feat. ")
|
||||
pub join: Option<String>,
|
||||
}
|
||||
|
||||
/// Label in Discogs release
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct DiscogsLabel {
|
||||
pub name: String,
|
||||
pub catno: Option<String>,
|
||||
pub id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Track in Discogs release
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct DiscogsTrack {
|
||||
/// Position (e.g., "1", "A1", "1-1")
|
||||
pub position: String,
|
||||
pub title: String,
|
||||
/// Duration as string (e.g., "3:45")
|
||||
pub duration: Option<String>,
|
||||
/// Track type (e.g., "track", "heading")
|
||||
#[serde(rename = "type_")]
|
||||
pub track_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Extended search result with more metadata
|
||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
||||
pub struct DiscogsSearchResultExtended {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
#[serde(rename = "type")]
|
||||
pub result_type: String,
|
||||
pub year: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub label: Option<Vec<String>>,
|
||||
pub catno: Option<String>,
|
||||
pub format: Option<Vec<String>>,
|
||||
pub cover_image: Option<String>,
|
||||
}
|
||||
|
||||
impl DiscogsClient {
|
||||
/// Create a new Discogs client (proxy handles credentials)
|
||||
pub fn new() -> Self {
|
||||
@@ -364,6 +427,82 @@ impl DiscogsClient {
|
||||
Ok(search)
|
||||
}
|
||||
|
||||
/// Search for releases with extended metadata
|
||||
/// Returns up to `limit` results with detailed release information
|
||||
pub async fn search_releases(
|
||||
&self,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
catalog_number: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiscogsSearchResultExtended>, 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!(
|
||||
"{}/search?q={}&type=release&per_page={}",
|
||||
DISCOGS_PROXY_URL,
|
||||
urlencoding::encode(&query),
|
||||
limit.min(25)
|
||||
);
|
||||
|
||||
log::debug!(
|
||||
"Searching Discogs releases 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()));
|
||||
}
|
||||
|
||||
// Parse response with extended fields
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExtendedSearchResponse {
|
||||
results: Vec<DiscogsSearchResultExtended>,
|
||||
}
|
||||
|
||||
let search: ExtendedSearchResponse = response.json().await
|
||||
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
|
||||
|
||||
// Filter to releases only
|
||||
let results: Vec<_> = search.results
|
||||
.into_iter()
|
||||
.filter(|r| r.result_type == "release" || r.result_type == "master")
|
||||
.collect();
|
||||
|
||||
log::info!("Found {} Discogs releases", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Get full release metadata including tracklist
|
||||
pub async fn get_release_metadata(&self, release_id: u64) -> Result<DiscogsReleaseMetadata, String> {
|
||||
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
|
||||
|
||||
log::debug!("Fetching Discogs release metadata for ID: {}", release_id);
|
||||
|
||||
let response = self.client.get(&url).send().await
|
||||
.map_err(|e| format!("Failed to fetch release: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to fetch release: {}", response.status()));
|
||||
}
|
||||
|
||||
let metadata: DiscogsReleaseMetadata = response.json().await
|
||||
.map_err(|e| format!("Failed to parse release metadata: {}", e))?;
|
||||
|
||||
log::info!("Fetched Discogs release: {} ({:?})", metadata.title, metadata.year);
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Download an image to the cache directory
|
||||
async fn download_image(&self, image_url: &str, path: &Path) -> Option<()> {
|
||||
log::debug!("Downloading Discogs artwork: {}", image_url);
|
||||
|
||||
@@ -200,6 +200,13 @@ pub fn run() {
|
||||
// Initialize ListenBrainz integration state
|
||||
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new()
|
||||
.expect("Failed to initialize ListenBrainz state");
|
||||
// Initialize remote metadata state (for Tag Editor service integration)
|
||||
let remote_metadata_state = commands::RemoteMetadataSharedState {
|
||||
inner: Arc::new(Mutex::new(library::remote_metadata::RemoteMetadataState::new(
|
||||
Some(Arc::new(musicbrainz::MusicBrainzSharedState::new()
|
||||
.expect("Failed to initialize MusicBrainz for remote metadata")))
|
||||
))),
|
||||
};
|
||||
|
||||
// Read saved audio device and settings for player initialization
|
||||
let (saved_device, audio_settings) = audio_settings_state
|
||||
@@ -425,6 +432,7 @@ pub fn run() {
|
||||
.manage(tray_settings_state)
|
||||
.manage(musicbrainz_state)
|
||||
.manage(listenbrainz_state)
|
||||
.manage(remote_metadata_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Auth commands
|
||||
commands::init_client,
|
||||
@@ -611,6 +619,9 @@ pub fn run() {
|
||||
// Album settings commands
|
||||
library::commands::library_get_album_settings,
|
||||
library::commands::library_set_album_hidden,
|
||||
library::commands::library_update_album_metadata,
|
||||
library::commands::library_write_album_metadata_to_files,
|
||||
library::commands::library_refresh_album_metadata_from_files,
|
||||
library::commands::library_get_hidden_albums,
|
||||
library::commands::library_backfill_downloads,
|
||||
// Artist images commands
|
||||
@@ -801,6 +812,11 @@ pub fn run() {
|
||||
commands::smart_playlist_preview,
|
||||
commands::smart_playlist_resolve_artist,
|
||||
commands::smart_playlist_get_available_types,
|
||||
// Remote metadata commands (Tag Editor service integration)
|
||||
commands::remote_metadata_search,
|
||||
commands::remote_metadata_get_album,
|
||||
commands::remote_metadata_cache_stats,
|
||||
commands::remote_metadata_clear_cache,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Tauri commands for local library
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use serde::Deserialize;
|
||||
use tauri::{Emitter, State};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::discogs::DiscogsClient;
|
||||
@@ -69,7 +71,7 @@ pub async fn library_add_folder(
|
||||
|
||||
log::info!("Folder network info: is_network={}, fs_type={:?}", is_network, fs_type);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
let id = db.add_folder_with_network_info(&path, is_network, fs_type.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -86,7 +88,7 @@ pub async fn library_remove_folder(
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: library_remove_folder {}", path);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.remove_folder(&path).map_err(|e| e.to_string())?;
|
||||
db.delete_tracks_in_folder(&path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
@@ -96,7 +98,7 @@ pub async fn library_remove_folder(
|
||||
pub async fn library_get_folders(state: State<'_, LibraryState>) -> Result<Vec<String>, String> {
|
||||
log::info!("Command: library_get_folders");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.get_folders().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -108,7 +110,7 @@ pub async fn library_get_folders_with_metadata(
|
||||
) -> Result<Vec<LibraryFolder>, String> {
|
||||
log::info!("Command: library_get_folders_with_metadata");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
let mut folders = db.get_folders_with_metadata().map_err(|e| e.to_string())?;
|
||||
|
||||
// Refresh network detection for folders without user override
|
||||
@@ -163,7 +165,7 @@ pub async fn library_get_folder(
|
||||
) -> Result<Option<LibraryFolder>, String> {
|
||||
log::info!("Command: library_get_folder {}", id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.get_folder_by_id(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -180,7 +182,7 @@ pub async fn library_update_folder_settings(
|
||||
) -> Result<LibraryFolder, String> {
|
||||
log::info!("Command: library_update_folder_settings {} alias={:?} enabled={}", id, alias, enabled);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.update_folder_settings(
|
||||
id,
|
||||
alias.as_deref(),
|
||||
@@ -204,7 +206,7 @@ pub async fn library_set_folder_enabled(
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: library_set_folder_enabled {} enabled={}", id, enabled);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.set_folder_enabled(id, enabled).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -226,7 +228,7 @@ pub async fn library_update_folder_path(
|
||||
return Err("The selected path is not a folder".to_string());
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.update_folder_path(id, &new_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Check if it's a network folder and update network info
|
||||
@@ -323,6 +325,7 @@ pub async fn library_scan(state: State<'_, LibraryState>) -> Result<(), String>
|
||||
|
||||
let scanner = LibraryScanner::new();
|
||||
let mut all_errors: Vec<ScanError> = Vec::new();
|
||||
let mut sidecar_cache: HashMap<String, Option<crate::library::AlbumTagSidecar>> = HashMap::new();
|
||||
|
||||
for folder in &folders {
|
||||
log::info!("Scanning folder: {}", folder);
|
||||
@@ -418,6 +421,7 @@ pub async fn library_scan(state: State<'_, LibraryState>) -> Result<(), String>
|
||||
|
||||
match MetadataExtractor::extract(&canonical_path) {
|
||||
Ok(mut track) => {
|
||||
apply_sidecar_override_if_present(&mut track, &mut sidecar_cache);
|
||||
// Try to extract embedded artwork, fallback to cached folder artwork
|
||||
let artwork_cache = get_artwork_cache_dir();
|
||||
let mut artwork_path =
|
||||
@@ -500,6 +504,22 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
|
||||
// Convert CUE to tracks
|
||||
let mut tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties);
|
||||
|
||||
// Apply sidecar overrides if present (matches by file_path + cue_start_secs)
|
||||
if let Some(group_key) = tracks
|
||||
.first()
|
||||
.map(|track| track.album_group_key.trim().to_string())
|
||||
.filter(|k| !k.is_empty())
|
||||
{
|
||||
let album_dir = Path::new(&group_key);
|
||||
if album_dir.is_dir() {
|
||||
if let Ok(Some(sidecar)) = crate::library::read_album_sidecar(album_dir) {
|
||||
for track in tracks.iter_mut() {
|
||||
crate::library::apply_sidecar_to_track(track, &sidecar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let artwork_cache = get_artwork_cache_dir();
|
||||
let mut artwork_path = MetadataExtractor::extract_artwork(audio_path.as_path(), &artwork_cache);
|
||||
if artwork_path.is_none() {
|
||||
@@ -520,7 +540,7 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
|
||||
}
|
||||
|
||||
// Insert tracks
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
let group_key = tracks
|
||||
.first()
|
||||
.map(|track| track.album_group_key.clone())
|
||||
@@ -537,6 +557,59 @@ async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_sidecar_override_if_present(
|
||||
track: &mut LocalTrack,
|
||||
cache: &mut HashMap<String, Option<crate::library::AlbumTagSidecar>>,
|
||||
) {
|
||||
let group_key = track.album_group_key.trim();
|
||||
if group_key.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let cached = cache.entry(group_key.to_string()).or_insert_with(|| {
|
||||
let album_dir = Path::new(group_key);
|
||||
if !album_dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match crate::library::read_album_sidecar(album_dir) {
|
||||
Ok(sidecar) => sidecar,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to read LocalLibrary sidecar for {}: {}",
|
||||
album_dir.display(),
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(sidecar) = cached.as_ref() {
|
||||
crate::library::apply_sidecar_to_track(track, sidecar);
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_track_artist_match(tracks: &[LocalTrack]) -> Option<String> {
|
||||
let mut artists: HashSet<String> = HashSet::new();
|
||||
for track in tracks {
|
||||
let value = track
|
||||
.album_artist
|
||||
.as_deref()
|
||||
.unwrap_or(track.artist.as_str())
|
||||
.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
artists.insert(value.to_string());
|
||||
if artists.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
artists.into_iter().next()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_scan_progress(
|
||||
state: State<'_, LibraryState>,
|
||||
@@ -620,6 +693,7 @@ pub async fn library_scan_folder(
|
||||
|
||||
let scanner = LibraryScanner::new();
|
||||
let mut all_errors: Vec<ScanError> = Vec::new();
|
||||
let mut sidecar_cache: HashMap<String, Option<crate::library::AlbumTagSidecar>> = HashMap::new();
|
||||
|
||||
log::info!("Scanning single folder: {}", folder.path);
|
||||
|
||||
@@ -713,6 +787,7 @@ pub async fn library_scan_folder(
|
||||
|
||||
match MetadataExtractor::extract(&canonical_path) {
|
||||
Ok(mut track) => {
|
||||
apply_sidecar_override_if_present(&mut track, &mut sidecar_cache);
|
||||
let artwork_cache = get_artwork_cache_dir();
|
||||
let mut artwork_path = MetadataExtractor::extract_artwork(&canonical_path, &artwork_cache);
|
||||
if artwork_path.is_none() {
|
||||
@@ -796,7 +871,7 @@ pub async fn library_get_albums(
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
|
||||
// Use optimized SQL-based filtering instead of N+1 query pattern
|
||||
let albums = db.get_albums_with_full_filter(
|
||||
@@ -816,7 +891,7 @@ pub async fn library_get_album_tracks(
|
||||
) -> Result<Vec<LocalTrack>, String> {
|
||||
log::info!("Command: library_get_album_tracks {}", album_group_key);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.get_album_tracks(&album_group_key)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -837,7 +912,7 @@ pub async fn library_get_artists(
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
|
||||
// Use optimized SQL-based filtering instead of N+1 query pattern
|
||||
let artists = db.get_artists_with_filter(
|
||||
@@ -867,7 +942,7 @@ pub async fn library_search(
|
||||
.map(|s| s.show_in_library)
|
||||
.unwrap_or(false);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
|
||||
// Use optimized SQL-based filtering
|
||||
// limit = 0 means no limit (fetch all tracks)
|
||||
@@ -886,7 +961,7 @@ pub async fn library_search(
|
||||
pub async fn library_get_stats(state: State<'_, LibraryState>) -> Result<LibraryStats, String> {
|
||||
log::info!("Command: library_get_stats");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.get_stats().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -894,7 +969,7 @@ pub async fn library_get_stats(state: State<'_, LibraryState>) -> Result<Library
|
||||
pub async fn library_clear(state: State<'_, LibraryState>) -> Result<(), String> {
|
||||
log::info!("Command: library_clear");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let mut db = state.db.lock().await;
|
||||
db.clear_all_tracks().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -1418,6 +1493,394 @@ pub async fn library_set_album_hidden(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAlbumTrackMetadataUpdate {
|
||||
pub id: i64,
|
||||
pub file_path: String,
|
||||
pub cue_start_secs: Option<f64>,
|
||||
pub title: String,
|
||||
pub disc_number: Option<u32>,
|
||||
pub track_number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryAlbumMetadataUpdateRequest {
|
||||
pub album_group_key: String,
|
||||
pub album_title: String,
|
||||
pub album_artist: String,
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub catalog_number: Option<String>,
|
||||
pub tracks: Vec<LibraryAlbumTrackMetadataUpdate>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_update_album_metadata(
|
||||
request: LibraryAlbumMetadataUpdateRequest,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"Command: library_update_album_metadata {}",
|
||||
request.album_group_key
|
||||
);
|
||||
|
||||
if request.album_group_key.trim().is_empty() {
|
||||
return Err("Album ID is required.".to_string());
|
||||
}
|
||||
if request.album_title.trim().is_empty() {
|
||||
return Err("Album title is required.".to_string());
|
||||
}
|
||||
if request.tracks.is_empty() {
|
||||
return Err("Album track list is empty.".to_string());
|
||||
}
|
||||
|
||||
let album_dir = PathBuf::from(request.album_group_key.trim());
|
||||
if !album_dir.is_dir() {
|
||||
return Err("Album folder not found on disk.".to_string());
|
||||
}
|
||||
|
||||
// Write sidecar first (persistence), then update DB.
|
||||
let sidecar_result = tokio::task::spawn_blocking({
|
||||
let album_dir = album_dir.clone();
|
||||
let request = request.clone();
|
||||
move || -> Result<(), String> {
|
||||
let album = crate::library::AlbumMetadataOverride {
|
||||
album_title: Some(request.album_title.trim().to_string()),
|
||||
album_artist: Some(request.album_artist.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
year: request.year,
|
||||
genre: request.genre.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
|
||||
catalog_number: request
|
||||
.catalog_number
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
};
|
||||
|
||||
let tracks = request
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| crate::library::TrackMetadataOverride {
|
||||
file_path: t.file_path.clone(),
|
||||
cue_start_secs: t.cue_start_secs,
|
||||
title: Some(t.title.trim().to_string()),
|
||||
disc_number: t.disc_number,
|
||||
track_number: t.track_number,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let sidecar = crate::library::AlbumTagSidecar::new(album, tracks);
|
||||
crate::library::write_album_sidecar(&album_dir, &sidecar)
|
||||
.map_err(|e| format!("Failed to write sidecar: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write sidecar: {}", e))?;
|
||||
sidecar_result?;
|
||||
|
||||
let mut db = state.db.lock().await;
|
||||
let existing_tracks = db
|
||||
.get_album_tracks(&request.album_group_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let track_artist_match = compute_track_artist_match(&existing_tracks);
|
||||
let track_updates = request
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| crate::library::AlbumTrackUpdate {
|
||||
id: t.id,
|
||||
title: t.title.clone(),
|
||||
disc_number: t.disc_number,
|
||||
track_number: t.track_number,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
db.update_album_group_metadata(
|
||||
&request.album_group_key,
|
||||
&request.album_title,
|
||||
&request.album_artist,
|
||||
request.year,
|
||||
request.genre.as_deref(),
|
||||
request.catalog_number.as_deref(),
|
||||
track_artist_match.as_deref(),
|
||||
&track_updates,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_write_album_metadata_to_files(
|
||||
app: tauri::AppHandle,
|
||||
request: LibraryAlbumMetadataUpdateRequest,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"Command: library_write_album_metadata_to_files {}",
|
||||
request.album_group_key
|
||||
);
|
||||
|
||||
if request.album_group_key.trim().is_empty() {
|
||||
return Err("Album ID is required.".to_string());
|
||||
}
|
||||
if request.album_title.trim().is_empty() {
|
||||
return Err("Album title is required.".to_string());
|
||||
}
|
||||
if request.tracks.is_empty() {
|
||||
return Err("Album track list is empty.".to_string());
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let existing_tracks = db
|
||||
.get_album_tracks(&request.album_group_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if existing_tracks
|
||||
.iter()
|
||||
.any(|t| t.cue_file_path.is_some() || t.cue_start_secs.is_some())
|
||||
{
|
||||
return Err("Writing tags to files is not supported for CUE-based albums. Use sidecar mode instead.".to_string());
|
||||
}
|
||||
drop(db);
|
||||
|
||||
let album_dir = PathBuf::from(request.album_group_key.trim());
|
||||
if !album_dir.is_dir() {
|
||||
return Err("Album folder not found on disk.".to_string());
|
||||
}
|
||||
|
||||
// Write embedded tags for each track file.
|
||||
let write_result = tokio::task::spawn_blocking({
|
||||
let request = request.clone();
|
||||
move || -> Result<(), String> {
|
||||
use lofty::{Accessor, AudioFile, ItemKey, Tag, TagExt, TaggedFileExt, TagType};
|
||||
|
||||
// Ensure we only write each file once.
|
||||
let mut by_file: HashMap<String, &LibraryAlbumTrackMetadataUpdate> = HashMap::new();
|
||||
for track in &request.tracks {
|
||||
by_file.entry(track.file_path.clone()).or_insert(track);
|
||||
}
|
||||
|
||||
let total = by_file.len();
|
||||
let mut current = 0usize;
|
||||
|
||||
for (file_path, track) in by_file {
|
||||
current += 1;
|
||||
// Emit progress event
|
||||
let _ = app.emit("library:tag_write_progress", serde_json::json!({
|
||||
"current": current,
|
||||
"total": total
|
||||
}));
|
||||
let path = Path::new(&file_path);
|
||||
if !path.is_file() {
|
||||
return Err("One or more audio files were not found on disk.".to_string());
|
||||
}
|
||||
|
||||
let mut tagged_file =
|
||||
lofty::read_from_path(path).map_err(|_| "Failed to read audio file tags.".to_string())?;
|
||||
|
||||
let primary_type = tagged_file.primary_tag_type();
|
||||
if tagged_file.primary_tag_mut().is_none() && tagged_file.first_tag_mut().is_none() {
|
||||
tagged_file.insert_tag(Tag::new(primary_type));
|
||||
}
|
||||
|
||||
{
|
||||
let tag = if let Some(tag) = tagged_file.primary_tag_mut() {
|
||||
tag
|
||||
} else if let Some(tag) = tagged_file.first_tag_mut() {
|
||||
tag
|
||||
} else {
|
||||
return Err("Failed to access audio file tags.".to_string());
|
||||
};
|
||||
|
||||
tag.set_title(track.title.trim().to_string());
|
||||
tag.set_album(request.album_title.trim().to_string());
|
||||
tag.set_artist(request.album_artist.trim().to_string());
|
||||
|
||||
if let Some(no) = track.track_number {
|
||||
tag.set_track(no);
|
||||
}
|
||||
if let Some(disc) = track.disc_number {
|
||||
tag.set_disk(disc);
|
||||
}
|
||||
|
||||
// Album artist (not part of Accessor).
|
||||
if request.album_artist.trim().is_empty() {
|
||||
tag.remove_key(&ItemKey::AlbumArtist);
|
||||
} else {
|
||||
tag.insert_text(ItemKey::AlbumArtist, request.album_artist.trim().to_string());
|
||||
}
|
||||
|
||||
// Year / Genre
|
||||
if let Some(year) = request.year {
|
||||
tag.set_year(year);
|
||||
} else {
|
||||
tag.remove_year();
|
||||
}
|
||||
|
||||
if let Some(ref genre) = request.genre {
|
||||
let g = genre.trim();
|
||||
if g.is_empty() {
|
||||
tag.remove_genre();
|
||||
} else {
|
||||
tag.set_genre(g.to_string());
|
||||
}
|
||||
} else {
|
||||
tag.remove_genre();
|
||||
}
|
||||
|
||||
if let Some(ref cat) = request.catalog_number {
|
||||
let c = cat.trim();
|
||||
if c.is_empty() {
|
||||
tag.remove_key(&ItemKey::CatalogNumber);
|
||||
} else {
|
||||
tag.insert_text(ItemKey::CatalogNumber, c.to_string());
|
||||
}
|
||||
} else {
|
||||
tag.remove_key(&ItemKey::CatalogNumber);
|
||||
}
|
||||
}
|
||||
|
||||
tagged_file
|
||||
.save_to_path(path)
|
||||
.map_err(|_| "Failed to write tags to audio files. Check that the album folder is mounted read-write and you have permissions.".to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write tags: {}", e))?;
|
||||
write_result?;
|
||||
|
||||
// Remove sidecar (direct-edit mode disables sidecar persistence).
|
||||
let _ = tokio::task::spawn_blocking({
|
||||
let album_dir = album_dir.clone();
|
||||
move || crate::library::delete_album_sidecar(&album_dir)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Update DB from the requested values.
|
||||
let mut db = state.db.lock().await;
|
||||
let existing_tracks = db
|
||||
.get_album_tracks(&request.album_group_key)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let track_artist_match = compute_track_artist_match(&existing_tracks);
|
||||
let track_updates = request
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|t| crate::library::AlbumTrackUpdate {
|
||||
id: t.id,
|
||||
title: t.title.clone(),
|
||||
disc_number: t.disc_number,
|
||||
track_number: t.track_number,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
db.update_album_group_metadata(
|
||||
&request.album_group_key,
|
||||
&request.album_title,
|
||||
&request.album_artist,
|
||||
request.year,
|
||||
request.genre.as_deref(),
|
||||
request.catalog_number.as_deref(),
|
||||
track_artist_match.as_deref(),
|
||||
&track_updates,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_refresh_album_metadata_from_files(
|
||||
album_group_key: String,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"Command: library_refresh_album_metadata_from_files {}",
|
||||
album_group_key
|
||||
);
|
||||
|
||||
if album_group_key.trim().is_empty() {
|
||||
return Err("Album ID is required.".to_string());
|
||||
}
|
||||
|
||||
let db = state.db.lock().await;
|
||||
let existing_tracks = db.get_album_tracks(&album_group_key).map_err(|e| e.to_string())?;
|
||||
if existing_tracks.is_empty() {
|
||||
return Err("Album not found.".to_string());
|
||||
}
|
||||
if existing_tracks
|
||||
.iter()
|
||||
.any(|t| t.cue_file_path.is_some() || t.cue_start_secs.is_some())
|
||||
{
|
||||
return Err(
|
||||
"Refreshing metadata from files is not supported for CUE-based albums.".to_string(),
|
||||
);
|
||||
}
|
||||
drop(db);
|
||||
|
||||
let album_dir = PathBuf::from(album_group_key.trim());
|
||||
if !album_dir.is_dir() {
|
||||
return Err("Album folder not found on disk.".to_string());
|
||||
}
|
||||
|
||||
// Delete sidecar, then refresh DB from embedded tags.
|
||||
let refresh = tokio::task::spawn_blocking({
|
||||
let existing_tracks = existing_tracks.clone();
|
||||
let album_dir = album_dir.clone();
|
||||
move || -> Result<Vec<crate::library::TrackMetadataUpdateFull>, String> {
|
||||
let _ = crate::library::delete_album_sidecar(&album_dir);
|
||||
|
||||
let mut updates: Vec<crate::library::TrackMetadataUpdateFull> = Vec::new();
|
||||
for track in existing_tracks {
|
||||
let path = Path::new(&track.file_path);
|
||||
if !path.is_file() {
|
||||
return Err("One or more audio files were not found on disk.".to_string());
|
||||
}
|
||||
|
||||
let extracted =
|
||||
MetadataExtractor::extract(path).map_err(|_| "Failed to read audio file tags.".to_string())?;
|
||||
|
||||
let album_group_title = if extracted.album_group_title.trim().is_empty() {
|
||||
extracted.album.clone()
|
||||
} else {
|
||||
extracted.album_group_title.clone()
|
||||
};
|
||||
|
||||
updates.push(crate::library::TrackMetadataUpdateFull {
|
||||
id: track.id,
|
||||
title: extracted.title,
|
||||
artist: extracted.artist,
|
||||
album: extracted.album,
|
||||
album_artist: extracted.album_artist,
|
||||
album_group_title,
|
||||
track_number: extracted.track_number,
|
||||
disc_number: extracted.disc_number,
|
||||
year: extracted.year,
|
||||
genre: extracted.genre,
|
||||
catalog_number: extracted.catalog_number,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(updates)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to refresh metadata: {}", e))?;
|
||||
let updates = refresh?;
|
||||
|
||||
let mut db = state.db.lock().await;
|
||||
db.update_tracks_metadata_by_id(&updates)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_get_hidden_albums(
|
||||
state: State<'_, LibraryState>,
|
||||
|
||||
@@ -5,6 +5,29 @@ use std::path::Path;
|
||||
|
||||
use crate::library::{AudioFormat, LibraryError, LocalAlbum, LocalArtist, LocalTrack};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlbumTrackUpdate {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub disc_number: Option<u32>,
|
||||
pub track_number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackMetadataUpdateFull {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub album_artist: Option<String>,
|
||||
pub album_group_title: String,
|
||||
pub track_number: Option<u32>,
|
||||
pub disc_number: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub catalog_number: Option<String>,
|
||||
}
|
||||
|
||||
/// Library database wrapper
|
||||
pub struct LibraryDatabase {
|
||||
conn: Connection,
|
||||
@@ -1250,6 +1273,145 @@ impl LibraryDatabase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_album_group_metadata(
|
||||
&mut self,
|
||||
group_key: &str,
|
||||
album_title: &str,
|
||||
album_artist: &str,
|
||||
year: Option<u32>,
|
||||
genre: Option<&str>,
|
||||
catalog_number: Option<&str>,
|
||||
track_artist_match: Option<&str>,
|
||||
track_updates: &[AlbumTrackUpdate],
|
||||
) -> Result<(), LibraryError> {
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction()
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
let normalized_album_artist = {
|
||||
let trimmed = album_artist.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
UPDATE local_tracks
|
||||
SET
|
||||
album = ?1,
|
||||
album_group_title = ?2,
|
||||
album_artist = ?3,
|
||||
year = ?4,
|
||||
genre = ?5,
|
||||
catalog_number = ?6
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?7
|
||||
"#,
|
||||
params![
|
||||
album_title.trim(),
|
||||
album_title.trim(),
|
||||
normalized_album_artist,
|
||||
year,
|
||||
genre.map(|s| s.trim()).filter(|s| !s.is_empty()),
|
||||
catalog_number.map(|s| s.trim()).filter(|s| !s.is_empty()),
|
||||
group_key
|
||||
],
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
if let Some(match_artist) = track_artist_match {
|
||||
let match_trim = match_artist.trim();
|
||||
if !match_trim.is_empty() && !album_artist.trim().is_empty() {
|
||||
tx.execute(
|
||||
r#"
|
||||
UPDATE local_tracks
|
||||
SET artist = ?1
|
||||
WHERE COALESCE(album_group_key, album || '|' || COALESCE(album_artist, artist)) = ?2
|
||||
AND artist = ?3
|
||||
"#,
|
||||
params![album_artist.trim(), group_key, match_trim],
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare("UPDATE local_tracks SET title = ?1, disc_number = ?2, track_number = ?3 WHERE id = ?4")
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
for update in track_updates {
|
||||
stmt.execute(params![
|
||||
update.title.trim(),
|
||||
update.disc_number,
|
||||
update.track_number,
|
||||
update.id
|
||||
])
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_tracks_metadata_by_id(
|
||||
&mut self,
|
||||
updates: &[TrackMetadataUpdateFull],
|
||||
) -> Result<(), LibraryError> {
|
||||
let tx = self
|
||||
.conn
|
||||
.transaction()
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare(
|
||||
r#"
|
||||
UPDATE local_tracks
|
||||
SET
|
||||
title = ?1,
|
||||
artist = ?2,
|
||||
album = ?3,
|
||||
album_artist = ?4,
|
||||
album_group_title = ?5,
|
||||
track_number = ?6,
|
||||
disc_number = ?7,
|
||||
year = ?8,
|
||||
genre = ?9,
|
||||
catalog_number = ?10
|
||||
WHERE id = ?11
|
||||
"#,
|
||||
)
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
|
||||
for update in updates {
|
||||
stmt.execute(params![
|
||||
update.title.trim(),
|
||||
update.artist.trim(),
|
||||
update.album.trim(),
|
||||
update.album_artist.as_ref().map(|s| s.trim().to_string()),
|
||||
update.album_group_title.trim(),
|
||||
update.track_number,
|
||||
update.disc_number,
|
||||
update.year,
|
||||
update.genre.as_ref().map(|s| s.trim().to_string()),
|
||||
update.catalog_number.as_ref().map(|s| s.trim().to_string()),
|
||||
update.id
|
||||
])
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_album_group_key(
|
||||
&self,
|
||||
album: &str,
|
||||
|
||||
@@ -9,15 +9,21 @@ pub mod database;
|
||||
pub mod errors;
|
||||
pub mod metadata;
|
||||
pub mod models;
|
||||
pub mod remote_metadata;
|
||||
pub mod scanner;
|
||||
pub mod tag_sidecar;
|
||||
pub mod thumbnails;
|
||||
|
||||
pub use commands::LibraryState;
|
||||
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTrack};
|
||||
pub use database::{LibraryDatabase, LibraryFolder, LibraryStats, PlaylistFolder, PlaylistSettings, PlaylistStats};
|
||||
pub use database::{
|
||||
AlbumTrackUpdate, LibraryDatabase, LibraryFolder, LibraryStats, PlaylistFolder, PlaylistSettings,
|
||||
PlaylistStats, TrackMetadataUpdateFull,
|
||||
};
|
||||
pub use errors::LibraryError;
|
||||
pub use metadata::MetadataExtractor;
|
||||
pub use models::*;
|
||||
pub use tag_sidecar::*;
|
||||
pub use scanner::{LibraryScanner, ScanResult};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! In-memory cache for remote metadata requests
|
||||
//!
|
||||
//! Provides short-lived caching to avoid redundant API calls during
|
||||
//! a tag editing session.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::models::{RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteProvider};
|
||||
|
||||
/// Cache entry with expiration
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry<T> {
|
||||
data: T,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
fn new(data: T, ttl: Duration) -> Self {
|
||||
Self {
|
||||
data,
|
||||
inserted_at: Instant::now(),
|
||||
ttl,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
self.inserted_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for search results
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
struct SearchCacheKey {
|
||||
provider: String,
|
||||
query: String,
|
||||
catalog_id: Option<String>,
|
||||
artist: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache key for full metadata
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
struct MetadataCacheKey {
|
||||
provider: String,
|
||||
provider_id: String,
|
||||
}
|
||||
|
||||
/// Remote metadata cache
|
||||
pub struct RemoteMetadataCache {
|
||||
/// Search results cache (short TTL: 10 minutes)
|
||||
search_cache: Arc<RwLock<HashMap<SearchCacheKey, CacheEntry<Vec<RemoteAlbumSearchResult>>>>>,
|
||||
/// Full metadata cache (longer TTL: 1 hour)
|
||||
metadata_cache: Arc<RwLock<HashMap<MetadataCacheKey, CacheEntry<RemoteAlbumMetadata>>>>,
|
||||
/// TTL for search results
|
||||
search_ttl: Duration,
|
||||
/// TTL for full metadata
|
||||
metadata_ttl: Duration,
|
||||
}
|
||||
|
||||
impl Default for RemoteMetadataCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteMetadataCache {
|
||||
/// Create a new cache with default TTLs
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
search_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
metadata_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
search_ttl: Duration::from_secs(10 * 60), // 10 minutes
|
||||
metadata_ttl: Duration::from_secs(60 * 60), // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached search results
|
||||
pub async fn get_search(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
query: &str,
|
||||
catalog_id: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
) -> Option<Vec<RemoteAlbumSearchResult>> {
|
||||
let key = SearchCacheKey {
|
||||
provider: provider.to_string(),
|
||||
query: query.to_lowercase(),
|
||||
catalog_id: catalog_id.map(|s| s.to_lowercase()),
|
||||
artist: artist.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
let cache = self.search_cache.read().await;
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if !entry.is_expired() {
|
||||
log::debug!(
|
||||
"Cache hit for search: {} {:?}",
|
||||
provider,
|
||||
query
|
||||
);
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Store search results in cache
|
||||
pub async fn set_search(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
query: &str,
|
||||
catalog_id: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
results: Vec<RemoteAlbumSearchResult>,
|
||||
) {
|
||||
let key = SearchCacheKey {
|
||||
provider: provider.to_string(),
|
||||
query: query.to_lowercase(),
|
||||
catalog_id: catalog_id.map(|s| s.to_lowercase()),
|
||||
artist: artist.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
let mut cache = self.search_cache.write().await;
|
||||
cache.insert(key, CacheEntry::new(results, self.search_ttl));
|
||||
}
|
||||
|
||||
/// Get cached full metadata
|
||||
pub async fn get_metadata(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
provider_id: &str,
|
||||
) -> Option<RemoteAlbumMetadata> {
|
||||
let key = MetadataCacheKey {
|
||||
provider: provider.to_string(),
|
||||
provider_id: provider_id.to_string(),
|
||||
};
|
||||
|
||||
let cache = self.metadata_cache.read().await;
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if !entry.is_expired() {
|
||||
log::debug!(
|
||||
"Cache hit for metadata: {} {}",
|
||||
provider,
|
||||
provider_id
|
||||
);
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Store full metadata in cache
|
||||
pub async fn set_metadata(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
provider_id: &str,
|
||||
metadata: RemoteAlbumMetadata,
|
||||
) {
|
||||
let key = MetadataCacheKey {
|
||||
provider: provider.to_string(),
|
||||
provider_id: provider_id.to_string(),
|
||||
};
|
||||
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
cache.insert(key, CacheEntry::new(metadata, self.metadata_ttl));
|
||||
}
|
||||
|
||||
/// Clear expired entries from both caches
|
||||
pub async fn cleanup_expired(&self) -> usize {
|
||||
let mut removed = 0;
|
||||
|
||||
{
|
||||
let mut cache = self.search_cache.write().await;
|
||||
let before = cache.len();
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
removed += before - cache.len();
|
||||
}
|
||||
|
||||
{
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
let before = cache.len();
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
removed += before - cache.len();
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
log::debug!("Cleaned up {} expired cache entries", removed);
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
pub async fn clear_all(&self) {
|
||||
{
|
||||
let mut cache = self.search_cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
{
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
log::info!("Cleared remote metadata cache");
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub async fn stats(&self) -> CacheStats {
|
||||
let search_count = self.search_cache.read().await.len();
|
||||
let metadata_count = self.metadata_cache.read().await.len();
|
||||
|
||||
CacheStats {
|
||||
search_entries: search_count,
|
||||
metadata_entries: metadata_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CacheStats {
|
||||
pub search_entries: usize,
|
||||
pub metadata_entries: usize,
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Remote metadata fetching for the Tag Editor
|
||||
//!
|
||||
//! This module provides a unified interface for fetching album metadata
|
||||
//! from MusicBrainz and Discogs. The Tag Editor uses these services to
|
||||
//! help users fill in metadata for their local library albums.
|
||||
//!
|
||||
//! # Providers
|
||||
//!
|
||||
//! - **MusicBrainz** (default): Free, community-maintained database.
|
||||
//! No authentication required. Uses tags for genres.
|
||||
//!
|
||||
//! - **Discogs**: Commercial database with extensive catalog.
|
||||
//! Requires API credentials (handled via proxy). Has genres and styles.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! 1. Search for albums using `search_albums()`
|
||||
//! 2. Display results to user
|
||||
//! 3. Fetch full metadata using `get_album_metadata()`
|
||||
//! 4. Apply to editor form
|
||||
|
||||
mod cache;
|
||||
mod models;
|
||||
|
||||
pub use cache::{CacheStats, RemoteMetadataCache};
|
||||
pub use models::{
|
||||
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider,
|
||||
RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::discogs::DiscogsClient;
|
||||
use crate::musicbrainz::MusicBrainzSharedState;
|
||||
|
||||
/// Shared state for remote metadata operations
|
||||
pub struct RemoteMetadataState {
|
||||
/// In-memory cache
|
||||
pub cache: RemoteMetadataCache,
|
||||
/// MusicBrainz client (shared with existing integration)
|
||||
pub musicbrainz: Option<Arc<MusicBrainzSharedState>>,
|
||||
/// Discogs client
|
||||
pub discogs: Arc<Mutex<DiscogsClient>>,
|
||||
}
|
||||
|
||||
impl RemoteMetadataState {
|
||||
pub fn new(musicbrainz: Option<Arc<MusicBrainzSharedState>>) -> Self {
|
||||
Self {
|
||||
cache: RemoteMetadataCache::new(),
|
||||
musicbrainz,
|
||||
discogs: Arc::new(Mutex::new(DiscogsClient::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ MusicBrainz Adapter ============
|
||||
|
||||
/// Convert MusicBrainz release search result to unified DTO
|
||||
pub fn musicbrainz_release_to_search_result(
|
||||
release: &crate::musicbrainz::models::ReleaseResult,
|
||||
) -> RemoteAlbumSearchResult {
|
||||
// Extract artist from artist-credit
|
||||
let artist = release
|
||||
.artist_credit
|
||||
.as_ref()
|
||||
.map(|credits| {
|
||||
credits
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{}{}",
|
||||
c.name.as_deref().unwrap_or(&c.artist.name),
|
||||
c.joinphrase.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract year from date (YYYY or YYYY-MM-DD)
|
||||
let year = release.date.as_ref().and_then(|d| {
|
||||
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
|
||||
});
|
||||
|
||||
// Extract label and catalog number
|
||||
let (label, catalog_number) = release
|
||||
.label_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.first())
|
||||
.map(|li| {
|
||||
(
|
||||
li.label.as_ref().map(|l| l.name.clone()),
|
||||
li.catalog_number.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Get track count - either from direct field or sum from media
|
||||
let track_count = release.track_count.or_else(|| {
|
||||
release.media.as_ref().map(|media| {
|
||||
media.iter().filter_map(|m| m.track_count).sum()
|
||||
})
|
||||
});
|
||||
|
||||
// Get format from first medium
|
||||
let format = release
|
||||
.media
|
||||
.as_ref()
|
||||
.and_then(|m| m.first())
|
||||
.and_then(|m| m.format.clone());
|
||||
|
||||
RemoteAlbumSearchResult {
|
||||
provider: RemoteProvider::MusicBrainz,
|
||||
provider_id: release.id.clone(),
|
||||
title: release.title.clone(),
|
||||
artist,
|
||||
year,
|
||||
track_count,
|
||||
country: release.country.clone(),
|
||||
label,
|
||||
catalog_number,
|
||||
confidence: release.score.map(|s| s.min(100) as u8),
|
||||
format,
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Discogs Adapter ============
|
||||
|
||||
/// Parse Discogs track position to (disc_number, track_number)
|
||||
/// Handles formats: "1", "A1", "1-1", "CD1-1", "1.1"
|
||||
pub fn parse_discogs_position(position: &str) -> (u8, u8) {
|
||||
let position = position.trim();
|
||||
|
||||
// Handle empty position
|
||||
if position.is_empty() {
|
||||
return (1, 1);
|
||||
}
|
||||
|
||||
// Try "X-Y" format (e.g., "1-5", "CD1-3")
|
||||
if let Some(pos) = position.find('-') {
|
||||
let disc_part = &position[..pos];
|
||||
let track_part = &position[pos + 1..];
|
||||
|
||||
// Extract number from disc part (handle "CD1", "1", etc.)
|
||||
let disc = disc_part
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u8>()
|
||||
.unwrap_or(1);
|
||||
|
||||
let track = track_part.parse::<u8>().unwrap_or(1);
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Try "X.Y" format
|
||||
if let Some(pos) = position.find('.') {
|
||||
let disc_part = &position[..pos];
|
||||
let track_part = &position[pos + 1..];
|
||||
|
||||
let disc = disc_part.parse::<u8>().unwrap_or(1);
|
||||
let track = track_part.parse::<u8>().unwrap_or(1);
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Handle vinyl sides (A, B, C, D -> disc 1, 1, 2, 2)
|
||||
if position.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
let side = position.chars().next().unwrap().to_ascii_uppercase();
|
||||
let track_str: String = position.chars().skip(1).collect();
|
||||
let track = track_str.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let disc = match side {
|
||||
'A' | 'B' => 1,
|
||||
'C' | 'D' => 2,
|
||||
'E' | 'F' => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Simple number
|
||||
let track = position.parse::<u8>().unwrap_or(1);
|
||||
(1, track)
|
||||
}
|
||||
|
||||
/// Parse Discogs duration string to milliseconds
|
||||
/// Handles format: "M:SS" or "MM:SS" or "H:MM:SS"
|
||||
pub fn parse_discogs_duration(duration: &str) -> Option<u32> {
|
||||
let parts: Vec<&str> = duration.split(':').collect();
|
||||
|
||||
match parts.len() {
|
||||
2 => {
|
||||
// M:SS or MM:SS
|
||||
let minutes: u32 = parts[0].parse().ok()?;
|
||||
let seconds: u32 = parts[1].parse().ok()?;
|
||||
Some((minutes * 60 + seconds) * 1000)
|
||||
}
|
||||
3 => {
|
||||
// H:MM:SS
|
||||
let hours: u32 = parts[0].parse().ok()?;
|
||||
let minutes: u32 = parts[1].parse().ok()?;
|
||||
let seconds: u32 = parts[2].parse().ok()?;
|
||||
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Discogs extended search result to unified DTO
|
||||
pub fn discogs_extended_to_search_result(
|
||||
result: &crate::discogs::DiscogsSearchResultExtended,
|
||||
) -> RemoteAlbumSearchResult {
|
||||
// Discogs title format is usually "Artist - Album"
|
||||
let (artist, title) = if let Some(pos) = result.title.find(" - ") {
|
||||
let (a, t) = result.title.split_at(pos);
|
||||
(a.to_string(), t.trim_start_matches(" - ").to_string())
|
||||
} else {
|
||||
("Unknown Artist".to_string(), result.title.clone())
|
||||
};
|
||||
|
||||
// Parse year from string
|
||||
let year = result.year.as_ref().and_then(|y| y.parse::<u16>().ok());
|
||||
|
||||
// Get first label
|
||||
let label = result.label.as_ref().and_then(|l| l.first().cloned());
|
||||
|
||||
// Get format as string
|
||||
let format = result.format.as_ref().map(|f| f.join(", "));
|
||||
|
||||
RemoteAlbumSearchResult {
|
||||
provider: RemoteProvider::Discogs,
|
||||
provider_id: result.id.to_string(),
|
||||
title,
|
||||
artist,
|
||||
year,
|
||||
track_count: None,
|
||||
country: result.country.clone(),
|
||||
label,
|
||||
catalog_number: result.catno.clone(),
|
||||
confidence: None,
|
||||
format,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert MusicBrainz full release to unified metadata DTO
|
||||
pub fn musicbrainz_full_to_metadata(
|
||||
release: &crate::musicbrainz::ReleaseFullResponse,
|
||||
) -> RemoteAlbumMetadata {
|
||||
// Extract artist from artist-credit
|
||||
let artist = release
|
||||
.artist_credit
|
||||
.as_ref()
|
||||
.map(|credits| {
|
||||
credits
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"{}{}",
|
||||
c.name.as_deref().unwrap_or(&c.artist.name),
|
||||
c.joinphrase.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract year from date
|
||||
let year = release.date.as_ref().and_then(|d| {
|
||||
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
|
||||
});
|
||||
|
||||
// Extract genres from tags (sorted by count, take top 5)
|
||||
let genres: Vec<String> = release.tags.as_ref().map(|tags| {
|
||||
let mut sorted: Vec<_> = tags.iter().collect();
|
||||
sorted.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
sorted.iter().take(5).map(|t| t.name.clone()).collect()
|
||||
}).unwrap_or_default();
|
||||
|
||||
// Extract label and catalog number
|
||||
let (label, catalog_number) = release
|
||||
.label_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.first())
|
||||
.map(|li| {
|
||||
(
|
||||
li.label.as_ref().map(|l| l.name.clone()),
|
||||
li.catalog_number.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Count discs
|
||||
let disc_count = release.media.as_ref().map(|m| m.len() as u8).unwrap_or(1);
|
||||
|
||||
// Convert tracks
|
||||
let tracks: Vec<RemoteTrackMetadata> = release.media.as_ref().map(|media| {
|
||||
let mut all_tracks = Vec::new();
|
||||
for medium in media {
|
||||
if let Some(tracks) = &medium.tracks {
|
||||
for track in tracks {
|
||||
all_tracks.push(RemoteTrackMetadata {
|
||||
disc_number: medium.position.unwrap_or(1),
|
||||
track_number: track.position.unwrap_or(1),
|
||||
title: track.title.clone().or_else(|| {
|
||||
track.recording.as_ref().and_then(|r| r.title.clone())
|
||||
}).unwrap_or_default(),
|
||||
duration_ms: track.length.map(|l| l as u32).or_else(|| {
|
||||
track.recording.as_ref().and_then(|r| r.length.map(|l| l as u32))
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
all_tracks
|
||||
}).unwrap_or_default();
|
||||
|
||||
RemoteAlbumMetadata {
|
||||
provider: RemoteProvider::MusicBrainz,
|
||||
provider_id: release.id.clone(),
|
||||
title: release.title.clone(),
|
||||
artist,
|
||||
year,
|
||||
genres,
|
||||
label,
|
||||
catalog_number,
|
||||
country: release.country.clone(),
|
||||
barcode: release.barcode.clone(),
|
||||
tracks,
|
||||
disc_count,
|
||||
source_url: Some(format!("https://musicbrainz.org/release/{}", release.id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Discogs full release to unified metadata DTO
|
||||
pub fn discogs_full_to_metadata(
|
||||
release: &crate::discogs::DiscogsReleaseMetadata,
|
||||
) -> RemoteAlbumMetadata {
|
||||
// Combine artists with join phrases
|
||||
let artist = release
|
||||
.artists
|
||||
.as_ref()
|
||||
.map(|artists| {
|
||||
artists
|
||||
.iter()
|
||||
.map(|a| {
|
||||
format!(
|
||||
"{}{}",
|
||||
a.name.clone(),
|
||||
a.join.as_deref().unwrap_or("")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Combine genres and styles
|
||||
let genres: Vec<String> = {
|
||||
let mut combined = Vec::new();
|
||||
if let Some(g) = &release.genres {
|
||||
combined.extend(g.clone());
|
||||
}
|
||||
if let Some(s) = &release.styles {
|
||||
combined.extend(s.clone());
|
||||
}
|
||||
combined
|
||||
};
|
||||
|
||||
// Get first label and catalog number
|
||||
let (label, catalog_number) = release
|
||||
.labels
|
||||
.as_ref()
|
||||
.and_then(|labels| labels.first())
|
||||
.map(|l| (Some(l.name.clone()), l.catno.clone()))
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Convert tracklist
|
||||
let tracks: Vec<RemoteTrackMetadata> = release.tracklist.as_ref().map(|tracklist| {
|
||||
tracklist
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
// Filter out headings (disc separators)
|
||||
t.track_type.as_deref() != Some("heading")
|
||||
})
|
||||
.map(|t| {
|
||||
let (disc_number, track_number) = parse_discogs_position(&t.position);
|
||||
RemoteTrackMetadata {
|
||||
disc_number,
|
||||
track_number,
|
||||
title: t.title.clone(),
|
||||
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}).unwrap_or_default();
|
||||
|
||||
// Count unique discs
|
||||
let disc_count = tracks.iter().map(|t| t.disc_number).max().unwrap_or(1);
|
||||
|
||||
RemoteAlbumMetadata {
|
||||
provider: RemoteProvider::Discogs,
|
||||
provider_id: release.id.to_string(),
|
||||
title: release.title.clone(),
|
||||
artist,
|
||||
year: release.year.map(|y| y as u16),
|
||||
genres,
|
||||
label,
|
||||
catalog_number,
|
||||
country: release.country.clone(),
|
||||
barcode: None, // Discogs doesn't include barcode in release details
|
||||
tracks,
|
||||
disc_count,
|
||||
source_url: release.uri.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Main Service Functions ============
|
||||
|
||||
impl RemoteMetadataState {
|
||||
/// Search for albums using the specified provider
|
||||
pub async fn search_albums(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
query: &str,
|
||||
artist: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
|
||||
// Check cache first
|
||||
if let Some(cached) = self.cache.get_search(provider, query, None, artist).await {
|
||||
log::debug!("Cache hit for search: {:?} {}", provider, query);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let results = match provider {
|
||||
RemoteProvider::MusicBrainz => {
|
||||
self.search_musicbrainz(query, artist.unwrap_or(""), limit).await?
|
||||
}
|
||||
RemoteProvider::Discogs => {
|
||||
self.search_discogs(query, artist.unwrap_or(""), limit).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Cache results
|
||||
self.cache.set_search(provider, query, None, artist, results.clone()).await;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Get full album metadata by provider ID
|
||||
pub async fn get_album_metadata(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
provider_id: &str,
|
||||
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
|
||||
// Check cache first
|
||||
if let Some(cached) = self.cache.get_metadata(provider, provider_id).await {
|
||||
log::debug!("Cache hit for metadata: {:?} {}", provider, provider_id);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let metadata = match provider {
|
||||
RemoteProvider::MusicBrainz => {
|
||||
self.get_musicbrainz_metadata(provider_id).await?
|
||||
}
|
||||
RemoteProvider::Discogs => {
|
||||
self.get_discogs_metadata(provider_id).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Cache results
|
||||
self.cache.set_metadata(provider, provider_id, metadata.clone()).await;
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Search MusicBrainz for releases
|
||||
async fn search_musicbrainz(
|
||||
&self,
|
||||
query: &str,
|
||||
artist: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
|
||||
let mb_state = self.musicbrainz.as_ref()
|
||||
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?;
|
||||
|
||||
let response = mb_state.client
|
||||
.search_releases_extended(query, artist, None, limit)
|
||||
.await
|
||||
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
|
||||
|
||||
let results = response.releases
|
||||
.iter()
|
||||
.map(musicbrainz_release_to_search_result)
|
||||
.collect();
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Search Discogs for releases
|
||||
async fn search_discogs(
|
||||
&self,
|
||||
query: &str,
|
||||
artist: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
|
||||
let client = self.discogs.lock().await;
|
||||
|
||||
let results = client
|
||||
.search_releases(artist, query, None, limit)
|
||||
.await
|
||||
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
|
||||
|
||||
let converted = results
|
||||
.iter()
|
||||
.map(discogs_extended_to_search_result)
|
||||
.collect();
|
||||
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
/// Get full metadata from MusicBrainz
|
||||
async fn get_musicbrainz_metadata(
|
||||
&self,
|
||||
release_id: &str,
|
||||
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
|
||||
let mb_state = self.musicbrainz.as_ref()
|
||||
.ok_or(RemoteMetadataError::ProviderUnavailable("MusicBrainz not initialized".to_string()))?;
|
||||
|
||||
let response = mb_state.client
|
||||
.get_release_with_tracks(release_id)
|
||||
.await
|
||||
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
|
||||
|
||||
Ok(musicbrainz_full_to_metadata(&response))
|
||||
}
|
||||
|
||||
/// Get full metadata from Discogs
|
||||
async fn get_discogs_metadata(
|
||||
&self,
|
||||
release_id: &str,
|
||||
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
|
||||
let release_id: u64 = release_id.parse()
|
||||
.map_err(|_| RemoteMetadataError::InvalidProviderId("Invalid Discogs release ID".to_string()))?;
|
||||
|
||||
let client = self.discogs.lock().await;
|
||||
|
||||
let response = client
|
||||
.get_release_metadata(release_id)
|
||||
.await
|
||||
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
|
||||
|
||||
Ok(discogs_full_to_metadata(&response))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_discogs_position() {
|
||||
assert_eq!(parse_discogs_position("1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("1-1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("1-5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("2-3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("CD1-5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("CD2-3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("A1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("B2"), (1, 2));
|
||||
assert_eq!(parse_discogs_position("C1"), (2, 1));
|
||||
assert_eq!(parse_discogs_position("D3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("1.5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("2.3"), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_discogs_duration() {
|
||||
assert_eq!(parse_discogs_duration("3:45"), Some(225000));
|
||||
assert_eq!(parse_discogs_duration("0:30"), Some(30000));
|
||||
assert_eq!(parse_discogs_duration("10:00"), Some(600000));
|
||||
assert_eq!(parse_discogs_duration("1:00:00"), Some(3600000));
|
||||
assert_eq!(parse_discogs_duration("invalid"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Unified DTOs for remote metadata providers (MusicBrainz, Discogs)
|
||||
//!
|
||||
//! These structs provide a provider-neutral interface for the Tag Editor
|
||||
//! to consume album metadata from different sources.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Remote metadata provider
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RemoteProvider {
|
||||
MusicBrainz,
|
||||
Discogs,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MusicBrainz => write!(f, "musicbrainz"),
|
||||
Self::Discogs => write!(f, "discogs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for RemoteProvider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"musicbrainz" | "mb" => Ok(Self::MusicBrainz),
|
||||
"discogs" => Ok(Self::Discogs),
|
||||
_ => Err(format!("Unknown provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight search result for displaying in results list
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteAlbumSearchResult {
|
||||
/// Provider identifier
|
||||
pub provider: RemoteProvider,
|
||||
/// Provider-specific ID (MusicBrainz release ID or Discogs release ID)
|
||||
pub provider_id: String,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artist
|
||||
pub artist: String,
|
||||
/// Release year (extracted from date)
|
||||
pub year: Option<u16>,
|
||||
/// Number of tracks (if available from search)
|
||||
pub track_count: Option<u16>,
|
||||
/// Release country
|
||||
pub country: Option<String>,
|
||||
/// Record label
|
||||
pub label: Option<String>,
|
||||
/// Catalog number
|
||||
pub catalog_number: Option<String>,
|
||||
/// Match confidence (0-100, provider-specific)
|
||||
pub confidence: Option<u8>,
|
||||
/// Format info (e.g., "CD", "Vinyl", "Digital")
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
/// Full album metadata with tracks (for applying to form)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteAlbumMetadata {
|
||||
/// Provider identifier
|
||||
pub provider: RemoteProvider,
|
||||
/// Provider-specific ID
|
||||
pub provider_id: String,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artist
|
||||
pub artist: String,
|
||||
/// Release year
|
||||
pub year: Option<u16>,
|
||||
/// Genres/styles/tags
|
||||
pub genres: Vec<String>,
|
||||
/// Record label
|
||||
pub label: Option<String>,
|
||||
/// Catalog number
|
||||
pub catalog_number: Option<String>,
|
||||
/// Release country
|
||||
pub country: Option<String>,
|
||||
/// Barcode/UPC (if available)
|
||||
pub barcode: Option<String>,
|
||||
/// Track list organized by disc
|
||||
pub tracks: Vec<RemoteTrackMetadata>,
|
||||
/// Total disc count
|
||||
pub disc_count: u8,
|
||||
/// URL to view on provider website
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Single track metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteTrackMetadata {
|
||||
/// Disc number (1-based)
|
||||
pub disc_number: u8,
|
||||
/// Track number within disc (1-based)
|
||||
pub track_number: u8,
|
||||
/// Track title
|
||||
pub title: String,
|
||||
/// Duration in milliseconds (if available)
|
||||
pub duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Search request parameters
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSearchRequest {
|
||||
/// Provider to search
|
||||
pub provider: RemoteProvider,
|
||||
/// Search query (usually "artist album")
|
||||
pub query: String,
|
||||
/// Optional catalog number for more precise matching
|
||||
pub catalog_id: Option<String>,
|
||||
/// Optional artist name for filtering
|
||||
pub artist: Option<String>,
|
||||
/// Maximum results to return (default: 10)
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RemoteSearchRequest {
|
||||
pub fn limit(&self) -> usize {
|
||||
self.limit.unwrap_or(10).min(25)
|
||||
}
|
||||
}
|
||||
|
||||
/// Search response wrapper
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSearchResponse {
|
||||
/// Provider that was searched
|
||||
pub provider: RemoteProvider,
|
||||
/// Search results
|
||||
pub results: Vec<RemoteAlbumSearchResult>,
|
||||
/// Total results available (may be more than returned)
|
||||
pub total_count: Option<usize>,
|
||||
/// Whether rate limit was hit
|
||||
pub rate_limited: bool,
|
||||
}
|
||||
|
||||
impl RemoteSearchResponse {
|
||||
pub fn empty(provider: RemoteProvider) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
results: Vec::new(),
|
||||
total_count: Some(0),
|
||||
rate_limited: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rate_limited(provider: RemoteProvider) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
results: Vec::new(),
|
||||
total_count: None,
|
||||
rate_limited: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error types for remote metadata operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "message")]
|
||||
pub enum RemoteMetadataError {
|
||||
/// Network or connection error
|
||||
NetworkError(String),
|
||||
/// Rate limit exceeded
|
||||
RateLimited(String),
|
||||
/// Invalid response from provider
|
||||
InvalidResponse(String),
|
||||
/// No results found
|
||||
NoResults,
|
||||
/// Provider not available
|
||||
ProviderUnavailable(String),
|
||||
/// Invalid provider ID
|
||||
InvalidProviderId(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteMetadataError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
|
||||
Self::RateLimited(msg) => write!(f, "Rate limited: {}", msg),
|
||||
Self::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
|
||||
Self::NoResults => write!(f, "No results found"),
|
||||
Self::ProviderUnavailable(msg) => write!(f, "Provider unavailable: {}", msg),
|
||||
Self::InvalidProviderId(msg) => write!(f, "Invalid provider ID: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemoteMetadataError> for String {
|
||||
fn from(err: RemoteMetadataError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_parsing() {
|
||||
assert_eq!(
|
||||
"musicbrainz".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::MusicBrainz
|
||||
);
|
||||
assert_eq!(
|
||||
"discogs".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::Discogs
|
||||
);
|
||||
assert_eq!(
|
||||
"MB".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::MusicBrainz
|
||||
);
|
||||
assert!("unknown".parse::<RemoteProvider>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_request_limit() {
|
||||
let req = RemoteSearchRequest {
|
||||
provider: RemoteProvider::MusicBrainz,
|
||||
query: "test".to_string(),
|
||||
catalog_id: None,
|
||||
artist: None,
|
||||
limit: None,
|
||||
};
|
||||
assert_eq!(req.limit(), 10);
|
||||
|
||||
let req_custom = RemoteSearchRequest {
|
||||
limit: Some(50),
|
||||
..req.clone()
|
||||
};
|
||||
assert_eq!(req_custom.limit(), 25); // Capped at 25
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! LocalLibrary album tag sidecar support.
|
||||
//!
|
||||
//! Sidecar files live next to album folders (default `.qbz.json`) and store
|
||||
//! album-level + per-track metadata overrides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::library::{LibraryError, LocalTrack};
|
||||
|
||||
const SIDECAR_FILE_NAME: &str = ".qbz.json";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlbumMetadataOverride {
|
||||
pub album_title: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub genre: Option<String>,
|
||||
pub catalog_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrackMetadataOverride {
|
||||
pub file_path: String,
|
||||
pub cue_start_secs: Option<f64>,
|
||||
pub title: Option<String>,
|
||||
pub disc_number: Option<u32>,
|
||||
pub track_number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AlbumTagSidecar {
|
||||
pub version: u32,
|
||||
pub updated_at: i64,
|
||||
pub album: AlbumMetadataOverride,
|
||||
pub tracks: Vec<TrackMetadataOverride>,
|
||||
}
|
||||
|
||||
impl AlbumTagSidecar {
|
||||
pub fn new(album: AlbumMetadataOverride, tracks: Vec<TrackMetadataOverride>) -> Self {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
version: 1,
|
||||
updated_at: now,
|
||||
album,
|
||||
tracks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sidecar_path(album_dir: &Path) -> PathBuf {
|
||||
album_dir.join(SIDECAR_FILE_NAME)
|
||||
}
|
||||
|
||||
pub fn read_album_sidecar(album_dir: &Path) -> Result<Option<AlbumTagSidecar>, LibraryError> {
|
||||
let path = sidecar_path(album_dir);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bytes = fs::read(&path).map_err(LibraryError::Io)?;
|
||||
let sidecar: AlbumTagSidecar =
|
||||
serde_json::from_slice(&bytes).map_err(|e| LibraryError::Metadata(e.to_string()))?;
|
||||
Ok(Some(sidecar))
|
||||
}
|
||||
|
||||
pub fn write_album_sidecar(album_dir: &Path, sidecar: &AlbumTagSidecar) -> Result<(), LibraryError> {
|
||||
fs::create_dir_all(album_dir).map_err(LibraryError::Io)?;
|
||||
|
||||
let target = sidecar_path(album_dir);
|
||||
let tmp = album_dir.join(format!("{}.tmp", SIDECAR_FILE_NAME));
|
||||
let content =
|
||||
serde_json::to_vec_pretty(sidecar).map_err(|e| LibraryError::Metadata(e.to_string()))?;
|
||||
|
||||
fs::write(&tmp, content).map_err(LibraryError::Io)?;
|
||||
fs::rename(&tmp, &target).map_err(LibraryError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_album_sidecar(album_dir: &Path) -> Result<(), LibraryError> {
|
||||
let path = sidecar_path(album_dir);
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
fs::remove_file(&path).map_err(LibraryError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply_sidecar_to_track(track: &mut LocalTrack, sidecar: &AlbumTagSidecar) {
|
||||
if let Some(title) = sidecar.album.album_title.as_ref().and_then(|s| normalize(s)) {
|
||||
track.album = title.clone();
|
||||
track.album_group_title = title.clone();
|
||||
}
|
||||
|
||||
if let Some(album_artist) = sidecar.album.album_artist.as_ref().and_then(|s| normalize(s)) {
|
||||
track.album_artist = Some(album_artist.clone());
|
||||
}
|
||||
|
||||
if let Some(year) = sidecar.album.year {
|
||||
track.year = Some(year);
|
||||
}
|
||||
|
||||
if let Some(genre) = sidecar.album.genre.as_ref().and_then(|s| normalize(s)) {
|
||||
track.genre = Some(genre.clone());
|
||||
}
|
||||
|
||||
if let Some(cat) = sidecar
|
||||
.album
|
||||
.catalog_number
|
||||
.as_ref()
|
||||
.and_then(|s| normalize(s))
|
||||
{
|
||||
track.catalog_number = Some(cat.clone());
|
||||
}
|
||||
|
||||
if let Some(entry) = sidecar.tracks.iter().find(|t| {
|
||||
t.file_path == track.file_path
|
||||
&& match (t.cue_start_secs, track.cue_start_secs) {
|
||||
(Some(a), Some(b)) => (a - b).abs() < 0.001,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
if let Some(title) = entry.title.as_ref().and_then(|s| normalize(s)) {
|
||||
track.title = title.clone();
|
||||
}
|
||||
if let Some(disc) = entry.disc_number {
|
||||
track.disc_number = Some(disc);
|
||||
}
|
||||
if let Some(no) = entry.track_number {
|
||||
track.track_number = Some(no);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +314,21 @@ impl MusicBrainzClient {
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
) -> Result<ReleaseSearchResponse, String> {
|
||||
self.search_releases_extended(title, artist, None, 5).await
|
||||
}
|
||||
|
||||
/// Search releases with extended options
|
||||
/// - `title`: Album title to search
|
||||
/// - `artist`: Artist name to search
|
||||
/// - `catalog_number`: Optional catalog number for more precise matching
|
||||
/// - `limit`: Maximum results to return (1-25)
|
||||
pub async fn search_releases_extended(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
catalog_number: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<ReleaseSearchResponse, String> {
|
||||
if !self.is_enabled().await {
|
||||
return Err("MusicBrainz integration is disabled".to_string());
|
||||
@@ -322,18 +337,37 @@ impl MusicBrainzClient {
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base_url = self.base_url().await;
|
||||
let query = format!(
|
||||
"release:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
);
|
||||
|
||||
// Build query - if catalog number provided, prioritize it
|
||||
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
|
||||
format!(
|
||||
"catno:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(catno),
|
||||
Self::escape_query(artist)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"release:\"{}\" AND artist:\"{}\"",
|
||||
Self::escape_query(title),
|
||||
Self::escape_query(artist)
|
||||
)
|
||||
};
|
||||
|
||||
let limit = limit.min(25).max(1);
|
||||
let url = format!(
|
||||
"{}/release?query={}&fmt=json&limit=5",
|
||||
"{}/release?query={}&fmt=json&limit={}",
|
||||
base_url,
|
||||
urlencoding::encode(&query)
|
||||
urlencoding::encode(&query),
|
||||
limit
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz release search: {} - {}", artist, title);
|
||||
log::debug!(
|
||||
"MusicBrainz release search: {} - {} (catalog: {:?}, limit: {})",
|
||||
artist,
|
||||
title,
|
||||
catalog_number,
|
||||
limit
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
@@ -354,6 +388,44 @@ impl MusicBrainzClient {
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Get full release details including tracks
|
||||
/// Fetches media, recordings, artist credits, labels, and tags
|
||||
pub async fn get_release_with_tracks(&self, release_id: &str) -> Result<ReleaseFullResponse, String> {
|
||||
if !self.is_enabled().await {
|
||||
return Err("MusicBrainz integration is disabled".to_string());
|
||||
}
|
||||
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let base_url = self.base_url().await;
|
||||
// inc=recordings gets track info, artist-credits for artist info,
|
||||
// labels for label/catalog, tags for genres
|
||||
let url = format!(
|
||||
"{}/release/{}?inc=recordings+artist-credits+labels+tags&fmt=json",
|
||||
base_url, release_id
|
||||
);
|
||||
|
||||
log::debug!("MusicBrainz release lookup with tracks: {}", release_id);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("MusicBrainz request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(format!("MusicBrainz API error {}: {}", status, text));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<ReleaseFullResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse MusicBrainz response: {}", e))
|
||||
}
|
||||
|
||||
/// Escape special characters in Lucene queries
|
||||
fn escape_query(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
|
||||
@@ -22,9 +22,9 @@ pub mod smart_playlists;
|
||||
pub use cache::{CacheStats, MusicBrainzCache, MusicBrainzCacheState};
|
||||
pub use client::{MusicBrainzClient, MusicBrainzConfig};
|
||||
pub use models::{
|
||||
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, MusicianAppearances,
|
||||
MusicianConfidence, Period, RelatedArtist, ResolvedArtist, ResolvedMusician, ResolvedRelease,
|
||||
ResolvedTrack,
|
||||
AlbumAppearance, ArtistRelationships, ArtistType, MatchConfidence, Medium, MediumTrack,
|
||||
MusicianAppearances, MusicianConfidence, Period, RelatedArtist, ReleaseFullResponse,
|
||||
ReleaseSearchResponse, ResolvedArtist, ResolvedMusician, ResolvedRelease, ResolvedTrack, Tag,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -222,6 +222,19 @@ pub struct ReleaseResult {
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
/// Media info (contains track counts per disc)
|
||||
pub media: Option<Vec<ReleaseSearchMedium>>,
|
||||
/// Total track count across all media
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Simplified medium info for search results
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseSearchMedium {
|
||||
pub format: Option<String>,
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
}
|
||||
|
||||
/// Label information
|
||||
@@ -239,6 +252,78 @@ pub struct LabelRef {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ============ Full Release Response (with tracks) ============
|
||||
|
||||
/// Full release response from lookup endpoint
|
||||
/// Includes media, tracks, and tags when requested via inc parameter
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReleaseFullResponse {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub country: Option<String>,
|
||||
pub barcode: Option<String>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
#[serde(rename = "label-info")]
|
||||
pub label_info: Option<Vec<LabelInfo>>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<ReleaseGroupRef>,
|
||||
/// Media (CDs, vinyl sides, etc.) containing tracks
|
||||
pub media: Option<Vec<Medium>>,
|
||||
/// Community tags (genres)
|
||||
pub tags: Option<Vec<Tag>>,
|
||||
}
|
||||
|
||||
/// A medium (disc) containing tracks
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Medium {
|
||||
/// Position of this medium in the release (1-based)
|
||||
pub position: Option<u8>,
|
||||
/// Format (CD, Vinyl, Digital Media, etc.)
|
||||
pub format: Option<String>,
|
||||
/// Track count on this medium
|
||||
#[serde(rename = "track-count")]
|
||||
pub track_count: Option<u16>,
|
||||
/// Tracks on this medium
|
||||
pub tracks: Option<Vec<MediumTrack>>,
|
||||
}
|
||||
|
||||
/// A track on a medium
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MediumTrack {
|
||||
/// Track position on the medium (1-based)
|
||||
pub position: Option<u8>,
|
||||
/// Track number as string (may differ from position for vinyl etc.)
|
||||
pub number: Option<String>,
|
||||
/// Track title (may differ from recording title)
|
||||
pub title: Option<String>,
|
||||
/// Track length in milliseconds
|
||||
pub length: Option<i64>,
|
||||
/// The recording this track is linked to
|
||||
pub recording: Option<TrackRecording>,
|
||||
}
|
||||
|
||||
/// Recording reference within a track
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TrackRecording {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
pub length: Option<i64>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Option<Vec<ArtistCredit>>,
|
||||
}
|
||||
|
||||
/// Community tag (used for genres)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Tag {
|
||||
/// Tag name (e.g., "rock", "electronic")
|
||||
pub name: String,
|
||||
/// Number of times this tag was applied
|
||||
pub count: Option<i32>,
|
||||
}
|
||||
|
||||
// ============ Resolved Types (Output) ============
|
||||
|
||||
/// Resolved track with MusicBrainz data
|
||||
|
||||
+114
@@ -16,6 +16,16 @@
|
||||
--accent-hover: #5a9bf4;
|
||||
--accent-active: #3275e4;
|
||||
|
||||
--danger: #ef4444;
|
||||
--danger-bg: rgba(239, 68, 68, 0.1);
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--danger-hover: rgba(239, 68, 68, 0.2);
|
||||
|
||||
--warning: #fbbf24;
|
||||
--warning-bg: rgba(251, 191, 36, 0.1);
|
||||
--warning-border: rgba(251, 191, 36, 0.3);
|
||||
--warning-hover: rgba(251, 191, 36, 0.2);
|
||||
|
||||
--border-subtle: #2a2a2a;
|
||||
--border-strong: #3a3a3a;
|
||||
|
||||
@@ -75,6 +85,16 @@
|
||||
--border-subtle: #e0e0e0;
|
||||
--border-strong: #cccccc;
|
||||
|
||||
--danger: #dc2626;
|
||||
--danger-bg: rgba(220, 38, 38, 0.1);
|
||||
--danger-border: rgba(220, 38, 38, 0.3);
|
||||
--danger-hover: rgba(220, 38, 38, 0.15);
|
||||
|
||||
--warning: #d97706;
|
||||
--warning-bg: rgba(217, 119, 6, 0.1);
|
||||
--warning-border: rgba(217, 119, 6, 0.3);
|
||||
--warning-hover: rgba(217, 119, 6, 0.15);
|
||||
|
||||
/* Alpha overrides for light theme - use black-based */
|
||||
--alpha-4: rgba(0, 0, 0, 0.04);
|
||||
--alpha-5: rgba(0, 0, 0, 0.05);
|
||||
@@ -448,3 +468,97 @@ select {
|
||||
box-shadow: inset 0 0 0 1px var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* ============ Standard Buttons ============ */
|
||||
/* Reusable button styles for modals, forms, and actions */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-bg);
|
||||
border: 1px solid var(--danger-border);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Button sizes */
|
||||
.btn-sm {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 12px 28px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ============ Utility Classes ============ */
|
||||
|
||||
/* Icon inline with text */
|
||||
.icon-inline {
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Warning badge (complements .btn-danger) */
|
||||
.badge-warning {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
artwork: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre: string;
|
||||
genre?: string;
|
||||
releaseDate?: string;
|
||||
quality?: string;
|
||||
size?: 'standard' | 'large';
|
||||
@@ -29,6 +29,7 @@
|
||||
onDownload?: () => void;
|
||||
showFavorite?: boolean;
|
||||
favoriteEnabled?: boolean;
|
||||
showGenre?: boolean;
|
||||
isAlbumFullyDownloaded?: boolean;
|
||||
onOpenContainingFolder?: () => void;
|
||||
onReDownloadAlbum?: () => void;
|
||||
@@ -55,6 +56,7 @@
|
||||
onDownload,
|
||||
showFavorite,
|
||||
favoriteEnabled,
|
||||
showGenre = true,
|
||||
isAlbumFullyDownloaded = false,
|
||||
onOpenContainingFolder,
|
||||
onReDownloadAlbum,
|
||||
@@ -199,12 +201,14 @@
|
||||
<!-- Action Overlay -->
|
||||
{#if hasOverlay}
|
||||
<div class="action-overlay" class:menu-open={menuOpen}>
|
||||
<div class="overlay-info">
|
||||
<span class="overlay-genre">{genre}</span>
|
||||
{#if formattedDate}
|
||||
<span class="overlay-date">{formattedDate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showGenre && (genre || formattedDate)}
|
||||
<div class="overlay-info">
|
||||
{#if genre}<span class="overlay-genre">{genre}</span>{/if}
|
||||
{#if formattedDate}
|
||||
<span class="overlay-date">{formattedDate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="action-buttons">
|
||||
{#if showFavoriteButton}
|
||||
<button
|
||||
@@ -219,6 +223,9 @@
|
||||
>
|
||||
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer to keep Play button centered -->
|
||||
<div class="overlay-btn--spacer"></div>
|
||||
{/if}
|
||||
{#if onPlay}
|
||||
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
|
||||
@@ -431,6 +438,12 @@
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.overlay-btn--spacer {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.overlay-info {
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
|
||||
@@ -407,7 +407,7 @@
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #0a0a0a;
|
||||
background-color: var(--bg-primary);
|
||||
animation: fadeIn 200ms ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -463,7 +463,7 @@
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -532,7 +532,7 @@
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -603,7 +603,7 @@
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
background-color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
@@ -614,7 +614,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
background-color: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
@@ -645,12 +645,12 @@
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
background: var(--alpha-10);
|
||||
}
|
||||
|
||||
.control-btn.primary {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
@@ -702,7 +702,7 @@
|
||||
|
||||
.volume-fill {
|
||||
height: 100%;
|
||||
background-color: white;
|
||||
background-color: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
background-color: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
@@ -765,7 +765,7 @@
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
background: var(--alpha-10);
|
||||
}
|
||||
|
||||
|
||||
@@ -550,39 +550,6 @@
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-custom-gradient {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
|
||||
@@ -299,7 +299,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 110;
|
||||
background-color: #000;
|
||||
background-color: var(--bg-primary);
|
||||
cursor: default;
|
||||
animation: fadeIn 300ms ease-out;
|
||||
overflow: hidden;
|
||||
@@ -362,7 +362,7 @@
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -534,7 +534,7 @@
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -595,7 +595,7 @@
|
||||
}
|
||||
|
||||
.control-btn:not(:disabled):hover {
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
background: var(--alpha-10);
|
||||
}
|
||||
|
||||
@@ -604,7 +604,7 @@
|
||||
height: 52px;
|
||||
background: var(--alpha-15);
|
||||
backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
@@ -647,7 +647,7 @@
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: white;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
@@ -658,7 +658,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
background: var(--text-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
@@ -703,7 +703,7 @@
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--text-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
@@ -719,7 +719,7 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
|
||||
@@ -429,47 +429,8 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
/* Layout-specific: push danger button to the left */
|
||||
.modal-actions :global(.btn-danger) {
|
||||
margin-right: auto;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -689,42 +689,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled,
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -327,37 +327,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn {
|
||||
/* Layout-specific: equal width buttons */
|
||||
.modal-footer :global(.btn) {
|
||||
flex: 1;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -33,12 +33,21 @@
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function portal(node: HTMLElement) {
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-overlay" onclick={handleBackdropClick} role="dialog" aria-modal="true">
|
||||
<div class="modal-overlay" use:portal onclick={handleBackdropClick} role="dialog" aria-modal="true">
|
||||
<div class="modal" style="max-width: {maxWidth}">
|
||||
{#if title || showCloseButton}
|
||||
<div class="modal-header">
|
||||
@@ -77,8 +86,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
padding-left: 140px; /* Half of sidebar width (280px) for visual center */
|
||||
z-index: 10000;
|
||||
z-index: 200000;
|
||||
animation: fade-in 150ms ease;
|
||||
}
|
||||
|
||||
@@ -92,7 +102,7 @@
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
max-height: calc(100dvh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-up 200ms ease;
|
||||
|
||||
@@ -523,35 +523,6 @@
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, background 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
@@ -504,7 +504,7 @@
|
||||
{#if folders.length > 0}
|
||||
<div class="form-group">
|
||||
<label for="folder-select">
|
||||
<Folder size={14} style="display: inline; vertical-align: middle; margin-right: 4px;" />
|
||||
<Folder size={14} class="icon-inline" />
|
||||
Folder
|
||||
</label>
|
||||
<select
|
||||
@@ -777,42 +777,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled,
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hidden-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1596,41 +1596,6 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--alpha-10);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
|
||||
@@ -312,11 +312,10 @@
|
||||
artwork={getArtworkUrl(album.artwork_path)}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
genre={"Unknown genre"}
|
||||
quality={getQualityBadge(album)}
|
||||
size="large"
|
||||
showFavorite={true}
|
||||
favoriteEnabled={false}
|
||||
showFavorite={false}
|
||||
showGenre={false}
|
||||
onPlay={() => onAlbumPlay(album)}
|
||||
onPlayNext={() => onAlbumQueueNext(album)}
|
||||
onPlayLater={() => onAlbumQueueLater(album)}
|
||||
|
||||
@@ -654,7 +654,7 @@
|
||||
|
||||
.album-stats {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -694,7 +694,7 @@
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #666666;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
import {
|
||||
HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw,
|
||||
Settings, ArrowLeft, X, Play, AlertCircle, ImageDown, Upload, Search, LayoutGrid, List, Edit3,
|
||||
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown
|
||||
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown, Check
|
||||
} from 'lucide-svelte';
|
||||
import FolderSettingsModal from '../FolderSettingsModal.svelte';
|
||||
import LocalLibraryTagEditorModal from '../LocalLibraryTagEditorModal.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { downloadSettingsVersion } from '$lib/stores/downloadSettingsStore';
|
||||
import { showToast } from '$lib/stores/toastStore';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import VirtualizedAlbumList from '../VirtualizedAlbumList.svelte';
|
||||
import VirtualizedArtistGrid from '../VirtualizedArtistGrid.svelte';
|
||||
@@ -55,6 +57,7 @@
|
||||
disc_number?: number;
|
||||
year?: number;
|
||||
genre?: string;
|
||||
catalog_number?: string;
|
||||
duration_secs: number;
|
||||
format: string;
|
||||
bit_depth?: number;
|
||||
@@ -630,13 +633,16 @@
|
||||
|
||||
// Album edit modal state
|
||||
let showAlbumEditModal = $state(false);
|
||||
let editingAlbumTitle = $state('');
|
||||
let showTagEditorModal = $state(false);
|
||||
let refreshingAlbumMetadata = $state(false);
|
||||
let albumMetadataRefreshed = $state(false);
|
||||
let editingAlbumHidden = $state(false);
|
||||
let discogsImageOptions = $state<DiscogsImageOption[]>([]);
|
||||
let selectedDiscogsImage = $state<string | null>(null);
|
||||
let fetchingDiscogsImages = $state(false);
|
||||
let discogsFetchSuccessful = $state(false);
|
||||
let discogsImagePage = $state(0);
|
||||
const IMAGES_PER_PAGE = 4;
|
||||
const IMAGES_PER_PAGE = 3;
|
||||
|
||||
// Folder selection state (by folder ID)
|
||||
let selectedFolders = $state<Set<number>>(new Set());
|
||||
@@ -1468,13 +1474,55 @@
|
||||
|
||||
function openAlbumEditModal() {
|
||||
if (!selectedAlbum) return;
|
||||
editingAlbumTitle = selectedAlbum.title;
|
||||
editingAlbumHidden = false;
|
||||
albumMetadataRefreshed = false;
|
||||
discogsImageOptions = [];
|
||||
selectedDiscogsImage = null;
|
||||
discogsFetchSuccessful = false;
|
||||
showAlbumEditModal = true;
|
||||
}
|
||||
|
||||
function openTagEditorFromAlbumSettings() {
|
||||
if (!selectedAlbum) return;
|
||||
showAlbumEditModal = false;
|
||||
showTagEditorModal = true;
|
||||
}
|
||||
|
||||
async function handleTagEditorSaved() {
|
||||
if (!selectedAlbum) return;
|
||||
await loadLibraryData();
|
||||
await loadAlbumById(selectedAlbum.id);
|
||||
}
|
||||
|
||||
async function handleRefreshAlbumMetadataFromFiles() {
|
||||
if (!selectedAlbum || refreshingAlbumMetadata) return;
|
||||
|
||||
const confirmed = await ask(
|
||||
'This will re-read embedded metadata from the audio files and discard QBZ sidecar overrides for this album.',
|
||||
{
|
||||
title: 'Refresh metadata from files?',
|
||||
kind: 'warning',
|
||||
okLabel: 'Refresh',
|
||||
cancelLabel: 'Cancel'
|
||||
}
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
refreshingAlbumMetadata = true;
|
||||
albumMetadataRefreshed = false;
|
||||
await invoke('library_refresh_album_metadata_from_files', { albumGroupKey: selectedAlbum.id });
|
||||
showToast('Metadata refreshed from files', 'success');
|
||||
albumMetadataRefreshed = true;
|
||||
await handleTagEditorSaved();
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh metadata:', err);
|
||||
alert(`Failed to refresh metadata: ${err}`);
|
||||
} finally {
|
||||
refreshingAlbumMetadata = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDiscogsArtwork() {
|
||||
if (!selectedAlbum || fetchingDiscogsImages) return;
|
||||
|
||||
@@ -1483,6 +1531,7 @@
|
||||
discogsImageOptions = [];
|
||||
selectedDiscogsImage = null;
|
||||
discogsImagePage = 0;
|
||||
discogsFetchSuccessful = false;
|
||||
|
||||
const options = await invoke<DiscogsImageOption[]>('discogs_search_artwork', {
|
||||
artist: selectedAlbum.artist,
|
||||
@@ -1491,6 +1540,10 @@
|
||||
});
|
||||
|
||||
discogsImageOptions = options;
|
||||
discogsFetchSuccessful = options.length > 0;
|
||||
if (options.length === 0) {
|
||||
alert('No artwork found on Discogs for this album.');
|
||||
}
|
||||
console.log(`Found ${options.length} Discogs artwork options`);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch Discogs artwork:', err);
|
||||
@@ -2243,7 +2296,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="library-view" class:virtualized-active={(activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0)}>
|
||||
<div class="library-view" class:virtualized-active={!selectedAlbum && ((activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0))}>
|
||||
{#if selectedAlbum}
|
||||
{@const albumSections = buildAlbumSections(albumTracks)}
|
||||
{@const showDiscHeaders = albumSections.length > 1}
|
||||
@@ -2480,7 +2533,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="settings-actions">
|
||||
<button class="secondary-btn" onclick={toggleHiddenAlbumsView}>
|
||||
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
|
||||
<span>{showHiddenAlbums ? 'Show Active Albums' : 'View Hidden Albums'}</span>
|
||||
{#if hiddenAlbums.length > 0}
|
||||
<span class="count">({hiddenAlbums.length})</span>
|
||||
@@ -2488,7 +2541,7 @@
|
||||
</button>
|
||||
{#if hasDiscogsCredentials}
|
||||
<button
|
||||
class="secondary-btn"
|
||||
class="btn btn-secondary"
|
||||
onclick={handleFetchMissingArtwork}
|
||||
disabled={fetchingArtwork || isOffline}
|
||||
title={isOffline ? 'Artwork fetching unavailable offline' : ''}
|
||||
@@ -2592,7 +2645,7 @@
|
||||
<div class="albums-section">
|
||||
<div class="section-header">
|
||||
<h3>Hidden Albums ({hiddenAlbums.length})</h3>
|
||||
<button class="secondary-btn" onclick={toggleHiddenAlbumsView}>
|
||||
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
|
||||
<span>Back to Active Albums</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3085,140 +3138,170 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Album Edit Modal -->
|
||||
<!-- Album Settings Modal -->
|
||||
{#if showAlbumEditModal && selectedAlbum}
|
||||
<div class="modal-overlay" onclick={() => showAlbumEditModal = false}>
|
||||
<div class="modal" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>Edit Album</h2>
|
||||
<h2>Album Settings</h2>
|
||||
<button class="close-btn" onclick={() => showAlbumEditModal = false}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="album-title">Album Title</label>
|
||||
<input
|
||||
id="album-title"
|
||||
type="text"
|
||||
bind:value={editingAlbumTitle}
|
||||
placeholder="Album title"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<p class="form-hint">Album title editing coming soon</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Album Artwork</label>
|
||||
<div class="artwork-row">
|
||||
{#if selectedAlbum.artwork_path}
|
||||
<img
|
||||
src={getArtworkUrl(selectedAlbum.artwork_path)}
|
||||
alt="Current artwork"
|
||||
class="artwork-preview"
|
||||
/>
|
||||
{:else}
|
||||
<div class="artwork-preview artwork-placeholder-mini">
|
||||
<Disc3 size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="artwork-actions">
|
||||
<button
|
||||
class="discogs-btn"
|
||||
onclick={handleSetAlbumArtwork}
|
||||
disabled={updatingArtwork}
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>{updatingArtwork ? 'Updating...' : 'Change Cover'}</span>
|
||||
</button>
|
||||
<button
|
||||
class="discogs-btn"
|
||||
onclick={fetchDiscogsArtwork}
|
||||
disabled={fetchingDiscogsImages}
|
||||
>
|
||||
<img src="/discogs_icon.svg" alt="Discogs" class="discogs-icon" />
|
||||
<span>{fetchingDiscogsImages ? 'Fetching...' : 'Fetch from Discogs'}</span>
|
||||
</button>
|
||||
<div class="album-header-grid">
|
||||
<div class="album-text">
|
||||
<div class="album-title">{selectedAlbum.title}</div>
|
||||
<div class="album-artist">{selectedAlbum.artist}</div>
|
||||
</div>
|
||||
<div class="album-settings-actions">
|
||||
<button
|
||||
class="album-action-btn"
|
||||
onclick={openTagEditorFromAlbumSettings}
|
||||
title="Edit album metadata for LocalLibrary indexing and search"
|
||||
>
|
||||
<Edit3 size={18} />
|
||||
<span>Edit album info</span>
|
||||
</button>
|
||||
<button
|
||||
class="album-action-btn"
|
||||
onclick={handleRefreshAlbumMetadataFromFiles}
|
||||
disabled={refreshingAlbumMetadata}
|
||||
title="Re-read embedded file tags and discard QBZ sidecar overrides"
|
||||
>
|
||||
{#if albumMetadataRefreshed && !refreshingAlbumMetadata}
|
||||
<Check size={18} />
|
||||
{:else}
|
||||
<RefreshCw size={18} class={refreshingAlbumMetadata ? 'spinning' : ''} />
|
||||
{/if}
|
||||
<span>{refreshingAlbumMetadata ? 'Refreshing...' : 'Refresh metadata'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if discogsImageOptions.length > 0}
|
||||
<div class="form-group">
|
||||
<div class="discogs-header">
|
||||
<label>Select Artwork from Discogs</label>
|
||||
{#if discogsImageOptions.length > IMAGES_PER_PAGE}
|
||||
<div class="carousel-controls">
|
||||
<button
|
||||
class="carousel-btn"
|
||||
onclick={prevDiscogsPage}
|
||||
disabled={!hasPrevDiscogsPages}
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="page-indicator">
|
||||
{discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)}
|
||||
</span>
|
||||
<button
|
||||
class="carousel-btn"
|
||||
onclick={nextDiscogsPage}
|
||||
disabled={!hasMoreDiscogsPages}
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<div class="artwork-layout-header" class:discogs-active={discogsFetchSuccessful}>
|
||||
<label>Album Artwork</label>
|
||||
{#if discogsFetchSuccessful}
|
||||
<div class="discogs-layout-label">Select Artwork from Discogs</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="artwork-layout" class:discogs-active={discogsFetchSuccessful}>
|
||||
<div class="artwork-left">
|
||||
<div class="artwork-row">
|
||||
{#if selectedAlbum.artwork_path}
|
||||
<img
|
||||
src={getArtworkUrl(selectedAlbum.artwork_path)}
|
||||
alt="Current artwork"
|
||||
class="artwork-preview"
|
||||
/>
|
||||
{:else}
|
||||
<div class="artwork-preview artwork-placeholder-mini">
|
||||
<Disc3 size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="artwork-actions">
|
||||
<button class="discogs-btn" onclick={handleSetAlbumArtwork} disabled={updatingArtwork}>
|
||||
<Upload size={14} />
|
||||
<span>{updatingArtwork ? 'Updating...' : 'Upload cover'}</span>
|
||||
</button>
|
||||
<button class="discogs-btn" onclick={fetchDiscogsArtwork} disabled={fetchingDiscogsImages}>
|
||||
<img src="/discogs_icon.svg" alt="Discogs" class="discogs-icon" />
|
||||
<span>{fetchingDiscogsImages ? 'Fetching...' : 'Get from Discogs'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if discogsFetchSuccessful}
|
||||
<div class="discogs-panel">
|
||||
{#if discogsImageOptions.length > IMAGES_PER_PAGE}
|
||||
<div class="discogs-header">
|
||||
<div class="carousel-controls">
|
||||
<button
|
||||
class="carousel-btn"
|
||||
onclick={prevDiscogsPage}
|
||||
disabled={!hasPrevDiscogsPages}
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span class="page-indicator">
|
||||
{discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)}
|
||||
</span>
|
||||
<button
|
||||
class="carousel-btn"
|
||||
onclick={nextDiscogsPage}
|
||||
disabled={!hasMoreDiscogsPages}
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="discogs-options discogs-options-compact">
|
||||
{#each paginatedDiscogsImages as option, i}
|
||||
<button
|
||||
class="discogs-option"
|
||||
class:selected={selectedDiscogsImage === option.url}
|
||||
onclick={() => selectedDiscogsImage = option.url}
|
||||
title={option.release_title ? `${option.release_title}${option.release_year ? ` (${option.release_year})` : ''}` : ''}
|
||||
>
|
||||
<img src={option.url} alt={`Option ${discogsImagePage * IMAGES_PER_PAGE + i + 1}`} />
|
||||
<div class="option-info">
|
||||
{#if option.release_title}
|
||||
<div class="release-title">
|
||||
{option.release_title}{#if option.release_year} ({option.release_year}){/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="image-dims">{option.width}x{option.height}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="discogs-options">
|
||||
{#each paginatedDiscogsImages as option, i}
|
||||
<button
|
||||
class="discogs-option"
|
||||
class:selected={selectedDiscogsImage === option.url}
|
||||
onclick={() => selectedDiscogsImage = option.url}
|
||||
title={option.release_title ? `${option.release_title}${option.release_year ? ` (${option.release_year})` : ''}` : ''}
|
||||
>
|
||||
<img src={option.url} alt={`Option ${discogsImagePage * IMAGES_PER_PAGE + i + 1}`} />
|
||||
<div class="option-info">
|
||||
{#if option.release_title}
|
||||
<div class="release-title">{option.release_title}{#if option.release_year} ({option.release_year}){/if}</div>
|
||||
{/if}
|
||||
<div class="image-dims">{option.width}x{option.height}</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="form-hint">Click an image to select it, then click Save</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={editingAlbumHidden}
|
||||
/>
|
||||
<span>Hide this album from library</span>
|
||||
</label>
|
||||
<p class="form-hint">Hidden albums can be viewed from Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="secondary-btn" onclick={() => showAlbumEditModal = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="primary-btn" onclick={saveAlbumEdit}>
|
||||
Save
|
||||
</button>
|
||||
<div class="footer-left">
|
||||
<label class="toggle-label footer-toggle">
|
||||
<input type="checkbox" bind:checked={editingAlbumHidden} />
|
||||
<span>Hide this album from library</span>
|
||||
</label>
|
||||
<p class="form-hint footer-hint">Hidden albums can be viewed from Settings</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button class="btn btn-secondary" onclick={() => showAlbumEditModal = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={saveAlbumEdit}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- LocalLibrary Tag Editor Modal -->
|
||||
<LocalLibraryTagEditorModal
|
||||
isOpen={showTagEditorModal}
|
||||
album={selectedAlbum}
|
||||
tracks={albumTracks}
|
||||
onClose={() => (showTagEditorModal = false)}
|
||||
onSaved={handleTagEditorSaved}
|
||||
/>
|
||||
|
||||
<!-- Folder Settings Modal -->
|
||||
<FolderSettingsModal
|
||||
isOpen={showFolderSettingsModal}
|
||||
@@ -3624,30 +3707,6 @@
|
||||
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);
|
||||
@@ -4794,7 +4853,8 @@
|
||||
}
|
||||
|
||||
.album-info .artist {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
@@ -4805,6 +4865,9 @@
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.album-info .artist-link:hover {
|
||||
@@ -4894,8 +4957,10 @@
|
||||
}
|
||||
|
||||
.modal {
|
||||
--album-settings-cover-size: 94px;
|
||||
--discogs-panel-height: calc(var(--album-settings-cover-size) + 56px);
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-width: 704px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
@@ -4941,10 +5006,65 @@
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
padding: 18px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.album-header-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
margin-top: 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.album-settings-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.album-action-btn {
|
||||
width: 190px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.album-action-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.album-action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -4957,6 +5077,29 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artwork-layout-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.artwork-layout-header.discogs-active {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.artwork-layout-header label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.discogs-layout-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
@@ -5009,9 +5152,49 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.artwork-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.artwork-layout.discogs-active {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.discogs-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
height: var(--discogs-panel-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-left: -8px;
|
||||
width: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.discogs-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.discogs-hint {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.artwork-preview {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
width: var(--album-settings-cover-size);
|
||||
height: var(--album-settings-cover-size);
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-tertiary);
|
||||
@@ -5068,6 +5251,20 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.discogs-options-compact {
|
||||
grid-template-columns: repeat(3, var(--album-settings-cover-size));
|
||||
grid-template-rows: var(--album-settings-cover-size);
|
||||
justify-content: start;
|
||||
gap: 10px;
|
||||
margin-top: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.discogs-panel .discogs-option:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.discogs-option {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
@@ -5127,7 +5324,7 @@
|
||||
.discogs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -5171,40 +5368,26 @@
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.secondary-btn,
|
||||
.primary-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
.footer-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--text-muted);
|
||||
color: var(--text-secondary);
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
border-color: var(--text-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
.footer-hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -411,13 +411,13 @@
|
||||
.error-box {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
background-color: var(--danger-bg);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-box p {
|
||||
color: #ff6b6b;
|
||||
color: var(--danger);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -430,8 +430,8 @@
|
||||
.timeout-box {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background-color: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
background-color: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -439,7 +439,7 @@
|
||||
.timeout-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fbbf24;
|
||||
color: var(--warning);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -554,9 +554,9 @@
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
background-color: var(--danger-bg);
|
||||
border-radius: 8px;
|
||||
color: #ff6b6b;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1458,12 +1458,12 @@
|
||||
.artwork-btn.artwork-clear {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 0, 0, 0.3);
|
||||
border-color: rgba(255, 100, 100, 0.5);
|
||||
background: var(--danger-bg);
|
||||
border-color: var(--danger-border);
|
||||
}
|
||||
|
||||
.artwork-btn.artwork-clear:hover {
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
|
||||
@@ -1534,56 +1534,6 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-secondary,
|
||||
.btn-primary,
|
||||
.btn-danger {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: background-color 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--alpha-10);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-secondary);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte';
|
||||
import Toggle from '../Toggle.svelte';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
@@ -1552,6 +1553,23 @@
|
||||
console.warn('Failed to set zoom:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatpak copyable command state
|
||||
let copiedCommands = $state<Record<string, boolean>>({});
|
||||
|
||||
async function copyCommand(key: string, command: string) {
|
||||
try {
|
||||
await copyToClipboard(command);
|
||||
copiedCommands[key] = true;
|
||||
setTimeout(() => { copiedCommands[key] = false; }, 1200);
|
||||
} catch {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
copiedCommands[key] = true;
|
||||
setTimeout(() => { copiedCommands[key] = false; }, 1200);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings-view" bind:this={settingsViewEl}>
|
||||
@@ -1990,9 +2008,26 @@
|
||||
<div class="flatpak-guide">
|
||||
<h4>Grant Filesystem Access</h4>
|
||||
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
|
||||
<CopyableCommand command="flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz" />
|
||||
<div class="copyable-command">
|
||||
<pre class="code-block">flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz</pre>
|
||||
<button class="copy-btn" onclick={() => copyCommand('fs-basic', 'flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz')}>
|
||||
{copiedCommands['fs-basic'] ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<h4>Examples</h4>
|
||||
<CopyableCommand command="# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz" />
|
||||
<div class="copyable-command">
|
||||
<pre class="code-block"># CIFS / Samba mount
|
||||
flatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz
|
||||
|
||||
# SSHFS mount
|
||||
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz
|
||||
|
||||
# Custom folder (edit as needed)
|
||||
flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
|
||||
<button class="copy-btn" onclick={() => copyCommand('fs-examples', `# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz`)}>
|
||||
{copiedCommands['fs-examples'] ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="flatpak-note">
|
||||
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
|
||||
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
|
||||
@@ -2003,7 +2038,12 @@
|
||||
<p>
|
||||
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
|
||||
</p>
|
||||
<CopyableCommand command="flatpak override --user --share=network com.blitzfc.qbz" />
|
||||
<div class="copyable-command">
|
||||
<pre class="code-block">flatpak override --user --share=network com.blitzfc.qbz</pre>
|
||||
<button class="copy-btn" onclick={() => copyCommand('network', 'flatpak override --user --share=network com.blitzfc.qbz')}>
|
||||
{copiedCommands['network'] ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="flatpak-note">
|
||||
<strong>Note:</strong> Without this, device discovery will not work.<br />
|
||||
You only need to do this once.
|
||||
@@ -2012,37 +2052,6 @@
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<script lang="ts">
|
||||
// ...existing code...
|
||||
import { onMount } from 'svelte';
|
||||
// ...existing code...
|
||||
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
|
||||
|
||||
// Componente para comandos copiables
|
||||
export let CopyableCommand: any = (props: { command: string }) => {
|
||||
let copied = false;
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await copyToClipboard(props.command);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1200);
|
||||
} catch {
|
||||
// fallback
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.command);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1200);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div class="copyable-command">
|
||||
<pre class="code-block">{props.command}</pre>
|
||||
<button class="copy-btn" on:click={handleCopy}>{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.copyable-command {
|
||||
|
||||
@@ -3075,7 +3075,6 @@
|
||||
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
color: white;
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
@@ -441,8 +441,8 @@
|
||||
}
|
||||
|
||||
.restore-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
color: #a5b4fc;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
@@ -461,7 +461,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #fff;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #6366f1;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 2px;
|
||||
transition: width 100ms linear;
|
||||
}
|
||||
@@ -568,7 +568,7 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
background: var(--text-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
@@ -584,7 +584,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
@@ -612,7 +612,7 @@
|
||||
|
||||
.ctrl-btn:hover {
|
||||
background: var(--alpha-8);
|
||||
color: #fff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn:active {
|
||||
@@ -620,7 +620,7 @@
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
color: #818cf8;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.play {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M12.965,5.462c0,-0 -2.584,0.004 -4.979,0.008c-3.034,0.006 -5.49,2.467 -5.49,5.5l0,13.03c0,1.459 0.579,2.858 1.611,3.889c1.031,1.032 2.43,1.611 3.889,1.611l13.003,0c3.038,-0 5.5,-2.462 5.5,-5.5c0,-2.405 0,-5.004 0,-5.004c0,-0.828 -0.672,-1.5 -1.5,-1.5c-0.827,-0 -1.5,0.672 -1.5,1.5l0,5.004c0,1.381 -1.119,2.5 -2.5,2.5l-13.003,0c-0.663,-0 -1.299,-0.263 -1.768,-0.732c-0.469,-0.469 -0.732,-1.105 -0.732,-1.768l0,-13.03c0,-1.379 1.117,-2.497 2.496,-2.5c2.394,-0.004 4.979,-0.008 4.979,-0.008c0.828,-0.002 1.498,-0.675 1.497,-1.503c-0.001,-0.828 -0.675,-1.499 -1.503,-1.497Z"/><path d="M20.046,6.411l-6.845,6.846c-0.137,0.137 -0.232,0.311 -0.271,0.501l-1.081,5.152c-0.069,0.329 0.032,0.671 0.268,0.909c0.237,0.239 0.577,0.343 0.907,0.277l5.194,-1.038c0.193,-0.039 0.371,-0.134 0.511,-0.274l6.845,-6.845l-5.528,-5.528Zm1.415,-1.414l5.527,5.528l1.112,-1.111c1.526,-1.527 1.526,-4.001 -0,-5.527c-0.001,-0 -0.001,-0.001 -0.001,-0.001c-1.527,-1.526 -4.001,-1.526 -5.527,-0l-1.111,1.111Z"/><g id="Icon"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Reference in new issue
Block a user