fix(library): improve artist consolidation and album matching

- Add all_artists field to LocalAlbum for accurate artist matching
- Consolidate duplicate artists with case differences (e.g., "Alice in Chains" / "Alice In Chains")
- Fix album count calculation to avoid double-counting
- Show Various Artists albums under contributing artists
- Handle multi-artist names (e.g., "A, B, C") with union matching
- Add cleanup missing files feature in settings maintenance section
- Improve settings UI with side-by-side Maintenance/Danger Zone layout
- Add status indicator during cleanup operations
This commit is contained in:
vicrodh committed 2026-01-28 08:13:07 -06:00
1 parent 8bf85a1c8a
commit 700dc07014
5 files changed
+394 -74

No files matched your search

+1
View File
@@ -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,
+53
View File
@@ -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<CleanupResult, String> {
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<i64> = 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<Vec<String>, String> {
log::info!("Command: library_get_folders");
+54 -10
View File
@@ -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<Vec<(i64, String)>, 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<usize, LibraryError> {
if ids.is_empty() {
return Ok(0);
}
let placeholders: Vec<String> = 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<String> = row.get(5)?;
let all_artists: String = row.get::<_, Option<String>>(3)?.unwrap_or_default();
let artwork_path: Option<String> = 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<String>>(8)?.unwrap_or_default(),
&row.get::<_, Option<String>>(9)?.unwrap_or_default(),
),
bit_depth: row.get(9)?,
sample_rate: row.get::<_, Option<f64>>(10)?.unwrap_or(44100.0),
bit_depth: row.get(10)?,
sample_rate: row.get::<_, Option<f64>>(11)?.unwrap_or(44100.0),
directory_path: row
.get::<_, Option<String>>(11)?
.get::<_, Option<String>>(12)?
.unwrap_or_else(|| group_key.clone()),
source: row.get::<_, Option<String>>(12)?.unwrap_or_else(|| "user".to_string()),
source: row.get::<_, Option<String>>(13)?.unwrap_or_else(|| "user".to_string()),
})
})
.map_err(|e| LibraryError::Database(e.to_string()))?;
+4
View File
@@ -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<u32>,
pub catalog_number: Option<String>,
pub artwork_path: Option<String>,
+282 -64
View File
@@ -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<ScanProgress | null>(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<string, LocalArtist[]>();
// 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<string, Set<string>>();
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<string>();
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<string, string>();
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}
</div>
<div class="danger-zone">
<div class="danger-zone-label">Danger Zone</div>
<button class="danger-btn-small" onclick={(e) => handleClearLibrary(e)} disabled={clearingLibrary}>
<Trash2 size={12} />
<span>{clearingLibrary ? 'Clearing...' : 'Clear Library Database'}</span>
</button>
<div class="settings-bottom-row">
<div class="maintenance-section">
<div class="maintenance-label">Maintenance</div>
<button
class="btn btn-secondary"
onclick={handleCleanupMissingFiles}
disabled={cleaningUpMissingFiles}
>
<RefreshCw size={14} class={cleaningUpMissingFiles ? 'spinning' : ''} />
<span>{cleaningUpMissingFiles ? 'Cleaning up...' : 'Cleanup missing files'}</span>
</button>
{#if cleanupStatus}
<div class="cleanup-status">{cleanupStatus}</div>
{/if}
</div>
<div class="settings-divider"></div>
<div class="danger-zone">
<div class="danger-zone-label">Danger Zone</div>
<button class="danger-btn-small" onclick={(e) => handleClearLibrary(e)} disabled={clearingLibrary}>
<Trash2 size={12} />
<span>{clearingLibrary ? 'Clearing...' : 'Clear Library Database'}</span>
</button>
</div>
</div>
</div>
{/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 {