From 25fffb7fd0f508205d3e452bbd896bd325c3ed93 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:05:16 -0600 Subject: [PATCH] refactor(v2): inline library scan and metadata _impl delegates Inlines bodies of library_scan_impl, library_stop_scan_impl, library_scan_folder_impl, library_clear_impl, library_update_album_metadata_impl, library_write_album_metadata_to_files_impl, and library_refresh_album_metadata_from_files_impl from library/commands.rs directly into the v2 wrappers in commands_v2/legacy_compat.rs. Moves their helpers (process_cue_file, apply_sidecar_override_if_present, compute_track_artist_match, normalize_library_path) alongside the inlined bodies. Deletes the corresponding legacy #[tauri::command] _impl definitions and the dead public command siblings (library_scan, library_stop_scan, library_scan_folder, library_clear, library_update_album_metadata, library_write_album_metadata_to_files, library_refresh_album_metadata_from_files) that were only ever called from the deleted _impl variants. Updates library/mod.rs to drop the corresponding re-exports. Step 8 of legacy cleanup, group A. cc --- src-tauri/src/commands_v2/legacy_compat.rs | 1022 +++++++++++++++++- src-tauri/src/library/commands.rs | 1089 +------------------- src-tauri/src/library/mod.rs | 15 +- 3 files changed, 1023 insertions(+), 1103 deletions(-) diff --git a/src-tauri/src/commands_v2/legacy_compat.rs b/src-tauri/src/commands_v2/legacy_compat.rs index 9327816c6..6b38ce982 100644 --- a/src-tauri/src/commands_v2/legacy_compat.rs +++ b/src-tauri/src/commands_v2/legacy_compat.rs @@ -1541,16 +1541,380 @@ pub async fn v2_start_legacy_migration( Ok(()) } +// === Library scan / metadata helpers (inlined from legacy library/commands.rs) === + +fn library_normalize_path(path: &std::path::Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +async fn library_process_cue_file( + cue_path: &std::path::Path, + state: &State<'_, crate::library::LibraryState>, +) -> Result<(), String> { + let mut cue = + crate::library::CueParser::parse(cue_path).map_err(|e| e.to_string())?; + + // Get audio file properties + let audio_path = library_normalize_path(std::path::Path::new(&cue.audio_file)); + if !audio_path.exists() { + return Err(format!("Audio file not found: {}", cue.audio_file)); + } + cue.audio_file = audio_path.to_string_lossy().to_string(); + + let properties = crate::library::MetadataExtractor::extract_properties(audio_path.as_path()) + .map_err(|e| e.to_string())?; + let format = crate::library::MetadataExtractor::detect_format(audio_path.as_path()); + + // Convert CUE to tracks + let mut tracks = crate::library::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 = std::path::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 = crate::library::get_artwork_cache_dir(); + let mut artwork_path = + crate::library::MetadataExtractor::extract_artwork(audio_path.as_path(), &artwork_cache); + if artwork_path.is_none() { + if let Some(folder_art) = crate::library::MetadataExtractor::find_folder_artwork( + audio_path.as_path(), + cue.title.as_deref(), + ) { + artwork_path = crate::library::MetadataExtractor::cache_artwork_file( + std::path::Path::new(&folder_art), + &artwork_cache, + ); + } + } + if let Some(path) = artwork_path.as_ref() { + for track in tracks.iter_mut() { + track.artwork_path = Some(path.clone()); + } + } + + // Insert tracks + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + let group_key = tracks + .first() + .map(|track| track.album_group_key.clone()) + .unwrap_or_default(); + + for track in tracks { + db.insert_track(&track).map_err(|e| e.to_string())?; + } + + if let (Some(path), false) = (artwork_path.as_ref(), group_key.is_empty()) { + let _ = db.update_album_group_artwork(&group_key, path); + } + + Ok(()) +} + +fn library_apply_sidecar_override_if_present( + track: &mut LocalTrack, + cache: &mut std::collections::HashMap>, +) { + 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 = std::path::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 library_compute_track_artist_match(tracks: &[LocalTrack]) -> Option { + let mut artists: HashSet = 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 v2_library_scan(state: State<'_, crate::library::LibraryState>) -> Result<(), String> { - crate::library::library_scan_impl(state).await +pub async fn v2_library_scan( + state: State<'_, crate::library::LibraryState>, +) -> Result<(), String> { + use std::sync::atomic::Ordering; + use crate::library::{ScanError, ScanProgress, ScanStatus, LibraryScanner, MetadataExtractor, CueParser, get_artwork_cache_dir}; + + log::info!("Command: library_scan"); + + // Get folders to scan + let folders = { + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + db.get_folders().map_err(|e| e.to_string())? + }; + + if folders.is_empty() { + return Err("No library folders configured".to_string()); + } + + // Reset cancel flag and progress + state.scan_cancel.store(false, Ordering::Relaxed); + { + let mut progress = state.scan_progress.lock().await; + *progress = ScanProgress { + status: ScanStatus::Scanning, + total_files: 0, + processed_files: 0, + current_file: None, + errors: Vec::new(), + }; + } + + let scanner = LibraryScanner::new(); + let mut all_errors: Vec = Vec::new(); + let mut sidecar_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + + for folder in &folders { + log::info!("Scanning folder: {}", folder); + + // Scan for files + let scan_result = match scanner.scan_directory(std::path::Path::new(folder)) { + Ok(result) => result, + Err(e) => { + all_errors.push(ScanError { + file_path: folder.clone(), + error: e.to_string(), + }); + continue; + } + }; + + let total_files = scan_result.audio_files.len() + scan_result.cue_files.len(); + + // Update total count + { + let mut progress = state.scan_progress.lock().await; + progress.total_files += total_files as u32; + } + + // Process CUE files first (they create multiple tracks from one file) + for cue_path in &scan_result.cue_files { + // Check for cancellation + if state.scan_cancel.load(Ordering::Relaxed) { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Cancelled; + progress.current_file = None; + log::info!("Library scan cancelled by user"); + return Ok(()); + } + + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some(cue_path.to_string_lossy().to_string()); + } + + match library_process_cue_file(cue_path, &state).await { + Ok(_) => {} + Err(e) => { + all_errors.push(ScanError { + file_path: cue_path.to_string_lossy().to_string(), + error: e, + }); + } + } + + { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + } + } + + // Process regular audio files (skip if covered by CUE) + let cue_audio_files: HashSet = scan_result + .cue_files + .iter() + .filter_map(|p| { + CueParser::parse(p).ok().map(|cue| { + library_normalize_path(std::path::Path::new(&cue.audio_file)) + .to_string_lossy() + .to_string() + }) + }) + .collect(); + + for audio_path in &scan_result.audio_files { + // Check for cancellation + if state.scan_cancel.load(Ordering::Relaxed) { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Cancelled; + progress.current_file = None; + log::info!("Library scan cancelled by user"); + return Ok(()); + } + + // Skip if this file is referenced by a CUE sheet + let canonical_path = library_normalize_path(audio_path); + let path_str = canonical_path.to_string_lossy().to_string(); + if cue_audio_files.contains(&path_str) { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + continue; + } + + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some(path_str.clone()); + } + + match MetadataExtractor::extract(&canonical_path) { + Ok(mut track) => { + library_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 = + MetadataExtractor::extract_artwork(&canonical_path, &artwork_cache); + if artwork_path.is_none() { + let album_hint = if !track.album_group_title.is_empty() { + Some(track.album_group_title.as_str()) + } else { + Some(track.album.as_str()) + }; + if let Some(folder_art) = + MetadataExtractor::find_folder_artwork(&canonical_path, album_hint) + { + artwork_path = MetadataExtractor::cache_artwork_file( + std::path::Path::new(&folder_art), + &artwork_cache, + ); + } + } + track.artwork_path = artwork_path; + + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + if let Err(e) = db.insert_track(&track) { + all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }); + } else if let (Some(artwork_path), false) = ( + track.artwork_path.as_ref(), + track.album_group_key.is_empty(), + ) { + let _ = db.update_album_group_artwork(&track.album_group_key, artwork_path); + } + } + Err(e) => { + all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }); + } + } + + { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + } + } + } + + // Clean up tracks whose files no longer exist on disk + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some("Cleaning up missing files...".to_string()); + } + { + let guard__ = state.db.lock().await; + if let Some(db) = guard__.as_ref() { + if let Ok(tracks) = db.get_all_track_paths() { + let missing_ids: Vec = tracks + .iter() + .filter(|(_, path)| !std::path::Path::new(path).exists()) + .map(|(id, _)| *id) + .collect(); + if !missing_ids.is_empty() { + log::info!("Removing {} tracks with missing files", missing_ids.len()); + for chunk in missing_ids.chunks(500) { + if let Err(e) = db.delete_tracks_by_ids(chunk) { + log::error!("Failed to delete missing tracks: {}", e); + } + } + } + } + } + } + + // Mark complete + { + let mut progress = state.scan_progress.lock().await; + progress.status = if all_errors.is_empty() { + ScanStatus::Complete + } else { + ScanStatus::Complete // Still complete, but with errors + }; + progress.current_file = None; + progress.errors = all_errors; + } + + log::info!("Library scan complete"); + Ok(()) } #[tauri::command] pub async fn v2_library_stop_scan( state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_stop_scan_impl(state).await + use std::sync::atomic::Ordering; + log::info!("Command: library_stop_scan"); + state.scan_cancel.store(true, Ordering::Relaxed); + Ok(()) } #[tauri::command] @@ -1558,14 +1922,287 @@ pub async fn v2_library_scan_folder( folder_id: i64, state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_scan_folder_impl(folder_id, state).await + use std::sync::atomic::Ordering; + use crate::library::{ScanError, ScanProgress, ScanStatus, LibraryScanner, MetadataExtractor, CueParser, get_artwork_cache_dir}; + + log::info!("Command: library_scan_folder {}", folder_id); + + // Get folder info + let folder = { + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + db.get_folder_by_id(folder_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Folder with ID {} not found", folder_id))? + }; + + if !folder.enabled { + return Err("Cannot scan disabled folder".to_string()); + } + + // Refresh network detection if not user overridden + if !folder.user_override_network { + let path = std::path::Path::new(&folder.path); + let network_info = crate::network::is_network_path(path); + + if network_info.is_network != folder.is_network { + log::info!( + "Updating network status for folder {} during scan: {} -> {}", + folder.path, + folder.is_network, + network_info.is_network + ); + + let fs_type = network_info.mount_info.as_ref().and_then(|mi| { + if let crate::network::MountKind::Network(nfs) = &mi.kind { + Some(format!("{:?}", nfs).to_lowercase()) + } else { + None + } + }); + + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + let _ = db.update_folder_settings( + folder.id, + folder.alias.as_deref(), + folder.enabled, + network_info.is_network, + fs_type.as_deref(), + false, + ); + } + } + + // Reset cancel flag and progress + state.scan_cancel.store(false, Ordering::Relaxed); + { + let mut progress = state.scan_progress.lock().await; + *progress = ScanProgress { + status: ScanStatus::Scanning, + total_files: 0, + processed_files: 0, + current_file: None, + errors: Vec::new(), + }; + } + + let scanner = LibraryScanner::new(); + let mut all_errors: Vec = Vec::new(); + let mut sidecar_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + + log::info!("Scanning single folder: {}", folder.path); + + // Scan for files + let scan_result = match scanner.scan_directory(std::path::Path::new(&folder.path)) { + Ok(result) => result, + Err(e) => { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Complete; + progress.errors = vec![ScanError { + file_path: folder.path.clone(), + error: e.to_string(), + }]; + return Err(e.to_string()); + } + }; + + let total_files = scan_result.audio_files.len() + scan_result.cue_files.len(); + + // Update total count + { + let mut progress = state.scan_progress.lock().await; + progress.total_files = total_files as u32; + } + + // Process CUE files first + for cue_path in &scan_result.cue_files { + if state.scan_cancel.load(Ordering::Relaxed) { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Cancelled; + progress.current_file = None; + log::info!("Library scan cancelled by user"); + return Ok(()); + } + + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some(cue_path.to_string_lossy().to_string()); + } + + match library_process_cue_file(cue_path, &state).await { + Ok(_) => {} + Err(e) => { + all_errors.push(ScanError { + file_path: cue_path.to_string_lossy().to_string(), + error: e, + }); + } + } + + { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + } + } + + // Process regular audio files + let cue_audio_files: HashSet = scan_result + .cue_files + .iter() + .filter_map(|p| { + CueParser::parse(p).ok().map(|cue| { + library_normalize_path(std::path::Path::new(&cue.audio_file)) + .to_string_lossy() + .to_string() + }) + }) + .collect(); + + for audio_path in &scan_result.audio_files { + if state.scan_cancel.load(Ordering::Relaxed) { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Cancelled; + progress.current_file = None; + log::info!("Library scan cancelled by user"); + return Ok(()); + } + + let canonical_path = library_normalize_path(audio_path); + let path_str = canonical_path.to_string_lossy().to_string(); + if cue_audio_files.contains(&path_str) { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + continue; + } + + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some(path_str.clone()); + } + + match MetadataExtractor::extract(&canonical_path) { + Ok(mut track) => { + library_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() { + if let Some(folder_art) = MetadataExtractor::find_folder_artwork( + canonical_path.as_path(), + Some(&track.album), + ) { + artwork_path = MetadataExtractor::cache_artwork_file( + std::path::Path::new(&folder_art), + &artwork_cache, + ); + } + } + track.artwork_path = artwork_path.clone(); + + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + let group_key = track.album_group_key.clone(); + if let Err(e) = db.insert_track(&track) { + all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }); + } else if let Some(path) = artwork_path.as_ref() { + if !group_key.is_empty() { + let _ = db.update_album_group_artwork(&group_key, path); + } + } + } + Err(e) => { + all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }); + } + } + + { + let mut progress = state.scan_progress.lock().await; + progress.processed_files += 1; + } + } + + // Clean up tracks in this folder whose files no longer exist on disk + { + let mut progress = state.scan_progress.lock().await; + progress.current_file = Some("Cleaning up missing files...".to_string()); + } + { + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + if let Ok(tracks) = db.get_all_track_paths() { + let folder_prefix = if folder.path.ends_with('/') { + folder.path.clone() + } else { + format!("{}/", folder.path) + }; + let missing_ids: Vec = tracks + .iter() + .filter(|(_, path)| { + path.starts_with(&folder_prefix) && !std::path::Path::new(path).exists() + }) + .map(|(id, _)| *id) + .collect(); + if !missing_ids.is_empty() { + log::info!( + "Removing {} tracks with missing files from folder {}", + missing_ids.len(), + folder.path + ); + for chunk in missing_ids.chunks(500) { + if let Err(e) = db.delete_tracks_by_ids(chunk) { + log::error!("Failed to delete missing tracks: {}", e); + } + } + } + } + + // Update folder scan time + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let _ = db.update_folder_scan_time(&folder.path, now); + } + + // Update final status + { + let mut progress = state.scan_progress.lock().await; + progress.status = ScanStatus::Complete; + progress.current_file = None; + progress.errors = all_errors; + } + + log::info!("Single folder scan complete"); + Ok(()) } #[tauri::command] pub async fn v2_library_clear( state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_clear_impl(state).await + log::info!("Command: library_clear"); + + let guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + db.clear_all_tracks().map_err(|e| e.to_string()) } #[tauri::command] @@ -1573,7 +2210,104 @@ pub async fn v2_library_update_album_metadata( request: crate::library::LibraryAlbumMetadataUpdateRequest, state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_update_album_metadata_impl(request, state).await + 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::>(); + + 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 guard__ = state.db.lock().await; + let db = guard__ + .as_mut() + .ok_or("No active session - please log in")?; + let existing_tracks = db + .get_album_tracks(&request.album_group_key) + .map_err(|e| e.to_string())?; + + let track_artist_match = library_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::>(); + + 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] @@ -1582,7 +2316,193 @@ pub async fn v2_library_write_album_metadata_to_files( request: crate::library::LibraryAlbumMetadataUpdateRequest, state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_write_album_metadata_to_files_impl(app, request, state).await + use std::collections::HashMap; + use crate::library::LibraryAlbumTrackMetadataUpdate; + + 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 guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + 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(guard__); + + 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::prelude::*; + use lofty::tag::{ItemKey, Tag}; + use lofty::config::WriteOptions; + + // Ensure we only write each file once. + let mut by_file: HashMap = 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 = std::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_date(lofty::tag::items::Timestamp { + year: year as u16, + ..Default::default() + }); + } else { + tag.remove_date(); + } + + 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, WriteOptions::default()) + .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 guard__ = state.db.lock().await; + let db = guard__ + .as_mut() + .ok_or("No active session - please log in")?; + let existing_tracks = db + .get_album_tracks(&request.album_group_key) + .map_err(|e| e.to_string())?; + let track_artist_match = library_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::>(); + + 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] @@ -1590,7 +2510,93 @@ pub async fn v2_library_refresh_album_metadata_from_files( album_group_key: String, state: State<'_, crate::library::LibraryState>, ) -> Result<(), String> { - crate::library::library_refresh_album_metadata_from_files_impl(album_group_key, state).await + 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 guard__ = state.db.lock().await; + let db = guard__ + .as_ref() + .ok_or("No active session - please log in")?; + 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(guard__); + + 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, String> { + let _ = crate::library::delete_album_sidecar(&album_dir); + + let mut updates: Vec = Vec::new(); + for track in existing_tracks { + let path = std::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 = crate::library::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 guard__ = state.db.lock().await; + let db = guard__ + .as_mut() + .ok_or("No active session - please log in")?; + db.update_tracks_metadata_by_id(&updates) + .map_err(|e| e.to_string())?; + + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index d4005b7c9..4f0c9b545 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -1,16 +1,13 @@ //! Tauri commands for local library -use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; -use tauri::{Emitter, State}; +use tauri::State; use crate::discogs::DiscogsClient; use crate::library::{ - cue_to_tracks, get_artwork_cache_dir, thumbnails, ArtistImageInfo, CueParser, LibraryFolder, - LibraryScanner, LibraryStats, LocalAlbum, LocalArtist, LocalTrack, MetadataExtractor, - ScanError, ScanProgress, ScanStatus, + get_artwork_cache_dir, thumbnails, ArtistImageInfo, LibraryFolder, LibraryStats, LocalAlbum, + LocalArtist, LocalTrack, MetadataExtractor, ScanProgress, }; use crate::network::{is_network_path, MountKind, NetworkFs}; @@ -22,10 +19,6 @@ pub use super::state::{ LibraryAlbumTrackMetadataUpdate, LibraryState, }; -fn normalize_library_path(path: &Path) -> PathBuf { - fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - // === Folder Management === #[tauri::command] @@ -458,353 +451,6 @@ pub async fn library_check_folder_accessible(path: String) -> Result) -> Result<(), String> { - log::info!("Command: library_scan"); - - // Get folders to scan - let folders = { - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.get_folders().map_err(|e| e.to_string())? - }; - - if folders.is_empty() { - return Err("No library folders configured".to_string()); - } - - // Reset cancel flag and progress - state.scan_cancel.store(false, Ordering::Relaxed); - { - let mut progress = state.scan_progress.lock().await; - *progress = ScanProgress { - status: ScanStatus::Scanning, - total_files: 0, - processed_files: 0, - current_file: None, - errors: Vec::new(), - }; - } - - let scanner = LibraryScanner::new(); - let mut all_errors: Vec = Vec::new(); - let mut sidecar_cache: HashMap> = - HashMap::new(); - - for folder in &folders { - log::info!("Scanning folder: {}", folder); - - // Scan for files - let scan_result = match scanner.scan_directory(Path::new(folder)) { - Ok(result) => result, - Err(e) => { - all_errors.push(ScanError { - file_path: folder.clone(), - error: e.to_string(), - }); - continue; - } - }; - - let total_files = scan_result.audio_files.len() + scan_result.cue_files.len(); - - // Update total count - { - let mut progress = state.scan_progress.lock().await; - progress.total_files += total_files as u32; - } - - // Process CUE files first (they create multiple tracks from one file) - for cue_path in &scan_result.cue_files { - // Check for cancellation - if state.scan_cancel.load(Ordering::Relaxed) { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Cancelled; - progress.current_file = None; - log::info!("Library scan cancelled by user"); - return Ok(()); - } - - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some(cue_path.to_string_lossy().to_string()); - } - - match process_cue_file(cue_path, &state).await { - Ok(_) => {} - Err(e) => { - all_errors.push(ScanError { - file_path: cue_path.to_string_lossy().to_string(), - error: e, - }); - } - } - - { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - } - } - - // Process regular audio files (skip if covered by CUE) - let cue_audio_files: std::collections::HashSet = scan_result - .cue_files - .iter() - .filter_map(|p| { - CueParser::parse(p).ok().map(|cue| { - normalize_library_path(Path::new(&cue.audio_file)) - .to_string_lossy() - .to_string() - }) - }) - .collect(); - - for audio_path in &scan_result.audio_files { - // Check for cancellation - if state.scan_cancel.load(Ordering::Relaxed) { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Cancelled; - progress.current_file = None; - log::info!("Library scan cancelled by user"); - return Ok(()); - } - - // Skip if this file is referenced by a CUE sheet - let canonical_path = normalize_library_path(audio_path); - let path_str = canonical_path.to_string_lossy().to_string(); - if cue_audio_files.contains(&path_str) { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - continue; - } - - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some(path_str.clone()); - } - - 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 = - MetadataExtractor::extract_artwork(&canonical_path, &artwork_cache); - if artwork_path.is_none() { - let album_hint = if !track.album_group_title.is_empty() { - Some(track.album_group_title.as_str()) - } else { - Some(track.album.as_str()) - }; - if let Some(folder_art) = - MetadataExtractor::find_folder_artwork(&canonical_path, album_hint) - { - artwork_path = MetadataExtractor::cache_artwork_file( - Path::new(&folder_art), - &artwork_cache, - ); - } - } - track.artwork_path = artwork_path; - - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - if let Err(e) = db.insert_track(&track) { - all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }); - } else if let (Some(artwork_path), false) = ( - track.artwork_path.as_ref(), - track.album_group_key.is_empty(), - ) { - let _ = db.update_album_group_artwork(&track.album_group_key, artwork_path); - } - } - Err(e) => { - all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }); - } - } - - { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - } - } - } - - // Clean up tracks whose files no longer exist on disk - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some("Cleaning up missing files...".to_string()); - } - { - let guard__ = state.db.lock().await; - if let Some(db) = guard__.as_ref() { - if let Ok(tracks) = db.get_all_track_paths() { - let missing_ids: Vec = tracks - .iter() - .filter(|(_, path)| !std::path::Path::new(path).exists()) - .map(|(id, _)| *id) - .collect(); - if !missing_ids.is_empty() { - log::info!("Removing {} tracks with missing files", missing_ids.len()); - for chunk in missing_ids.chunks(500) { - if let Err(e) = db.delete_tracks_by_ids(chunk) { - log::error!("Failed to delete missing tracks: {}", e); - } - } - } - } - } - } - - // Mark complete - { - let mut progress = state.scan_progress.lock().await; - progress.status = if all_errors.is_empty() { - ScanStatus::Complete - } else { - ScanStatus::Complete // Still complete, but with errors - }; - progress.current_file = None; - progress.errors = all_errors; - } - - log::info!("Library scan complete"); - Ok(()) -} - -/// Process a CUE file and insert its tracks -async fn process_cue_file(cue_path: &Path, state: &State<'_, LibraryState>) -> Result<(), String> { - let mut cue = CueParser::parse(cue_path).map_err(|e| e.to_string())?; - - // Get audio file properties - let audio_path = normalize_library_path(Path::new(&cue.audio_file)); - if !audio_path.exists() { - return Err(format!("Audio file not found: {}", cue.audio_file)); - } - cue.audio_file = audio_path.to_string_lossy().to_string(); - - let properties = - MetadataExtractor::extract_properties(audio_path.as_path()).map_err(|e| e.to_string())?; - let format = MetadataExtractor::detect_format(audio_path.as_path()); - - // 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() { - if let Some(folder_art) = - MetadataExtractor::find_folder_artwork(audio_path.as_path(), cue.title.as_deref()) - { - artwork_path = - MetadataExtractor::cache_artwork_file(Path::new(&folder_art), &artwork_cache); - } - } - if let Some(path) = artwork_path.as_ref() { - for track in tracks.iter_mut() { - track.artwork_path = Some(path.clone()); - } - } - - // Insert tracks - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let group_key = tracks - .first() - .map(|track| track.album_group_key.clone()) - .unwrap_or_default(); - - for track in tracks { - db.insert_track(&track).map_err(|e| e.to_string())?; - } - - if let (Some(path), false) = (artwork_path.as_ref(), group_key.is_empty()) { - let _ = db.update_album_group_artwork(&group_key, path); - } - - Ok(()) -} - -fn apply_sidecar_override_if_present( - track: &mut LocalTrack, - cache: &mut HashMap>, -) { - 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 { - let mut artists: HashSet = 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( @@ -814,286 +460,6 @@ pub async fn library_get_scan_progress( Ok(progress.clone()) } -#[tauri::command] -pub async fn library_stop_scan_impl(state: State<'_, LibraryState>) -> Result<(), String> { - log::info!("Command: library_stop_scan"); - state.scan_cancel.store(true, Ordering::Relaxed); - Ok(()) -} - -/// Scan a single folder (by ID) -#[tauri::command] -pub async fn library_scan_folder_impl( - folder_id: i64, - state: State<'_, LibraryState>, -) -> Result<(), String> { - log::info!("Command: library_scan_folder {}", folder_id); - - // Get folder info - let folder = { - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.get_folder_by_id(folder_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Folder with ID {} not found", folder_id))? - }; - - if !folder.enabled { - return Err("Cannot scan disabled folder".to_string()); - } - - // Refresh network detection if not user overridden - if !folder.user_override_network { - let path = Path::new(&folder.path); - let network_info = crate::network::is_network_path(path); - - if network_info.is_network != folder.is_network { - log::info!( - "Updating network status for folder {} during scan: {} -> {}", - folder.path, - folder.is_network, - network_info.is_network - ); - - let fs_type = network_info.mount_info.as_ref().and_then(|mi| { - if let crate::network::MountKind::Network(nfs) = &mi.kind { - Some(format!("{:?}", nfs).to_lowercase()) - } else { - None - } - }); - - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let _ = db.update_folder_settings( - folder.id, - folder.alias.as_deref(), - folder.enabled, - network_info.is_network, - fs_type.as_deref(), - false, - ); - } - } - - // Reset cancel flag and progress - state.scan_cancel.store(false, Ordering::Relaxed); - { - let mut progress = state.scan_progress.lock().await; - *progress = ScanProgress { - status: ScanStatus::Scanning, - total_files: 0, - processed_files: 0, - current_file: None, - errors: Vec::new(), - }; - } - - let scanner = LibraryScanner::new(); - let mut all_errors: Vec = Vec::new(); - let mut sidecar_cache: HashMap> = - HashMap::new(); - - log::info!("Scanning single folder: {}", folder.path); - - // Scan for files - let scan_result = match scanner.scan_directory(Path::new(&folder.path)) { - Ok(result) => result, - Err(e) => { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Complete; - progress.errors = vec![ScanError { - file_path: folder.path.clone(), - error: e.to_string(), - }]; - return Err(e.to_string()); - } - }; - - let total_files = scan_result.audio_files.len() + scan_result.cue_files.len(); - - // Update total count - { - let mut progress = state.scan_progress.lock().await; - progress.total_files = total_files as u32; - } - - // Process CUE files first - for cue_path in &scan_result.cue_files { - if state.scan_cancel.load(Ordering::Relaxed) { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Cancelled; - progress.current_file = None; - log::info!("Library scan cancelled by user"); - return Ok(()); - } - - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some(cue_path.to_string_lossy().to_string()); - } - - match process_cue_file(cue_path, &state).await { - Ok(_) => {} - Err(e) => { - all_errors.push(ScanError { - file_path: cue_path.to_string_lossy().to_string(), - error: e, - }); - } - } - - { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - } - } - - // Process regular audio files - let cue_audio_files: std::collections::HashSet = scan_result - .cue_files - .iter() - .filter_map(|p| { - CueParser::parse(p).ok().map(|cue| { - normalize_library_path(Path::new(&cue.audio_file)) - .to_string_lossy() - .to_string() - }) - }) - .collect(); - - for audio_path in &scan_result.audio_files { - if state.scan_cancel.load(Ordering::Relaxed) { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Cancelled; - progress.current_file = None; - log::info!("Library scan cancelled by user"); - return Ok(()); - } - - let canonical_path = normalize_library_path(audio_path); - let path_str = canonical_path.to_string_lossy().to_string(); - if cue_audio_files.contains(&path_str) { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - continue; - } - - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some(path_str.clone()); - } - - 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() { - if let Some(folder_art) = MetadataExtractor::find_folder_artwork( - canonical_path.as_path(), - Some(&track.album), - ) { - artwork_path = MetadataExtractor::cache_artwork_file( - Path::new(&folder_art), - &artwork_cache, - ); - } - } - track.artwork_path = artwork_path.clone(); - - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - let group_key = track.album_group_key.clone(); - if let Err(e) = db.insert_track(&track) { - all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }); - } else if let Some(path) = artwork_path.as_ref() { - if !group_key.is_empty() { - let _ = db.update_album_group_artwork(&group_key, path); - } - } - } - Err(e) => { - all_errors.push(ScanError { - file_path: path_str, - error: e.to_string(), - }); - } - } - - { - let mut progress = state.scan_progress.lock().await; - progress.processed_files += 1; - } - } - - // Clean up tracks in this folder whose files no longer exist on disk - { - let mut progress = state.scan_progress.lock().await; - progress.current_file = Some("Cleaning up missing files...".to_string()); - } - { - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - if let Ok(tracks) = db.get_all_track_paths() { - let folder_prefix = if folder.path.ends_with('/') { - folder.path.clone() - } else { - format!("{}/", folder.path) - }; - let missing_ids: Vec = tracks - .iter() - .filter(|(_, path)| { - path.starts_with(&folder_prefix) && !std::path::Path::new(path).exists() - }) - .map(|(id, _)| *id) - .collect(); - if !missing_ids.is_empty() { - log::info!( - "Removing {} tracks with missing files from folder {}", - missing_ids.len(), - folder.path - ); - for chunk in missing_ids.chunks(500) { - if let Err(e) = db.delete_tracks_by_ids(chunk) { - log::error!("Failed to delete missing tracks: {}", e); - } - } - } - } - - // Update folder scan time - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let _ = db.update_folder_scan_time(&folder.path, now); - } - - // Update final status - { - let mut progress = state.scan_progress.lock().await; - progress.status = ScanStatus::Complete; - progress.current_file = None; - progress.errors = all_errors; - } - - log::info!("Single folder scan complete"); - Ok(()) -} - // === Queries === #[tauri::command] @@ -1249,17 +615,6 @@ pub async fn library_get_stats( db.get_stats(include_qobuz).map_err(|e| e.to_string()) } -#[tauri::command] -pub async fn library_clear_impl(state: State<'_, LibraryState>) -> Result<(), String> { - log::info!("Command: library_clear"); - - let guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - db.clear_all_tracks().map_err(|e| e.to_string()) -} - // === Playback === #[tauri::command] @@ -1988,396 +1343,6 @@ pub async fn library_set_album_hidden( .map_err(|e| e.to_string()) } -#[tauri::command] -pub async fn library_update_album_metadata_impl( - 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::>(); - - 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 guard__ = state.db.lock().await; - let db = guard__ - .as_mut() - .ok_or("No active session - please log in")?; - 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::>(); - - 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_impl( - 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 guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - 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(guard__); - - 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::prelude::*; - use lofty::tag::{ItemKey, Tag}; - use lofty::config::WriteOptions; - - // Ensure we only write each file once. - let mut by_file: HashMap = 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_date(lofty::tag::items::Timestamp { - year: year as u16, - ..Default::default() - }); - } else { - tag.remove_date(); - } - - 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, WriteOptions::default()) - .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 guard__ = state.db.lock().await; - let db = guard__ - .as_mut() - .ok_or("No active session - please log in")?; - 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::>(); - - 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_impl( - 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 guard__ = state.db.lock().await; - let db = guard__ - .as_ref() - .ok_or("No active session - please log in")?; - 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(guard__); - - 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, String> { - let _ = crate::library::delete_album_sidecar(&album_dir); - - let mut updates: Vec = 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 guard__ = state.db.lock().await; - let db = guard__ - .as_mut() - .ok_or("No active session - please log in")?; - db.update_tracks_metadata_by_id(&updates) - .map_err(|e| e.to_string())?; - - Ok(()) -} #[tauri::command] pub async fn library_get_hidden_albums( @@ -2937,51 +1902,3 @@ pub async fn move_playlist_to_folder( .map_err(|e| e.to_string()) } -// Shared wrappers to keep legacy command names while exposing non-command impls for V2 usage. -#[tauri::command] -pub async fn library_scan(state: State<'_, LibraryState>) -> Result<(), String> { - library_scan_impl(state).await -} - -#[tauri::command] -pub async fn library_stop_scan(state: State<'_, LibraryState>) -> Result<(), String> { - library_stop_scan_impl(state).await -} - -#[tauri::command] -pub async fn library_scan_folder( - folder_id: i64, - state: State<'_, LibraryState>, -) -> Result<(), String> { - library_scan_folder_impl(folder_id, state).await -} - -#[tauri::command] -pub async fn library_clear(state: State<'_, LibraryState>) -> Result<(), String> { - library_clear_impl(state).await -} - -#[tauri::command] -pub async fn library_update_album_metadata( - request: LibraryAlbumMetadataUpdateRequest, - state: State<'_, LibraryState>, -) -> Result<(), String> { - library_update_album_metadata_impl(request, state).await -} - -#[tauri::command] -pub async fn library_write_album_metadata_to_files( - app: tauri::AppHandle, - request: LibraryAlbumMetadataUpdateRequest, - state: State<'_, LibraryState>, -) -> Result<(), String> { - library_write_album_metadata_to_files_impl(app, request, state).await -} - -#[tauri::command] -pub async fn library_refresh_album_metadata_from_files( - album_group_key: String, - state: State<'_, LibraryState>, -) -> Result<(), String> { - library_refresh_album_metadata_from_files_impl(album_group_key, state).await -} diff --git a/src-tauri/src/library/mod.rs b/src-tauri/src/library/mod.rs index 25bce009d..11f640d21 100644 --- a/src-tauri/src/library/mod.rs +++ b/src-tauri/src/library/mod.rs @@ -86,15 +86,12 @@ pub use state::{ LibraryAlbumTrackMetadataUpdate, LibraryState, }; pub use commands::{ - library_add_folder, library_cleanup_missing_files, library_clear_impl, - library_fetch_missing_artwork, library_get_album_tracks, library_get_artists, - library_get_folders, library_get_folders_with_metadata, library_get_scan_progress, - library_get_stats, library_get_thumbnail, library_get_thumbnails_cache_size, - library_get_tracks_by_ids, library_refresh_album_metadata_from_files_impl, - library_scan_folder_impl, library_scan_impl, library_search, library_stop_scan_impl, - library_update_album_metadata_impl, library_write_album_metadata_to_files_impl, - playlist_get_custom_order, playlist_get_tracks_with_local_copies, playlist_has_custom_order, - update_playlist_folder, + library_add_folder, library_cleanup_missing_files, library_fetch_missing_artwork, + library_get_album_tracks, library_get_artists, library_get_folders, + library_get_folders_with_metadata, library_get_scan_progress, library_get_stats, + library_get_thumbnail, library_get_thumbnails_cache_size, library_get_tracks_by_ids, + library_search, playlist_get_custom_order, playlist_get_tracks_with_local_copies, + playlist_has_custom_order, update_playlist_folder, }; use std::path::Path;