diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de332945b..5e8d244c3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -569,6 +569,7 @@ pub fn run() { // Local library commands library::commands::library_add_folder, library::commands::library_remove_folder, + library::commands::library_cleanup_missing_files, library::commands::library_get_folders, library::commands::library_get_folders_with_metadata, library::commands::library_get_folder, diff --git a/src-tauri/src/library/commands.rs b/src-tauri/src/library/commands.rs index f8e1a3296..549a8fcd7 100644 --- a/src-tauri/src/library/commands.rs +++ b/src-tauri/src/library/commands.rs @@ -94,6 +94,59 @@ pub async fn library_remove_folder( Ok(()) } +/// Result of cleanup operation +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CleanupResult { + pub checked: usize, + pub removed: usize, +} + +/// Clean up tracks whose files no longer exist on disk +#[tauri::command] +pub async fn library_cleanup_missing_files( + state: State<'_, LibraryState>, +) -> Result { + log::info!("Command: library_cleanup_missing_files"); + + let mut db = state.db.lock().await; + + // Get all track paths + let tracks = db.get_all_track_paths().map_err(|e| e.to_string())?; + let total = tracks.len(); + log::info!("Checking {} tracks for missing files...", total); + + // Find tracks whose files don't exist + let mut missing_ids: Vec = Vec::new(); + for (id, path) in &tracks { + if !std::path::Path::new(path).exists() { + log::debug!("Missing file: {}", path); + missing_ids.push(*id); + } + } + + log::info!("Found {} missing files out of {} tracks", missing_ids.len(), total); + + // Delete missing tracks in batches + let removed = if !missing_ids.is_empty() { + let mut total_removed = 0; + for chunk in missing_ids.chunks(500) { + let count = db.delete_tracks_by_ids(chunk).map_err(|e| e.to_string())?; + total_removed += count; + } + total_removed + } else { + 0 + }; + + log::info!("Removed {} tracks with missing files", removed); + + Ok(CleanupResult { + checked: total, + removed, + }) +} + #[tauri::command] pub async fn library_get_folders(state: State<'_, LibraryState>) -> Result, String> { log::info!("Command: library_get_folders"); diff --git a/src-tauri/src/library/database.rs b/src-tauri/src/library/database.rs index 89415e640..b005b2ca8 100644 --- a/src-tauri/src/library/database.rs +++ b/src-tauri/src/library/database.rs @@ -980,6 +980,46 @@ impl LibraryDatabase { Ok(()) } + /// Get all file paths for local tracks (for cleanup check) + pub fn get_all_track_paths(&self) -> Result, LibraryError> { + let mut stmt = self + .conn + .prepare("SELECT id, file_path FROM local_tracks WHERE source IS NULL OR source = 'user'") + .map_err(|e| LibraryError::Database(e.to_string()))?; + + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| LibraryError::Database(e.to_string()))?; + + let mut paths = Vec::new(); + for row in rows { + paths.push(row.map_err(|e| LibraryError::Database(e.to_string()))?); + } + Ok(paths) + } + + /// Delete tracks by their IDs + pub fn delete_tracks_by_ids(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } + + let placeholders: Vec = ids.iter().map(|_| "?".to_string()).collect(); + let query = format!( + "DELETE FROM local_tracks WHERE id IN ({})", + placeholders.join(",") + ); + + let params: Vec<&dyn rusqlite::ToSql> = ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); + + let count = self + .conn + .execute(&query, params.as_slice()) + .map_err(|e| LibraryError::Database(e.to_string()))?; + + Ok(count) + } + // === Query Methods === /// Get all albums with optional hidden filter @@ -1031,6 +1071,7 @@ impl LibraryDatabase { WHEN COUNT(DISTINCT artist) > 1 THEN 'Various Artists' ELSE MIN(artist) END as artist, + GROUP_CONCAT(DISTINCT artist) as all_artists, MIN(year) as year, MIN(catalog_number) as catalog_number, MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork, @@ -1072,6 +1113,7 @@ impl LibraryDatabase { WHEN COUNT(DISTINCT artist) > 1 THEN 'Various Artists' ELSE MIN(artist) END as artist, + GROUP_CONCAT(DISTINCT artist) as all_artists, MIN(year) as year, MIN(catalog_number) as catalog_number, MAX(CASE WHEN artwork_path IS NOT NULL THEN artwork_path END) as artwork, @@ -1118,7 +1160,8 @@ impl LibraryDatabase { let group_key: String = row.get(0)?; let album: String = row.get(1)?; let artist: String = row.get(2)?; - let artwork_path: Option = row.get(5)?; + let all_artists: String = row.get::<_, Option>(3)?.unwrap_or_default(); + let artwork_path: Option = row.get(6)?; log::debug!("Album {} by {}: artwork_path = {:?}", album, artist, artwork_path); @@ -1126,20 +1169,21 @@ impl LibraryDatabase { id: group_key.clone(), title: album, artist, - year: row.get(3)?, - catalog_number: row.get(4)?, + all_artists, + year: row.get(4)?, + catalog_number: row.get(5)?, artwork_path, - track_count: row.get(6)?, - total_duration_secs: row.get(7)?, + track_count: row.get(7)?, + total_duration_secs: row.get(8)?, format: Self::parse_format( - &row.get::<_, Option>(8)?.unwrap_or_default(), + &row.get::<_, Option>(9)?.unwrap_or_default(), ), - bit_depth: row.get(9)?, - sample_rate: row.get::<_, Option>(10)?.unwrap_or(44100.0), + bit_depth: row.get(10)?, + sample_rate: row.get::<_, Option>(11)?.unwrap_or(44100.0), directory_path: row - .get::<_, Option>(11)? + .get::<_, Option>(12)? .unwrap_or_else(|| group_key.clone()), - source: row.get::<_, Option>(12)?.unwrap_or_else(|| "user".to_string()), + source: row.get::<_, Option>(13)?.unwrap_or_else(|| "user".to_string()), }) }) .map_err(|e| LibraryError::Database(e.to_string()))?; diff --git a/src-tauri/src/library/models.rs b/src-tauri/src/library/models.rs index 37e4d58ca..c816c3bb2 100644 --- a/src-tauri/src/library/models.rs +++ b/src-tauri/src/library/models.rs @@ -127,6 +127,10 @@ pub struct LocalAlbum { pub id: String, pub title: String, pub artist: String, + /// All contributing artists (comma-separated) - used for matching in artist view + /// This includes all unique album_artist/artist values from the album's tracks + #[serde(default)] + pub all_artists: String, pub year: Option, pub catalog_number: Option, pub artwork_path: Option, diff --git a/src/lib/components/views/LocalLibraryView.svelte b/src/lib/components/views/LocalLibraryView.svelte index e29f6ed2d..ff2934a0f 100644 --- a/src/lib/components/views/LocalLibraryView.svelte +++ b/src/lib/components/views/LocalLibraryView.svelte @@ -76,6 +76,7 @@ id: string; title: string; artist: string; + all_artists?: string; // Comma-separated list of all contributing artists year?: number; catalog_number?: string; artwork_path?: string; @@ -439,8 +440,8 @@ let scanProgress = $state(null); // Reactive counters based on filtered data + // Note: filteredArtistCount is defined after mergedArtists below let filteredAlbumCount = $derived(albums.length); - let filteredArtistCount = $derived(artists.length); let filteredTrackCount = $derived.by(() => { // When in tracks view with search results, use actual filtered count if (activeTab === 'tracks' && tracks.length > 0) { @@ -459,11 +460,144 @@ return albums.reduce((sum, album) => sum + album.track_count, 0); }); - // Memoized filtered artists + // Merge artists with same normalized name (e.g., "Alice in Chains" and "Alice In Chains") + // Priority goes to the variant with most albums, then tracks + // Returns both merged list and canonical name mappings + let artistMergeResult = $derived.by(() => { + const byNormalized = new Map(); + + // Group by normalized name (skip empty names) + for (const artist of artists) { + if (!artist.name || !artist.name.trim()) continue; + const normalized = normalizeArtistName(artist.name); + if (!normalized) continue; // Skip if normalized name is empty + if (!byNormalized.has(normalized)) { + byNormalized.set(normalized, []); + } + byNormalized.get(normalized)!.push(artist); + } + + // Build a map of normalized artist -> unique album IDs + // This gives us accurate album counts even when same album has tracks with different artist spellings + const artistAlbumIds = new Map>(); + for (const album of albums) { + const normalizedAlbumArtist = normalizeArtistName(album.artist); + const isVariousArtists = normalizedAlbumArtist === 'various artists'; + + // For all albums with all_artists, credit each artist + if (album.all_artists) { + const allArtistsList = album.all_artists.split(',').map(a => normalizeArtistName(a.trim())); + for (const normalizedArtist of allArtistsList) { + if (!normalizedArtist || normalizedArtist === 'various artists') continue; + if (!artistAlbumIds.has(normalizedArtist)) { + artistAlbumIds.set(normalizedArtist, new Set()); + } + artistAlbumIds.get(normalizedArtist)!.add(album.id); + } + } else if (!isVariousArtists) { + // Fallback to album.artist (only if not Various Artists) + if (!artistAlbumIds.has(normalizedAlbumArtist)) { + artistAlbumIds.set(normalizedAlbumArtist, new Set()); + } + artistAlbumIds.get(normalizedAlbumArtist)!.add(album.id); + } + } + + // Helper to get album count for an artist (handles multi-artist names) + function getAlbumCountForArtist(artistName: string, normalized: string): number { + // First try direct lookup + const direct = artistAlbumIds.get(normalized); + if (direct && direct.size > 0) return direct.size; + + // For multi-artist names like "A, B, C", find albums where ANY part appears + const parts = artistName.split(/,|&|feat\.|ft\.|featuring|with/i) + .map(p => normalizeArtistName(p.trim())) + .filter(p => p.length > 0); + + if (parts.length > 1) { + // Union of all albums where ANY artist appears + const unionAlbums = new Set(); + for (const part of parts) { + const partAlbums = artistAlbumIds.get(part); + if (partAlbums) { + for (const albumId of partAlbums) { + unionAlbums.add(albumId); + } + } + } + return unionAlbums.size; + } + + return 0; + } + + // Merge groups and build canonical mappings + const merged: LocalArtist[] = []; + const newCanonicalMappings = new Map(); + + for (const [normalized, variants] of byNormalized) { + if (variants.length === 1) { + // Single variant - use actual album count + const actualAlbumCount = getAlbumCountForArtist(variants[0].name, normalized); + merged.push({ + ...variants[0], + album_count: actualAlbumCount || variants[0].album_count + }); + continue; + } + + // Find the canonical variant (most albums, then most tracks) + const sorted = [...variants].sort((a, b) => { + if (b.album_count !== a.album_count) return b.album_count - a.album_count; + return b.track_count - a.track_count; + }); + const canonical = sorted[0]; + + // Get actual unique album count using helper + const actualAlbumCount = getAlbumCountForArtist(canonical.name, normalized); + // Sum track counts (tracks are unique per artist spelling in DB) + const totalTracks = variants.reduce((sum, v) => sum + v.track_count, 0); + + // Create merged artist with canonical name and accurate album count + merged.push({ + name: canonical.name, + album_count: actualAlbumCount || canonical.album_count, + track_count: totalTracks + }); + + // Map all variants to canonical name + for (const variant of variants) { + if (variant.name !== canonical.name) { + newCanonicalMappings.set(variant.name, canonical.name); + } + } + } + + return { merged, canonicalMappings: newCanonicalMappings }; + }); + + // Extract merged artists list + let mergedArtists = $derived(artistMergeResult.merged); + + // Artist count uses merged artists (after deduplication) + let filteredArtistCount = $derived(mergedArtists.length); + + // Combined canonical names: merge mappings + Qobuz mappings + // This is a derived that combines both sources without causing infinite loops + let allCanonicalNames = $derived.by(() => { + const combined = new Map(canonicalNames); // Start with Qobuz mappings + // Add merge mappings (these take precedence for case normalization) + for (const [variant, canonical] of artistMergeResult.canonicalMappings) { + combined.set(variant, canonical); + } + return combined; + }); + + // Memoized filtered artists (uses merged artists) let filteredArtistsMemo = $derived.by(() => { - if (!debouncedArtistSearch) return artists; + if (!debouncedArtistSearch) return mergedArtists; const needle = debouncedArtistSearch.toLowerCase(); - return artists.filter(artist => artist.name.toLowerCase().includes(needle)); + return mergedArtists.filter(artist => artist.name.toLowerCase().includes(needle)); }); // Memoized grouped artists with alpha index and display names @@ -473,7 +607,7 @@ // Add display names from canonical names mapping const withDisplayNames = filtered.map(artist => ({ ...artist, - displayName: canonicalNames.get(artist.name) || artist.name + displayName: allCanonicalNames.get(artist.name) || artist.name })); if (!artistGroupingEnabled) { @@ -557,15 +691,44 @@ let selectedArtistAlbums = $derived.by(() => { if (!selectedArtistName) return []; const normalizedSelected = normalizeArtistName(selectedArtistName); + + // Split selected artist into parts for multi-artist matching + // e.g., "Katsutoshi Kitagawa, Mina Kubota" -> ["katsutoshi kitagawa", "mina kubota"] + const selectedParts = selectedArtistName.split(/,|&|feat\.|ft\.|featuring|with/i) + .map(p => normalizeArtistName(p.trim())) + .filter(p => p.length > 0); + return albums.filter(album => { const normalizedArtist = normalizeArtistName(album.artist); - // Exact match + const allArtistsList = album.all_artists + ? album.all_artists.split(',').map(a => normalizeArtistName(a.trim())) + : []; + + // For "Various Artists" albums: include if ANY part of the artist name is in all_artists + if (normalizedArtist === 'various artists') { + if (allArtistsList.length === 0) return false; + // Check exact match first + if (allArtistsList.includes(normalizedSelected)) return true; + // Check if ANY part of a multi-artist name is present + if (selectedParts.length > 1 && selectedParts.some(part => allArtistsList.includes(part))) { + return true; + } + return false; + } + + // Exact match on display artist if (normalizedArtist === normalizedSelected) return true; - // Check if artist appears in the album artist string (for multi-artist albums) - // e.g., "Artist A & Artist B" or "Artist A, Artist B" or "Artist A feat. Artist B" - if (normalizedArtist.includes(normalizedSelected)) return true; - // Also check individual parts split by common separators - const artistParts = album.artist.split(/[,&]|feat\.|ft\.|featuring|with|y|und|et|e /i) + + // Check all_artists field for EXACT matches + if (allArtistsList.includes(normalizedSelected)) return true; + + // Check if ANY part of a multi-artist name is present in all_artists + if (selectedParts.length > 1 && allArtistsList.length > 0) { + if (selectedParts.some(part => allArtistsList.includes(part))) return true; + } + + // Also check individual parts split by common separators in album.artist + const artistParts = album.artist.split(/,|&|feat\.|ft\.|featuring|with/i) .map(p => normalizeArtistName(p.trim())) .filter(p => p.length > 0); return artistParts.includes(normalizedSelected); @@ -590,7 +753,7 @@ for (const album of items) { // Use canonical name for artist grouping to merge "Alice in Chains" and "Alice In Chains" const key = mode === 'artist' - ? (canonicalNames.get(album.artist) || album.artist) + ? (allCanonicalNames.get(album.artist) || album.artist) : alphaGroupKey(album.title); let group = groups.get(key); if (!group) { @@ -1171,6 +1334,40 @@ } let clearingLibrary = $state(false); + let cleaningUpMissingFiles = $state(false); + let cleanupStatus = $state(''); + + async function handleCleanupMissingFiles() { + if (cleaningUpMissingFiles) return; + + cleaningUpMissingFiles = true; + cleanupStatus = 'Scanning track paths...'; + try { + const result = await invoke<{ checked: number; removed: number }>('library_cleanup_missing_files'); + + if (result.removed > 0) { + cleanupStatus = `Removed ${result.removed} of ${result.checked} tracks`; + showToast(`Removed ${result.removed} tracks with missing files`, 'success'); + // Reload library data + cleanupStatus = 'Refreshing library...'; + await loadAlbums(); + await loadArtists(); + } else { + cleanupStatus = `Checked ${result.checked} tracks - all OK`; + showToast('No missing files found', 'info'); + } + } catch (err) { + console.error('Failed to cleanup missing files:', err); + cleanupStatus = 'Error during cleanup'; + showToast('Failed to cleanup missing files', 'error'); + } finally { + cleaningUpMissingFiles = false; + // Clear status after a delay + setTimeout(() => { + cleanupStatus = ''; + }, 3000); + } + } async function handleClearLibrary(event: MouseEvent) { event.preventDefault(); @@ -1835,8 +2032,8 @@ const sorted = [...items].sort((a, b) => { if (mode === 'artist') { // Use canonical names for sorting to keep "Alice in Chains" and "Alice In Chains" together - const aArtist = canonicalNames.get(a.artist) || a.artist; - const bArtist = canonicalNames.get(b.artist) || b.artist; + const aArtist = allCanonicalNames.get(a.artist) || a.artist; + const bArtist = allCanonicalNames.get(b.artist) || b.artist; const artistCmp = aArtist.localeCompare(bArtist); if (artistCmp !== 0) return artistCmp; return a.title.localeCompare(b.title); @@ -1848,7 +2045,7 @@ for (const album of sorted) { // Use canonical name for artist grouping const key = mode === 'artist' - ? (canonicalNames.get(album.artist) || album.artist) + ? (allCanonicalNames.get(album.artist) || album.artist) : alphaGroupKey(album.title); if (!groups.has(key)) { groups.set(key, []); @@ -2020,42 +2217,6 @@ if (normalizeArtistName(name) === 'various artists') return; // Select artist to show their albums in the right column selectedArtistName = name; - - // Debug: Log artist selection and matching albums - const normalizedSelected = normalizeArtistName(name); - console.log('[Artist Debug] Selected artist:', name); - console.log('[Artist Debug] Normalized name:', normalizedSelected); - - // Find albums that should match - const matchingAlbums = albums.filter(album => { - const normalizedArtist = normalizeArtistName(album.artist); - const exactMatch = normalizedArtist === normalizedSelected; - const partialMatch = normalizedArtist.includes(normalizedSelected); - if (exactMatch || partialMatch) { - console.log('[Artist Debug] Match found:', album.title, '- Artist:', album.artist); - } - return exactMatch || partialMatch; - }); - - console.log('[Artist Debug] Total matching albums:', matchingAlbums.length); - console.log('[Artist Debug] Total albums in library:', albums.length); - - // Also check artist data - const artistData = artists.find(a => a.name === name); - if (artistData) { - console.log('[Artist Debug] Artist from artists array:', artistData); - console.log('[Artist Debug] Claimed album count:', artistData.album_count, 'track count:', artistData.track_count); - } - - // Debug: Find albums with similar artist name (first word match) - const firstWord = name.split(' ')[0].toLowerCase(); - const similarAlbums = albums.filter(album => - album.artist.toLowerCase().includes(firstWord) - ); - console.log('[Artist Debug] Albums with "' + firstWord + '" in artist name:'); - similarAlbums.slice(0, 10).forEach(album => { - console.log(' -', album.title, '| Artist:', album.artist); - }); } /** @@ -2096,7 +2257,7 @@ * Get the display name for an artist (canonical if available, otherwise original). */ function getArtistDisplayName(name: string): string { - return canonicalNames.get(name) || name; + return allCanonicalNames.get(name) || name; } /** @@ -2268,8 +2429,8 @@ } if (mode === 'artist') { // Use canonical names for sorting to keep variants together - const aArtist = canonicalNames.get(a.artist) || a.artist; - const bArtist = canonicalNames.get(b.artist) || b.artist; + const aArtist = allCanonicalNames.get(a.artist) || a.artist; + const bArtist = allCanonicalNames.get(b.artist) || b.artist; const artistCmp = aArtist.localeCompare(bArtist); if (artistCmp !== 0) return artistCmp; const albumCmp = a.album.localeCompare(b.album); @@ -2315,7 +2476,7 @@ } else if (mode === 'artist') { const rawArtist = track.artist || 'Unknown Artist'; // Use canonical name for grouping to merge "Alice in Chains" and "Alice In Chains" - const canonicalArtist = canonicalNames.get(rawArtist) || rawArtist; + const canonicalArtist = allCanonicalNames.get(rawArtist) || rawArtist; if (!groups.has(canonicalArtist)) { groups.set(canonicalArtist, { title: canonicalArtist, tracks: [], artists: new Set([rawArtist]) }); } else { @@ -2643,12 +2804,31 @@ {/if} -
-
Danger Zone
- +
+
+
Maintenance
+ + {#if cleanupStatus} +
{cleanupStatus}
+ {/if} +
+ +
+ +
+
Danger Zone
+ +
{/if} @@ -3830,10 +4010,48 @@ flex: 1; } + .settings-bottom-row { + display: flex; + gap: 24px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--bg-tertiary); + } + + .settings-divider { + width: 1px; + background: var(--bg-tertiary); + align-self: stretch; + } + + .maintenance-section { + flex: 1; + display: flex; + flex-direction: column; + } + + .maintenance-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + } + + .cleanup-status { + margin-top: 8px; + font-size: 12px; + color: var(--text-secondary); + padding: 6px 10px; + background: var(--bg-tertiary); + border-radius: 4px; + } + .danger-zone { - margin-top: 24px; - padding-top: 20px; - border-top: 2px solid var(--bg-tertiary); + flex: 1; + display: flex; + flex-direction: column; } .danger-zone-label {