From beb3dd7e65959ca2f28784138f12f2cd65f948e3 Mon Sep 17 00:00:00 2001 From: Rufei Zhou Date: Sat, 29 Nov 2025 04:16:20 -0800 Subject: [PATCH 1/3] add incremental library scanning --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/db.rs | 86 ++- src-tauri/src/fs_track.rs | 52 +- src-tauri/src/library.rs | 652 ++++++++++++++++++++++- src-tauri/src/lyrics.rs | 60 ++- src-tauri/src/main.rs | 51 +- src-tauri/src/state.rs | 1 + src-tauri/tests/assets/minimal.mp3 | Bin 0 -> 11744 bytes src/components/Library.vue | 125 ++++- src/components/library/LibraryHeader.vue | 15 +- 11 files changed, 990 insertions(+), 56 deletions(-) create mode 100644 src-tauri/tests/assets/minimal.mp3 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8960c08..73f7261 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2408,6 +2408,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-os", "tauri-plugin-shell", + "tempfile", "thiserror 1.0.69", "tokio", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0879999..d23c412 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,9 @@ tauri-plugin-os = "2" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" +[dev-dependencies] +tempfile = "3.8" + [features] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9ac593e..bde9c2a 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,7 +10,7 @@ use rusqlite::{named_params, params, Connection}; use std::fs; use tauri::{AppHandle, Manager}; -const CURRENT_DB_VERSION: u32 = 7; +const CURRENT_DB_VERSION: u32 = 8; /// Initializes the database connection, creating the .sqlite file if needed, and upgrading the database /// if it's out of date. @@ -200,6 +200,26 @@ pub fn upgrade_database_if_needed( tx.commit()?; } + + if existing_version <= 7 { + println!("Migrate database version 8..."); + let tx = db.transaction()?; + + tx.pragma_update(None, "user_version", 8)?; + + tx.execute_batch(indoc! {" + ALTER TABLE tracks ADD file_mtime INTEGER; + ALTER TABLE tracks ADD lrc_mtime INTEGER; + ALTER TABLE tracks ADD txt_mtime INTEGER; + + DELETE FROM tracks WHERE 1; + DELETE FROM albums WHERE 1; + DELETE FROM artists WHERE 1; + UPDATE library_data SET init = 0 WHERE 1; + "})?; + + tx.commit()?; + } } Ok(()) @@ -380,12 +400,13 @@ pub fn update_track_synced_lyrics( id: i64, synced_lyrics: &str, plain_lyrics: &str, + lrc_mtime: i64, db: &Connection, ) -> Result { let mut statement = db.prepare( - "UPDATE tracks SET lrc_lyrics = ?, txt_lyrics = ?, instrumental = false WHERE id = ?", + "UPDATE tracks SET lrc_lyrics = ?, txt_lyrics = ?, instrumental = false, lrc_mtime = ?, txt_mtime = NULL WHERE id = ?", )?; - statement.execute((synced_lyrics, plain_lyrics, id))?; + statement.execute((synced_lyrics, plain_lyrics, lrc_mtime, id))?; Ok(get_track_by_id(id, db)?) } @@ -393,19 +414,20 @@ pub fn update_track_synced_lyrics( pub fn update_track_plain_lyrics( id: i64, plain_lyrics: &str, + txt_mtime: i64, db: &Connection, ) -> Result { let mut statement = db.prepare( - "UPDATE tracks SET txt_lyrics = ?, lrc_lyrics = null, instrumental = false WHERE id = ?", + "UPDATE tracks SET txt_lyrics = ?, lrc_lyrics = null, instrumental = false, txt_mtime = ?, lrc_mtime = NULL WHERE id = ?", )?; - statement.execute((plain_lyrics, id))?; + statement.execute((plain_lyrics, txt_mtime, id))?; Ok(get_track_by_id(id, db)?) } pub fn update_track_null_lyrics(id: i64, db: &Connection) -> Result { let mut statement = db.prepare( - "UPDATE tracks SET txt_lyrics = null, lrc_lyrics = null, instrumental = false WHERE id = ?", + "UPDATE tracks SET txt_lyrics = null, lrc_lyrics = null, instrumental = false, lrc_mtime = NULL, txt_mtime = NULL WHERE id = ?", )?; statement.execute([id])?; @@ -465,8 +487,11 @@ pub fn add_track(track: &fs_track::FsTrack, db: &Connection) -> Result<()> { track_number, txt_lyrics, lrc_lyrics, - instrumental - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + instrumental, + file_mtime, + lrc_mtime, + txt_mtime + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "}; let mut statement = db.prepare(query)?; statement.execute(( @@ -481,6 +506,9 @@ pub fn add_track(track: &fs_track::FsTrack, db: &Connection) -> Result<()> { track.txt_lyrics(), track.lrc_lyrics(), is_instrumental, + track.file_mtime(), + track.lrc_mtime(), + track.txt_mtime(), ))?; Ok(()) @@ -631,11 +659,11 @@ pub fn get_search_track_ids( pub fn get_albums(db: &Connection) -> Result> { let mut statement = db.prepare(indoc! {" - SELECT albums.id, albums.name, albums.album_artist_name AS album_artist_name, albums.album_artist_name, + SELECT albums.id, albums.name, albums.image_path, albums.album_artist_name AS album_artist_name, albums.album_artist_name, COUNT(tracks.id) AS tracks_count FROM albums JOIN tracks ON tracks.album_id = albums.id - GROUP BY albums.id, albums.name, albums.album_artist_name + GROUP BY albums.id, albums.name, albums.image_path, albums.album_artist_name ORDER BY albums.name_lower ASC "})?; let mut rows = statement.query([])?; @@ -909,6 +937,44 @@ pub fn get_artist_track_ids(artist_id: i64, without_plain_lyrics: bool, without_ Ok(tracks) } +pub fn get_track_metadata_map(db: &Connection) -> Result, Option, Option)>> { + let mut statement = db.prepare("SELECT id, file_path, file_mtime, lrc_mtime, txt_mtime FROM tracks")?; + let mut rows = statement.query([])?; + let mut map = std::collections::HashMap::new(); + + while let Some(row) = rows.next()? { + let file_path: String = row.get("file_path")?; + let id: i64 = row.get("id")?; + let file_mtime: Option = row.get("file_mtime")?; + let lrc_mtime: Option = row.get("lrc_mtime")?; + let txt_mtime: Option = row.get("txt_mtime")?; + map.insert(file_path, (id, file_mtime, lrc_mtime, txt_mtime)); + } + + Ok(map) +} + +pub fn remove_track_by_id(id: i64, db: &Connection) -> Result<()> { + db.execute("DELETE FROM tracks WHERE id = ?", [id])?; + Ok(()) +} + +pub fn clean_orphaned_entities(db: &Connection) -> Result<()> { + // Delete albums with no tracks + db.execute( + "DELETE FROM albums WHERE id NOT IN (SELECT DISTINCT album_id FROM tracks)", + (), + )?; + + // Delete artists with no tracks + db.execute( + "DELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM tracks)", + (), + )?; + + Ok(()) +} + pub fn clean_library(db: &Connection) -> Result<()> { db.execute("DELETE FROM tracks WHERE 1", ())?; db.execute("DELETE FROM albums WHERE 1", ())?; diff --git a/src-tauri/src/fs_track.rs b/src-tauri/src/fs_track.rs index 696618c..1eb88cb 100644 --- a/src-tauri/src/fs_track.rs +++ b/src-tauri/src/fs_track.rs @@ -27,6 +27,9 @@ pub struct FsTrack { txt_lyrics: Option, lrc_lyrics: Option, track_number: Option, + file_mtime: Option, + lrc_mtime: Option, + txt_mtime: Option, } #[derive(Error, Debug)] @@ -63,6 +66,9 @@ impl FsTrack { txt_lyrics: Option, lrc_lyrics: Option, track_number: Option, + file_mtime: Option, + lrc_mtime: Option, + txt_mtime: Option, ) -> FsTrack { FsTrack { file_path, @@ -75,10 +81,13 @@ impl FsTrack { txt_lyrics, lrc_lyrics, track_number, + file_mtime, + lrc_mtime, + txt_mtime, } } - fn new_from_path(path: &Path) -> Result { + pub fn new_from_path(path: &Path) -> Result { let file_path = path.display().to_string(); let file_name = path.file_name().unwrap().to_str().unwrap().to_owned(); let tagged_file = read_from_path(&file_path) @@ -107,6 +116,13 @@ impl FsTrack { let duration = properties.duration().as_secs_f64(); let track_number = tag.track(); + // Get file mtime + let file_mtime = std::fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + let mut track = FsTrack::new( file_path, file_name, @@ -118,9 +134,13 @@ impl FsTrack { None, None, track_number, + file_mtime, + None, + None, ); track.txt_lyrics = track.get_txt_lyrics(); track.lrc_lyrics = track.get_lrc_lyrics(); + track.update_lyrics_mtimes(); Ok(track) } @@ -165,6 +185,36 @@ impl FsTrack { self.track_number } + pub fn file_mtime(&self) -> Option { + self.file_mtime + } + + pub fn lrc_mtime(&self) -> Option { + self.lrc_mtime + } + + pub fn txt_mtime(&self) -> Option { + self.txt_mtime + } + + fn update_lyrics_mtimes(&mut self) { + // Get mtime for .lrc file if it exists + let lrc_path = self.get_lrc_path(); + self.lrc_mtime = std::fs::metadata(&lrc_path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + + // Get mtime for .txt file if it exists + let txt_path = self.get_txt_path(); + self.txt_mtime = std::fs::metadata(&txt_path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + } + fn get_txt_path(&self) -> String { let path = PathBuf::from(self.file_path.to_owned()); let file_name = path.file_name().unwrap().to_str().unwrap().to_owned(); diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 05df4e6..ebf5a06 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -3,7 +3,11 @@ use crate::fs_track::{self, FsTrack}; use crate::persistent_entities::{PersistentAlbum, PersistentArtist, PersistentTrack}; use anyhow::Result; use rusqlite::Connection; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter, State, Manager}; +use globwalk::glob; +use std::path::Path; +use std::collections::HashSet; +use rayon::prelude::*; pub fn initialize_library(conn: &mut Connection, app_handle: AppHandle) -> Result<()> { let init = db::get_init(conn)?; @@ -34,6 +38,260 @@ pub fn initialize_library(conn: &mut Connection, app_handle: AppHandle) -> Resul } } +pub fn incremental_scan( + conn: &mut Connection, + app_handle: AppHandle, + cancel_flag: std::sync::Arc +) -> Result<()> { + use serde::Serialize; + use std::time::Instant; + + #[derive(Clone, Serialize)] + #[serde(rename_all = "camelCase")] + struct ScanProgress { + files_changed: usize, + files_processed: usize, + } + + println!("Starting incremental library scan..."); + let now = Instant::now(); + + // Start a transaction for all database operations + let tx = conn.transaction()?; + + let directories = db::get_directories(&tx)?; + + // Get current database state + let db_tracks = db::get_track_metadata_map(&tx)?; + + // First pass: collect all file paths and categorize into new vs existing + #[derive(Debug)] + struct ExistingFile { + path: std::path::PathBuf, + id: i64, + db_mtime: Option, + } + + let mut disk_files = HashSet::new(); + let mut new_files = Vec::new(); + let mut existing_files = Vec::new(); + + for directory in &directories { + let globwalker = glob(format!( + "{}/**/*.{{mp3,m4a,flac,ogg,opus,wav,MP3,M4A,FLAC,OGG,OPUS,WAV}}", + directory + ))?; + + for item in globwalker { + // Check for cancellation periodically during file discovery + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + let entry = item?; + let path = entry.path().to_path_buf(); + let file_path_string = path.display().to_string(); + disk_files.insert(file_path_string.clone()); + + if let Some((id, db_file_mtime, _db_lrc_mtime, _db_txt_mtime)) = db_tracks.get(&file_path_string) { + // File exists in DB - will need to check if modified + existing_files.push(ExistingFile { + path, + id: *id, + db_mtime: *db_file_mtime, + }); + } else { + // New file - no stat needed, will process fully later + new_files.push((None, path)); + } + } + } + + // Check for cancellation after file discovery + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + println!("Found {} files on disk ({} new, {} existing), checking existing files for modifications...", + disk_files.len(), new_files.len(), existing_files.len()); + + // Second pass: check metadata in parallel ONLY for existing files (to detect modifications) + let modified_files: Vec<(Option, std::path::PathBuf)> = existing_files + .par_iter() + .filter_map(|file| { + // Check for cancellation during parallel processing + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + + // Get current mtime from disk + let disk_mtime = std::fs::metadata(&file.path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + + // Compare with DB mtime - only include if changed + if let Some(disk_mtime) = disk_mtime { + if file.db_mtime.map_or(true, |db_mt| db_mt != disk_mtime) { + return Some((Some(file.id), file.path.clone())); + } + } + None + }) + .collect(); + + // Check for cancellation after metadata collection + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + // Check for cancellation before deletion phase + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + // Find and remove files that are in DB but not on disk + let mut deleted_count = 0; + for (file_path, (id, _, _, _)) in &db_tracks { + if !disk_files.contains(file_path) { + println!("File removed from disk, removing from DB: {}", file_path); + db::remove_track_by_id(*id, &tx)?; + deleted_count += 1; + } + } + + // Combine all changed files for processing + let mut all_changed = Vec::new(); + all_changed.extend(new_files.clone()); + all_changed.extend(modified_files.clone()); + + let total_changed = all_changed.len(); + let potential_added = new_files.len(); + let potential_modified = modified_files.len(); + + println!("Found {} changed files (potential added: {}, potential modified: {}, deleted: {})", + total_changed, potential_added, potential_modified, deleted_count); + + // Track actual successes + let mut actual_added = 0; + let mut actual_modified = 0; + + if total_changed > 0 { + // Process files in parallel using rayon + let track_results: Vec<(Option, Result)> = all_changed + .par_iter() + .filter_map(|(maybe_id, path)| { + // Check for cancellation before starting expensive file read + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + + let result = fs_track::FsTrack::new_from_path(path); + Some((*maybe_id, result)) + }) + .collect(); + + // Check for cancellation after parallel file processing + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + // Write results to database sequentially and count successes + for (idx, (maybe_id, track_result)) in track_results.iter().enumerate() { + // Check for cancellation each iteration + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + match track_result { + Ok(track) => { + if let Some(id) = maybe_id { + // Update existing track by removing old entry + db::remove_track_by_id(*id, &tx)?; + actual_modified += 1; + } else { + // New track + actual_added += 1; + } + db::add_track(track, &tx)?; + + app_handle.emit("scan-progress", ScanProgress { + files_changed: total_changed, + files_processed: idx + 1, + }).unwrap(); + } + Err(error) => { + let path = &all_changed[idx].1; + eprintln!("Failed to read file during incremental scan: {}. Error: {}", + path.display(), error); + if let Some(id) = maybe_id { + db::remove_track_by_id(*id, &tx)?; + } + } + } + } + } + + // Check for cancellation before cleanup + if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) { + println!("Scan cancelled by user"); + drop(tx); + return Ok(()); + } + + // Clean up orphaned artists and albums if any tracks were deleted or modified + if deleted_count > 0 || total_changed > 0 { + db::clean_orphaned_entities(&tx)?; + } + + // Commit transaction - if we got here, scan was not cancelled + tx.commit()?; + + println!("==> Incremental scan took: {}ms", now.elapsed().as_millis()); + + // Queue notification for display after reload + use crate::state::{AppState, Notify, NotifyType}; + let app_state: State = app_handle.state(); + let mut notifications = app_state.queued_notifications.lock().unwrap(); + + let mut parts = Vec::new(); + if actual_added > 0 { + parts.push(format!("{} added", actual_added)); + } + if actual_modified > 0 { + parts.push(format!("{} modified", actual_modified)); + } + if deleted_count > 0 { + parts.push(format!("{} deleted", deleted_count)); + } + + let message = if parts.is_empty() { + "Library refreshed: no changes detected".to_string() + } else { + format!("Library refreshed: {}", parts.join(", ")) + }; + + notifications.push(Notify { + message, + notify_type: NotifyType::Success, + }); + + Ok(()) +} + pub fn uninitialize_library(conn: &Connection) -> Result<()> { db::clean_library(conn)?; db::set_init(false, conn)?; @@ -66,7 +324,89 @@ pub fn get_track_ids( } pub fn get_track(id: i64, conn: &Connection) -> Result { - db::get_track_by_id(id, conn) + let mut track = db::get_track_by_id(id, conn)?; + + // Check if lyrics files on disk are newer than cached version + // Clone the file path to avoid borrow checker issues when reassigning track + let file_path_string = track.file_path.clone(); + let file_path = Path::new(&file_path_string); + let parent_path = file_path.parent().unwrap(); + let file_stem = file_path.file_stem().unwrap().to_str().unwrap(); + + // Check .lrc file + let lrc_path = parent_path.join(format!("{}.lrc", file_stem)); + if let Ok(lrc_meta) = std::fs::metadata(&lrc_path) { + if let Ok(modified) = lrc_meta.modified() { + if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { + let disk_lrc_mtime = duration.as_secs() as i64; + + // Get current DB mtime + let query = "SELECT lrc_mtime FROM tracks WHERE id = ?"; + let db_lrc_mtime: Option = conn.prepare(query) + .and_then(|mut stmt| stmt.query_row([id], |row| row.get::<_, Option>(0))) + .ok() + .flatten(); + + // Update if: no mtime in DB, OR disk is newer, OR we don't have lyrics content + let needs_update = db_lrc_mtime.is_none() || + db_lrc_mtime.map_or(false, |db_mt| disk_lrc_mtime > db_mt) || + track.lrc_lyrics.is_none(); + + if needs_update { + if let Ok(lrc_content) = std::fs::read_to_string(&lrc_path) { + db::update_track_synced_lyrics(id, &lrc_content, "", disk_lrc_mtime, conn)?; + track = db::get_track_by_id(id, conn)?; + } + } + } + } + } else { + // .lrc file doesn't exist on disk - check if DB has it and remove + if track.lrc_lyrics.is_some() { + let txt_path = parent_path.join(format!("{}.txt", file_stem)); + if !txt_path.exists() { + db::update_track_null_lyrics(id, conn)?; + track = db::get_track_by_id(id, conn)?; + } + } + } + + // Check .txt file + let txt_path = parent_path.join(format!("{}.txt", file_stem)); + if let Ok(txt_meta) = std::fs::metadata(&txt_path) { + if let Ok(modified) = txt_meta.modified() { + if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) { + let disk_txt_mtime = duration.as_secs() as i64; + + // Get current DB mtime + let query = "SELECT txt_mtime FROM tracks WHERE id = ?"; + let db_txt_mtime: Option = conn.prepare(query) + .and_then(|mut stmt| stmt.query_row([id], |row| row.get::<_, Option>(0))) + .ok() + .flatten(); + + // Update if: no mtime in DB, OR disk is newer, OR we don't have lyrics content + let needs_update = db_txt_mtime.is_none() || + db_txt_mtime.map_or(false, |db_mt| disk_txt_mtime > db_mt) || + track.txt_lyrics.is_none(); + + if needs_update { + if let Ok(txt_content) = std::fs::read_to_string(&txt_path) { + db::update_track_plain_lyrics(id, &txt_content, disk_txt_mtime, conn)?; + track = db::get_track_by_id(id, conn)?; + } + } + } + } + } else { + // .txt file doesn't exist on disk - check if DB has it and remove + if track.txt_lyrics.is_some() && track.lrc_lyrics.is_none() { + db::update_track_null_lyrics(id, conn)?; + track = db::get_track_by_id(id, conn)?; + } + } + + Ok(track) } pub fn get_albums(conn: &Connection) -> Result> { @@ -112,3 +452,311 @@ pub fn get_artist_track_ids(artist_id: i64, without_plain_lyrics: bool, without_ pub fn get_init(conn: &Connection) -> Result { db::get_init(conn) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::TempDir; + + fn create_test_audio_file(path: &Path, title: &str, artist: &str, album: &str) -> Result<()> { + use lofty::file::{TaggedFileExt, AudioFile}; + use lofty::tag::{Accessor, Tag, TagType}; + use lofty::config::WriteOptions; + + // Copy the minimal MP3 file from test assets + let minimal_mp3 = include_bytes!("../tests/assets/minimal.mp3"); + fs::write(path, minimal_mp3)?; + + // Open and modify the tags + let mut tagged_file = lofty::read_from_path(path)?; + + // Create or get ID3v2 tag + let tag = match tagged_file.primary_tag_mut() { + Some(primary_tag) => primary_tag, + None => { + tagged_file.insert_tag(Tag::new(TagType::Id3v2)); + tagged_file.primary_tag_mut().unwrap() + } + }; + + // Set the metadata + tag.set_title(title.to_string()); + tag.set_artist(artist.to_string()); + tag.set_album(album.to_string()); + + // Write back to file + tagged_file.save_to_path(path, WriteOptions::default())?; + + Ok(()) + } + + fn setup_test_db() -> Result<(Connection, TempDir)> { + let temp_dir = TempDir::new()?; + let db_path = temp_dir.path().join("test.db"); + let mut conn = Connection::open(&db_path)?; + + // Initialize database schema + db::upgrade_database_if_needed(&mut conn, 0)?; + + Ok((conn, temp_dir)) + } + + #[test] + fn test_mtime_tracking_in_database() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + let music_path = temp_music.path().to_str().unwrap().to_string(); + + db::set_directories(vec![music_path.clone()], &conn)?; + + // Create file + let file_path = temp_music.path().join("song.mp3"); + create_test_audio_file(&file_path, "Test", "Artist", "Album")?; + + // Create a track directly using FsTrack + let track = fs_track::FsTrack::new_from_path(&file_path)?; + + // Verify mtime was captured + assert!(track.file_mtime().is_some()); + + // Add to database + db::add_track(&track, &conn)?; + + let tracks = db::get_tracks(&conn)?; + assert_eq!(tracks.len(), 1); + + // Check that mtime was stored in database + let metadata_map = db::get_track_metadata_map(&conn)?; + let file_path_str = file_path.to_str().unwrap(); + + assert!(metadata_map.contains_key(file_path_str)); + let (_id, file_mtime, _lrc_mtime, _txt_mtime) = metadata_map.get(file_path_str).unwrap(); + assert!(file_mtime.is_some()); + assert_eq!(*file_mtime, track.file_mtime()); + + Ok(()) + } + + #[test] + fn test_lyrics_mtime_tracking() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + + // Create audio file + let file_path = temp_music.path().join("song.mp3"); + create_test_audio_file(&file_path, "Test", "Artist", "Album")?; + + // Create lyrics file + let lrc_path = temp_music.path().join("song.lrc"); + let mut lrc_file = File::create(&lrc_path)?; + writeln!(lrc_file, "[00:00.00]Test lyrics")?; + drop(lrc_file); + + // Create track + let track = fs_track::FsTrack::new_from_path(&file_path)?; + + // Verify both file and lyrics mtimes were captured + assert!(track.file_mtime().is_some()); + assert!(track.lrc_mtime().is_some()); + assert!(track.lrc_lyrics().is_some()); + + // Add to database + db::add_track(&track, &conn)?; + + // Verify mtimes stored correctly + let metadata_map = db::get_track_metadata_map(&conn)?; + let file_path_str = file_path.to_str().unwrap(); + let (_id, _file_mtime, lrc_mtime, _txt_mtime) = metadata_map.get(file_path_str).unwrap(); + + assert!(lrc_mtime.is_some()); + assert_eq!(*lrc_mtime, track.lrc_mtime()); + + Ok(()) + } + + #[test] + fn test_lyrics_freshness_check() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + + // Create audio file + let file_path = temp_music.path().join("song.mp3"); + create_test_audio_file(&file_path, "Test Song", "Test Artist", "Test Album")?; + + // Create initial lyrics + let lrc_path = temp_music.path().join("song.lrc"); + let mut lrc_file = File::create(&lrc_path)?; + writeln!(lrc_file, "[00:00.00]Old lyrics")?; + drop(lrc_file); + + // Add track to database + let track = fs_track::FsTrack::new_from_path(&file_path)?; + db::add_track(&track, &conn)?; + + let tracks = db::get_tracks(&conn)?; + let track_id = tracks[0].id; + + // Get track - should have old lyrics + let track = get_track(track_id, &conn)?; + assert!(track.lrc_lyrics.as_ref().unwrap().contains("Old lyrics")); + + // Wait to ensure mtime changes (filesystem has 1-second resolution on many systems) + std::thread::sleep(std::time::Duration::from_millis(1100)); + + // Update lyrics on disk + let mut lrc_file = File::create(&lrc_path)?; + writeln!(lrc_file, "[00:00.00]New lyrics")?; + drop(lrc_file); + + // Get track again - should auto-detect and serve fresh lyrics + let track = get_track(track_id, &conn)?; + assert!(track.lrc_lyrics.as_ref().unwrap().contains("New lyrics")); + + Ok(()) + } + + #[test] + fn test_lyrics_deletion_removes_from_db() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + + // Create audio file with lyrics + let file_path = temp_music.path().join("song.mp3"); + create_test_audio_file(&file_path, "Test", "Artist", "Album")?; + + let lrc_path = temp_music.path().join("song.lrc"); + let mut lrc_file = File::create(&lrc_path)?; + writeln!(lrc_file, "[00:00.00]Test lyrics")?; + drop(lrc_file); + + // Add to database + let track = fs_track::FsTrack::new_from_path(&file_path)?; + db::add_track(&track, &conn)?; + + let tracks = db::get_tracks(&conn)?; + let track_id = tracks[0].id; + + // Verify lyrics exist + let track = get_track(track_id, &conn)?; + assert!(track.lrc_lyrics.is_some()); + + // Delete lyrics file from disk + fs::remove_file(&lrc_path)?; + + // Get track again - should detect deletion and clear from DB + let track = get_track(track_id, &conn)?; + assert!(track.lrc_lyrics.is_none()); + + Ok(()) + } + + #[test] + fn test_file_removal_detection() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + + // Create two files + let file1_path = temp_music.path().join("song1.mp3"); + let file2_path = temp_music.path().join("song2.mp3"); + create_test_audio_file(&file1_path, "Song 1", "Artist", "Album")?; + create_test_audio_file(&file2_path, "Song 2", "Artist", "Album")?; + + // Add both to database + let track1 = fs_track::FsTrack::new_from_path(&file1_path)?; + let track2 = fs_track::FsTrack::new_from_path(&file2_path)?; + db::add_track(&track1, &conn)?; + db::add_track(&track2, &conn)?; + + let tracks = db::get_tracks(&conn)?; + assert_eq!(tracks.len(), 2); + + // Get track ID before deletion + let track1_id = tracks.iter().find(|t| t.title == "Song 1").unwrap().id; + + // Delete file1 from disk + fs::remove_file(&file1_path)?; + + // Manually check the detection logic (without full scan) + let metadata_map = db::get_track_metadata_map(&conn)?; + let file1_str = file1_path.to_str().unwrap(); + + // Verify file1 is in DB + assert!(metadata_map.contains_key(file1_str)); + + // Verify file1 doesn't exist on disk + assert!(!file1_path.exists()); + + // Remove it from DB as incremental_scan would + db::remove_track_by_id(track1_id, &conn)?; + + // Verify removal + let tracks = db::get_tracks(&conn)?; + assert_eq!(tracks.len(), 1); + assert_eq!(tracks[0].title, "Song 2"); + + Ok(()) + } + + #[test] + fn test_orphaned_artists_albums_cleaned_up() -> Result<()> { + let (conn, _temp_db) = setup_test_db()?; + let temp_music = TempDir::new()?; + + // Create files from different artists and albums + let file1_path = temp_music.path().join("artist1_song.mp3"); + let file2_path = temp_music.path().join("artist2_song.mp3"); + create_test_audio_file(&file1_path, "Song 1", "Artist 1", "Album 1")?; + create_test_audio_file(&file2_path, "Song 2", "Artist 2", "Album 2")?; + + // Add to database + let track1 = fs_track::FsTrack::new_from_path(&file1_path)?; + let track2 = fs_track::FsTrack::new_from_path(&file2_path)?; + db::add_track(&track1, &conn)?; + db::add_track(&track2, &conn)?; + + // Verify we have 2 artists and 2 albums + let artists_before = db::get_artists(&conn)?; + let albums_before = db::get_albums(&conn)?; + assert_eq!(artists_before.len(), 2, "Should have 2 artists initially"); + assert_eq!(albums_before.len(), 2, "Should have 2 albums initially"); + + // Verify artist/album IDs include both + let artist_ids_before = db::get_artist_ids(&conn)?; + let album_ids_before = db::get_album_ids(&conn)?; + assert_eq!(artist_ids_before.len(), 2, "Should have 2 artist IDs initially"); + assert_eq!(album_ids_before.len(), 2, "Should have 2 album IDs initially"); + + // Remove track from Artist 1 + let tracks = db::get_tracks(&conn)?; + let track1_id = tracks.iter().find(|t| t.title == "Song 1").unwrap().id; + db::remove_track_by_id(track1_id, &conn)?; + + // Before cleanup, orphaned entities still exist in raw tables + // (get_artists/get_albums use JOIN so they won't show, but get_artist_ids doesn't) + let artist_ids_before_cleanup = db::get_artist_ids(&conn)?; + let album_ids_before_cleanup = db::get_album_ids(&conn)?; + assert_eq!(artist_ids_before_cleanup.len(), 2, "Orphans still in database before cleanup"); + assert_eq!(album_ids_before_cleanup.len(), 2, "Orphans still in database before cleanup"); + + // Clean up orphaned entities + db::clean_orphaned_entities(&conn)?; + + // Verify Artist 1 and Album 1 are completely removed + let artists_after = db::get_artists(&conn)?; + let albums_after = db::get_albums(&conn)?; + assert_eq!(artists_after.len(), 1, "Should have 1 artist after cleanup"); + assert_eq!(albums_after.len(), 1, "Should have 1 album after cleanup"); + assert_eq!(artists_after[0].name, "Artist 2"); + assert_eq!(albums_after[0].name, "Album 2"); + + // Verify Artist 1 and Album 1 don't appear in ID lists either + let artist_ids_after = db::get_artist_ids(&conn)?; + let album_ids_after = db::get_album_ids(&conn)?; + assert_eq!(artist_ids_after.len(), 1, "Should have 1 artist ID after cleanup"); + assert_eq!(album_ids_after.len(), 1, "Should have 1 album ID after cleanup"); + + Ok(()) + } +} diff --git a/src-tauri/src/lyrics.rs b/src-tauri/src/lyrics.rs index a7d6c82..643a652 100644 --- a/src-tauri/src/lyrics.rs +++ b/src-tauri/src/lyrics.rs @@ -30,7 +30,7 @@ pub async fn download_lyrics_for_track( track: PersistentTrack, is_try_embed_lyrics: bool, lrclib_instance: &str, -) -> Result { +) -> Result<(Response, i64, i64)> { let lyrics = request( &track.title, &track.album_name, @@ -48,69 +48,89 @@ pub async fn apply_string_lyrics_for_track( plain_lyrics: &str, synced_lyrics: &str, is_try_embed_lyrics: bool, -) -> Result<()> { - save_plain_lyrics(&track.file_path, plain_lyrics)?; - save_synced_lyrics(&track.file_path, synced_lyrics)?; +) -> Result<(i64, i64)> { + let txt_mtime = save_plain_lyrics(&track.file_path, plain_lyrics)?; + let lrc_mtime = save_synced_lyrics(&track.file_path, synced_lyrics)?; if is_try_embed_lyrics { embed_lyrics(&track.file_path, &plain_lyrics, &synced_lyrics); } - Ok(()) + Ok((lrc_mtime, txt_mtime)) } pub async fn apply_lyrics_for_track( track: PersistentTrack, lyrics: Response, is_try_embed_lyrics: bool, -) -> Result { +) -> Result<(Response, i64, i64)> { match &lyrics { Response::SyncedLyrics(synced_lyrics, plain_lyrics) => { - save_synced_lyrics(&track.file_path, &synced_lyrics)?; + let lrc_mtime = save_synced_lyrics(&track.file_path, &synced_lyrics)?; if is_try_embed_lyrics { embed_lyrics(&track.file_path, &plain_lyrics, &synced_lyrics); } - Ok(lyrics) + Ok((lyrics, lrc_mtime, 0)) } Response::UnsyncedLyrics(plain_lyrics) => { - save_plain_lyrics(&track.file_path, &plain_lyrics)?; + let txt_mtime = save_plain_lyrics(&track.file_path, &plain_lyrics)?; if is_try_embed_lyrics { embed_lyrics(&track.file_path, &plain_lyrics, ""); } - Ok(lyrics) + Ok((lyrics, 0, txt_mtime)) } Response::IsInstrumental => { save_instrumental(&track.file_path)?; - Ok(lyrics) + Ok((lyrics, 0, 0)) } - _ => Ok(lyrics), + _ => Ok((lyrics, 0, 0)), } } -fn save_plain_lyrics(track_path: &str, lyrics: &str) -> Result<()> { +fn save_plain_lyrics(track_path: &str, lyrics: &str) -> Result { let txt_path = build_txt_path(track_path)?; let lrc_path = build_lrc_path(track_path)?; let _ = remove_file(lrc_path); if lyrics.is_empty() { - let _ = remove_file(txt_path); + let _ = remove_file(&txt_path); + Ok(0) } else { - write(txt_path, lyrics)?; + write(&txt_path, lyrics)?; + + // Get mtime of the file we just wrote + let mtime = std::fs::metadata(&txt_path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + Ok(mtime) } - Ok(()) } -fn save_synced_lyrics(track_path: &str, lyrics: &str) -> Result<()> { +fn save_synced_lyrics(track_path: &str, lyrics: &str) -> Result { let txt_path = build_txt_path(track_path)?; let lrc_path = build_lrc_path(track_path)?; if lyrics.is_empty() { - let _ = remove_file(lrc_path); + let _ = remove_file(&lrc_path); + Ok(0) } else { let _ = remove_file(txt_path); - write(lrc_path, lyrics)?; + write(&lrc_path, lyrics)?; + + // Get mtime of the file we just wrote + let mtime = std::fs::metadata(&lrc_path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + Ok(mtime) } - Ok(()) } fn save_instrumental(track_path: &str) -> Result<()> { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9b87ff0..a50284e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -134,6 +134,26 @@ async fn refresh_library( app_state: State<'_, AppState>, app_handle: AppHandle, ) -> Result<(), String> { + // Reset cancel flag before starting + app_state.scan_cancel_flag.store(false, std::sync::atomic::Ordering::Relaxed); + + let mut conn_guard = app_state.db.lock().unwrap(); + let conn = conn_guard.as_mut().unwrap(); + + library::incremental_scan(conn, app_handle, app_state.scan_cancel_flag.clone()) + .map_err(|err| err.to_string())?; + + Ok(()) +} + +#[tauri::command] +async fn rebuild_library( + app_state: State<'_, AppState>, + app_handle: AppHandle, +) -> Result<(), String> { + // Reset cancel flag before starting + app_state.scan_cancel_flag.store(false, std::sync::atomic::Ordering::Relaxed); + let mut conn_guard = app_state.db.lock().unwrap(); let conn = conn_guard.as_mut().unwrap(); @@ -310,7 +330,7 @@ async fn download_lyrics(track_id: i64, app_handle: AppHandle) -> Result Result { app_handle .db(|db: &Connection| { - db::update_track_synced_lyrics(track_id, &synced_lyrics, &plain_lyrics, db) + db::update_track_synced_lyrics(track_id, &synced_lyrics, &plain_lyrics, lrc_mtime, db) }) .map_err(|err| err.to_string())?; app_handle.emit("reload-track-id", track_id).unwrap(); @@ -326,7 +346,7 @@ async fn download_lyrics(track_id: i64, app_handle: AppHandle) -> Result { app_handle - .db(|db: &Connection| db::update_track_plain_lyrics(track_id, &plain_lyrics, db)) + .db(|db: &Connection| db::update_track_plain_lyrics(track_id, &plain_lyrics, txt_mtime, db)) .map_err(|err| err.to_string())?; app_handle.emit("reload-track-id", track_id).unwrap(); Ok("Plain lyrics downloaded".to_owned()) @@ -356,7 +376,7 @@ async fn apply_lyrics( .try_embed_lyrics; let lyrics = lrclib::get::Response::from_raw_response(lrclib_response); - let lyrics = lyrics::apply_lyrics_for_track(track, lyrics, is_try_embed_lyrics) + let (lyrics, lrc_mtime, txt_mtime) = lyrics::apply_lyrics_for_track(track, lyrics, is_try_embed_lyrics) .await .map_err(|err| err.to_string())?; @@ -364,7 +384,7 @@ async fn apply_lyrics( lrclib::get::Response::SyncedLyrics(synced_lyrics, plain_lyrics) => { app_handle .db(|db: &Connection| { - db::update_track_synced_lyrics(track_id, &synced_lyrics, &plain_lyrics, db) + db::update_track_synced_lyrics(track_id, &synced_lyrics, &plain_lyrics, lrc_mtime, db) }) .map_err(|err| err.to_string())?; std::thread::spawn(move || { @@ -374,7 +394,7 @@ async fn apply_lyrics( } lrclib::get::Response::UnsyncedLyrics(plain_lyrics) => { app_handle - .db(|db: &Connection| db::update_track_plain_lyrics(track_id, &plain_lyrics, db)) + .db(|db: &Connection| db::update_track_plain_lyrics(track_id, &plain_lyrics, txt_mtime, db)) .map_err(|err| err.to_string())?; std::thread::spawn(move || { app_handle.emit("reload-track-id", track_id).unwrap(); @@ -475,7 +495,7 @@ async fn save_lyrics( let re = Regex::new(r"\[au:\s*instrumental\]").expect("Invalid regex"); let is_instrumental = re.is_match(&synced_lyrics); - lyrics::apply_string_lyrics_for_track( + let (lrc_mtime, txt_mtime) = lyrics::apply_string_lyrics_for_track( &track, &plain_lyrics, &synced_lyrics, @@ -483,20 +503,20 @@ async fn save_lyrics( ) .await .map_err(|err| err.to_string())?; - + if is_instrumental { app_handle .db(|db: &Connection| db::update_track_instrumental(track.id, db)) .map_err(|err| err.to_string())?; } else if !synced_lyrics.is_empty() { app_handle - .db(|db: &Connection| { - db::update_track_synced_lyrics(track.id, &synced_lyrics, &plain_lyrics, db) + .db(move |db: &Connection| { + db::update_track_synced_lyrics(track.id, &synced_lyrics, &plain_lyrics, lrc_mtime, db) }) .map_err(|err| err.to_string())?; } else if !plain_lyrics.is_empty() { app_handle - .db(|db: &Connection| db::update_track_plain_lyrics(track.id, &plain_lyrics, db)) + .db(move |db: &Connection| db::update_track_plain_lyrics(track.id, &plain_lyrics, txt_mtime, db)) .map_err(|err| err.to_string())?; } else { app_handle @@ -707,6 +727,12 @@ fn drain_notifications(app_state: tauri::State) -> Vec { notifications } +#[tauri::command] +fn cancel_scan(app_state: tauri::State) -> Result<(), String> { + app_state.scan_cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); + Ok(()) +} + #[tokio::main] async fn main() { tauri::Builder::default() @@ -718,6 +744,7 @@ async fn main() { db: Default::default(), player: Default::default(), queued_notifications: std::sync::Mutex::new(Vec::new()), + scan_cancel_flag: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }) .setup(|app| { let handle = app.handle(); @@ -781,6 +808,7 @@ async fn main() { initialize_library, uninitialize_library, refresh_library, + rebuild_library, get_tracks, get_track_ids, get_track, @@ -810,6 +838,7 @@ async fn main() { set_volume, open_devtools, drain_notifications, + cancel_scan, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 42c4a62..c68154d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -30,6 +30,7 @@ pub struct AppState { pub db: std::sync::Mutex>, pub player: std::sync::Mutex>, pub queued_notifications: std::sync::Mutex>, + pub scan_cancel_flag: std::sync::Arc, } pub trait ServiceAccess { diff --git a/src-tauri/tests/assets/minimal.mp3 b/src-tauri/tests/assets/minimal.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..79792687af0e8ec880e97026c18cca637f25ba05 GIT binary patch literal 11744 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 L|NVdV>E-_b5m<`+ literal 0 HcmV?d00001 diff --git a/src/components/Library.vue b/src/components/Library.vue index 52da95c..f4f8cdd 100644 --- a/src/components/Library.vue +++ b/src/components/Library.vue @@ -1,5 +1,5 @@