Merge branch 'feature/local-library-tag-editor' into pre-release

This commit is contained in:
Victor RH committed 2026-01-26 21:06:21 -06:00
commit 49bb0cf349
37 files changed
+3924 -580

No files matched your search

+2
View File
@@ -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::*;
+104
View File
@@ -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)),
}
}
+140 -1
View File
@@ -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);
+16
View File
@@ -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");
+479 -16
View File
@@ -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>,
+162
View File
@@ -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,
+7 -1
View File
@@ -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
}
}
+153
View File
@@ -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())
}
}
+80 -8
View File
@@ -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('\\', "\\\\")
+3 -3
View File
@@ -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;
+85
View File
@@ -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
View File
@@ -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;
}
+20 -7
View File
@@ -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%;
+10 -10
View File
@@ -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;
+9 -9
View File
@@ -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);
+2 -41
View File
@@ -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
+2 -31
View File
@@ -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>
+13 -3
View File
@@ -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;
+1 -37
View File
@@ -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;
-35
View File
@@ -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;
}
+354 -171
View File
@@ -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>
+7 -7
View File
@@ -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;
+43 -34
View File
@@ -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 {
-1
View File
@@ -3075,7 +3075,6 @@
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
overflow: hidden;
position: relative;
z-index: 1;
}
.main-content {
+9 -9
View File
@@ -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 {
+2
View File
@@ -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