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>,