1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use rayon::prelude::*;
6
7use crate::db::connection::Database;
8use crate::db::queries::{self, TrackMeta};
9
10use super::features;
11use super::metadata::{self, is_audio_file};
12
13#[derive(Debug, Default)]
15pub struct ScanResult {
16 pub added: usize,
17 pub updated: usize,
18 pub removed: usize,
19 pub skipped: usize,
20 pub unreadable: usize,
23 pub removed_paths: Vec<String>,
26 pub errors: Vec<(PathBuf, String)>,
27}
28
29#[derive(Debug, Clone, Copy, Default)]
31pub struct ScanOptions {
32 pub force: bool,
34 pub force_remove: bool,
39}
40
41#[cfg(not(test))]
45const CHUNK_SIZE: usize = 1000;
46#[cfg(test)]
47const CHUNK_SIZE: usize = 4;
48
49pub struct ScanEvent<'a> {
51 pub artist: &'a str,
52 pub album: &'a str,
53 pub title: &'a str,
54 pub path: &'a Path,
55 pub is_new: bool,
56}
57
58pub fn scan_folder(
61 db: &Database,
62 path: &Path,
63 opts: ScanOptions,
64 on_track: Option<&dyn Fn(ScanEvent)>,
65) -> ScanResult {
66 let mut result = ScanResult::default();
67
68 let mut audio_files: Vec<PathBuf> = Vec::new();
71 for entry in walkdir::WalkDir::new(path).follow_links(true) {
72 match entry {
73 Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
74 audio_files.push(e.path().to_path_buf())
75 }
76 Ok(_) => {}
77 Err(e) => {
78 result.unreadable += 1;
79 log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
80 }
81 }
82 }
83
84 let total_files = audio_files.len();
85 log::info!("found {} audio files in {}", total_files, path.display());
86
87 let files_to_scan: Vec<PathBuf> = if opts.force {
91 std::mem::take(&mut audio_files)
92 } else {
93 let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
94 audio_files
95 .iter()
96 .filter(|file_path| {
97 let Ok(file_meta) = std::fs::metadata(file_path) else {
98 return true;
99 };
100 let mtime = file_meta
101 .modified()
102 .ok()
103 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
104 .map(|d| d.as_secs() as i64)
105 .unwrap_or(0);
106 let size = file_meta.len() as i64;
107 let path_str = file_path.to_string_lossy();
108 match scan_cache.get(path_str.as_ref()) {
109 Some(&(cached_mtime, cached_size)) => {
110 mtime != cached_mtime || size != cached_size
111 }
112 None => true,
113 }
114 })
115 .cloned()
116 .collect()
117 };
118
119 result.skipped = total_files - files_to_scan.len();
120
121 for chunk in files_to_scan.chunks(CHUNK_SIZE) {
125 let metadata_results: Vec<(PathBuf, Result<TrackMeta, String>)> = chunk
127 .par_iter()
128 .map(|file_path| {
129 (
130 file_path.clone(),
131 isolate_read(file_path, metadata::read_metadata),
132 )
133 })
134 .collect();
135
136 let tx = match db.conn.unchecked_transaction() {
137 Ok(tx) => tx,
138 Err(e) => {
139 log::error!("failed to begin scan transaction: {}", e);
140 result
141 .errors
142 .push((path.to_path_buf(), format!("db error: {}", e)));
143 return result;
144 }
145 };
146
147 let (mut added, mut updated) = (0usize, 0usize);
148 for (file_path, meta_result) in metadata_results {
149 match meta_result {
150 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
151 Ok((track_id, is_new)) => {
152 if is_new {
153 added += 1;
154 } else {
155 updated += 1;
156 }
157 if let Some(cb) = &on_track {
158 cb(ScanEvent {
159 artist: &meta.artist,
160 album: &meta.album,
161 title: &meta.title,
162 path: &file_path,
163 is_new,
164 });
165 }
166 if let Err(e) = queries::update_scan_cache(
167 &tx,
168 meta.path.as_deref().unwrap_or(""),
169 meta.mtime.unwrap_or(0),
170 meta.size_bytes.unwrap_or(0),
171 track_id,
172 ) {
173 log::warn!("failed to cache {}: {}", file_path.display(), e);
175 }
176 }
177 Err(e) => {
178 result.errors.push((file_path, format!("db error: {}", e)));
179 }
180 },
181 Err(e) => {
182 result.errors.push((file_path, e));
183 }
184 }
185 }
186
187 match tx.commit() {
188 Ok(()) => {
189 result.added += added;
190 result.updated += updated;
191 }
192 Err(e) => {
193 log::error!("failed to commit scan transaction: {}", e);
194 result
195 .errors
196 .push((path.to_path_buf(), format!("db error: {}", e)));
197 }
198 }
199 }
200
201 if total_files == 0 {
205 log::error!(
206 "{} contains no audio files — skipping stale-track removal. \
207 If this folder should have music in it, it is probably not mounted or not readable.",
208 path.display()
209 );
210 return result;
211 }
212
213 let tx = match db.conn.unchecked_transaction() {
214 Ok(tx) => tx,
215 Err(e) => {
216 log::error!("failed to begin stale-removal transaction: {}", e);
217 result
218 .errors
219 .push((path.to_path_buf(), format!("db error: {}", e)));
220 return result;
221 }
222 };
223 match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
224 Ok(removed) => {
225 result.removed = removed.len();
226 result.removed_paths = removed;
227 if let Err(e) = tx.commit() {
228 log::error!("failed to commit stale removals: {}", e);
229 result.removed = 0;
230 result.removed_paths.clear();
231 result
232 .errors
233 .push((path.to_path_buf(), format!("db error: {}", e)));
234 }
235 }
236 Err(e) => {
237 log::error!("failed to remove stale tracks: {}", e);
238 result.errors.push((path.to_path_buf(), e.to_string()));
239 }
240 }
241
242 result
243}
244
245fn isolate_read(
250 path: &Path,
251 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
252) -> Result<TrackMeta, String> {
253 match catch_unwind(AssertUnwindSafe(|| read(path))) {
254 Ok(result) => result.map_err(|e| e.to_string()),
255 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
256 }
257}
258
259pub fn full_scan(
261 db: &Database,
262 folders: &[PathBuf],
263 opts: ScanOptions,
264 on_track: Option<&dyn Fn(ScanEvent)>,
265) -> ScanResult {
266 let mut total = ScanResult::default();
267 for folder in folders {
268 if !folder.exists() {
269 log::warn!("library folder does not exist: {}", folder.display());
270 continue;
271 }
272 let r = scan_folder(db, folder, opts, on_track);
273 total.added += r.added;
274 total.updated += r.updated;
275 total.removed += r.removed;
276 total.skipped += r.skipped;
277 total.unreadable += r.unreadable;
278 total.removed_paths.extend(r.removed_paths);
279 total.errors.extend(r.errors);
280 }
281 total
282}
283
284pub struct AnalysisEvent<'a> {
286 pub path: &'a str,
287 pub success: bool,
288 pub current: usize,
289 pub total: usize,
290}
291
292pub fn analyze_missing(
295 db: &Database,
296 on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
297) -> (usize, usize) {
298 let missing = match queries::tracks_missing_vectors(&db.conn) {
299 Ok(m) => m,
300 Err(e) => {
301 log::error!("failed to query missing vectors: {}", e);
302 return (0, 0);
303 }
304 };
305
306 if missing.is_empty() {
307 return (0, 0);
308 }
309
310 let total = missing.len();
311 log::info!("analyzing {} tracks for acoustic features", total);
312
313 let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
315 .par_iter()
316 .enumerate()
317 .map(|(i, (track_id, path))| {
318 let result = match catch_unwind(AssertUnwindSafe(|| {
319 features::analyze_track(Path::new(path))
320 })) {
321 Ok(r) => r,
322 Err(_) => Err(features::AnalysisError::Bliss(format!(
323 "panicked while analyzing {}",
324 path
325 ))),
326 };
327 if let Some(cb) = &on_track {
328 cb(AnalysisEvent {
329 path,
330 success: result.is_ok(),
331 current: i + 1,
332 total,
333 });
334 }
335 (*track_id, path.clone(), result)
336 })
337 .collect();
338
339 let mut analyzed = 0usize;
341 let mut errors = 0usize;
342 let tx = match db.conn.unchecked_transaction() {
343 Ok(tx) => tx,
344 Err(e) => {
345 log::error!("failed to begin analysis transaction: {}", e);
346 return (0, 0);
347 }
348 };
349 for (track_id, path, result) in results {
350 match result {
351 Ok(embedding) => {
352 if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
353 log::warn!("failed to store vector for {}: {}", path, e);
354 errors += 1;
355 } else {
356 analyzed += 1;
357 }
358 }
359 Err(e) => {
360 log::warn!("analysis failed for {}: {}", path, e);
361 errors += 1;
362 }
363 }
364 }
365 if let Err(e) = tx.commit() {
366 log::error!("failed to commit analysis transaction: {}", e);
367 }
368
369 log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
370 (analyzed, errors)
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::db::connection::Database;
377 use crate::db::queries;
378 use crate::test_utils;
379
380 fn test_db(dir: &Path) -> Database {
381 let db_path = dir.join("test.db");
382 Database::open(&db_path).unwrap()
383 }
384
385 #[test]
386 fn scan_folder_indexes_new_files() {
387 let dir = tempfile::tempdir().unwrap();
388 let music_dir = dir.path().join("music");
389 std::fs::create_dir_all(&music_dir).unwrap();
390
391 let wav_path = music_dir.join("silence.wav");
393 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
394
395 let db = test_db(dir.path());
396 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
397
398 assert_eq!(result.added, 1, "expected 1 track added");
399 assert_eq!(result.skipped, 0);
400 assert_eq!(result.removed, 0);
401 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
402
403 let stats = queries::library_stats(&db.conn).unwrap();
405 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
406 }
407
408 #[test]
409 fn scan_folder_skips_unchanged_files() {
410 let dir = tempfile::tempdir().unwrap();
411 let music_dir = dir.path().join("music");
412 std::fs::create_dir_all(&music_dir).unwrap();
413
414 let wav_path = music_dir.join("unchanged.wav");
415 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
416
417 let db = test_db(dir.path());
418
419 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
421 assert_eq!(r1.added, 1);
422
423 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
425 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
426 assert_eq!(r2.added, 0, "no new files should be added");
427 }
428
429 #[test]
430 fn scan_folder_removes_deleted_tracks() {
431 let dir = tempfile::tempdir().unwrap();
432 let music_dir = dir.path().join("music");
433 std::fs::create_dir_all(&music_dir).unwrap();
434
435 let wav_path = music_dir.join("ephemeral.wav");
436 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
437 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
438
439 let db = test_db(dir.path());
440
441 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
443 assert_eq!(r1.added, 2);
444
445 std::fs::remove_file(&wav_path).unwrap();
447
448 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
450 assert_eq!(
451 r2.removed, 1,
452 "expected 1 track removed after file deletion"
453 );
454
455 let stats = queries::library_stats(&db.conn).unwrap();
456 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
457 }
458
459 #[test]
460 fn empty_folder_does_not_wipe_the_library() {
461 let dir = tempfile::tempdir().unwrap();
462 let music_dir = dir.path().join("music");
463 std::fs::create_dir_all(&music_dir).unwrap();
464 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
465 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
466
467 let db = test_db(dir.path());
468 assert_eq!(
469 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
470 2
471 );
472
473 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
476 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
477
478 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
479 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
480 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
481 }
482
483 #[cfg(unix)]
484 #[test]
485 fn unreadable_folder_is_not_a_deletion() {
486 use std::os::unix::fs::PermissionsExt;
487
488 let dir = tempfile::tempdir().unwrap();
489 let music_dir = dir.path().join("music");
490 let locked_dir = music_dir.join("locked");
491 std::fs::create_dir_all(&locked_dir).unwrap();
492 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
493 let locked_file = locked_dir.join("locked.wav");
494 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
495
496 let db = test_db(dir.path());
497 assert_eq!(
498 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
499 2
500 );
501
502 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
503 if locked_file.try_exists().is_ok() {
504 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
506 return;
507 }
508
509 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
510 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
511
512 assert!(
513 r.unreadable >= 1,
514 "the unreadable subtree should be counted"
515 );
516 assert_eq!(r.removed, 0, "an IO error is not a deletion");
517 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
518 }
519
520 #[test]
521 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
522 let dir = tempfile::tempdir().unwrap();
523 let music_dir = dir.path().join("music");
524 std::fs::create_dir_all(&music_dir).unwrap();
525 for i in 0..6 {
526 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
527 }
528
529 let db = test_db(dir.path());
530
531 let seen = std::cell::Cell::new(0usize);
533 let abort = |_: ScanEvent| {
534 seen.set(seen.get() + 1);
535 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
536 };
537 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
538 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
539 }));
540 assert!(panicked.is_err());
541
542 assert_eq!(
544 queries::library_stats(&db.conn).unwrap().total_tracks,
545 CHUNK_SIZE as i64
546 );
547
548 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
550 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
551 assert_eq!(r.added, 6 - CHUNK_SIZE);
552 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
553 }
554
555 #[test]
556 fn failing_file_is_named_and_the_scan_continues() {
557 let dir = tempfile::tempdir().unwrap();
558 let music_dir = dir.path().join("music");
559 std::fs::create_dir_all(&music_dir).unwrap();
560 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
561 let broken = music_dir.join("broken.flac");
562 std::fs::write(&broken, b"").unwrap();
563
564 let db = test_db(dir.path());
565 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
566
567 assert_eq!(r.added, 1, "the good file must still be indexed");
568 assert_eq!(r.errors.len(), 1);
569 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
570 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
571 }
572
573 #[test]
574 fn a_panicking_tag_read_becomes_an_error() {
575 let path = Path::new("/music/hostile.mp3");
576 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
577 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
578 }
579
580 #[test]
581 fn scan_folder_updates_modified_files() {
582 let dir = tempfile::tempdir().unwrap();
583 let music_dir = dir.path().join("music");
584 std::fs::create_dir_all(&music_dir).unwrap();
585
586 let wav_path = music_dir.join("modified.wav");
587 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
588
589 let db = test_db(dir.path());
590
591 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
593 assert_eq!(r1.added, 1);
594
595 std::thread::sleep(std::time::Duration::from_millis(1100));
598 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
599
600 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
602 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
603 assert_eq!(r2.added, 0, "the row already exists");
604 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
605 }
606}