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:
1 parent
8bf85a1c8a
commit
700dc07014
5 files changed
+394
-74
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in new issue
Block a user