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 let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
133 CHUNK_SIZE.saturating_mul(2),
134 );
135 let reader = std::thread::Builder::new()
136 .name("koan-scan-read".into())
137 .spawn(move || {
138 files_to_scan.par_iter().for_each(|file_path| {
139 let _ = send.send((
141 file_path.clone(),
142 isolate_read(file_path, metadata::read_metadata),
143 ));
144 });
145 });
146 if let Err(e) = &reader {
147 log::error!("failed to spawn scan reader: {}", e);
148 result
149 .errors
150 .push((path.to_path_buf(), format!("scan error: {}", e)));
151 return result;
152 }
153
154 loop {
155 let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
157 recv.iter().take(CHUNK_SIZE).collect();
158 if batch.is_empty() {
159 break;
160 }
161
162 let tx = match db.conn.unchecked_transaction() {
163 Ok(tx) => tx,
164 Err(e) => {
165 log::error!("failed to begin scan transaction: {}", e);
166 result
167 .errors
168 .push((path.to_path_buf(), format!("db error: {}", e)));
169 return result;
170 }
171 };
172
173 let (mut added, mut updated) = (0usize, 0usize);
174 for (file_path, meta_result) in batch {
175 match meta_result {
176 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
177 Ok((track_id, is_new)) => {
178 if is_new {
179 added += 1;
180 } else {
181 updated += 1;
182 }
183 if let Some(cb) = &on_track {
184 cb(ScanEvent {
185 artist: &meta.artist,
186 album: &meta.album,
187 title: &meta.title,
188 path: &file_path,
189 is_new,
190 });
191 }
192 if let Err(e) = queries::update_scan_cache(
193 &tx,
194 meta.path.as_deref().unwrap_or(""),
195 meta.mtime.unwrap_or(0),
196 meta.size_bytes.unwrap_or(0),
197 track_id,
198 ) {
199 log::warn!("failed to cache {}: {}", file_path.display(), e);
201 }
202 }
203 Err(e) => {
204 result.errors.push((file_path, format!("db error: {}", e)));
205 }
206 },
207 Err(e) => {
208 result.errors.push((file_path, e));
209 }
210 }
211 }
212
213 match tx.commit() {
214 Ok(()) => {
215 result.added += added;
216 result.updated += updated;
217 }
218 Err(e) => {
219 log::error!("failed to commit scan transaction: {}", e);
220 result
221 .errors
222 .push((path.to_path_buf(), format!("db error: {}", e)));
223 }
224 }
225 }
226
227 if let Ok(handle) = reader
228 && handle.join().is_err()
229 {
230 log::error!("scan reader thread panicked");
231 }
232
233 if total_files == 0 {
237 log::error!(
238 "{} contains no audio files — skipping stale-track removal. \
239 If this folder should have music in it, it is probably not mounted or not readable.",
240 path.display()
241 );
242 return result;
243 }
244
245 let tx = match db.conn.unchecked_transaction() {
246 Ok(tx) => tx,
247 Err(e) => {
248 log::error!("failed to begin stale-removal transaction: {}", e);
249 result
250 .errors
251 .push((path.to_path_buf(), format!("db error: {}", e)));
252 return result;
253 }
254 };
255 match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
256 Ok(removed) => {
257 result.removed = removed.len();
258 result.removed_paths = removed;
259 if let Err(e) = tx.commit() {
260 log::error!("failed to commit stale removals: {}", e);
261 result.removed = 0;
262 result.removed_paths.clear();
263 result
264 .errors
265 .push((path.to_path_buf(), format!("db error: {}", e)));
266 }
267 }
268 Err(e) => {
269 log::error!("failed to remove stale tracks: {}", e);
270 result.errors.push((path.to_path_buf(), e.to_string()));
271 }
272 }
273
274 result
275}
276
277fn isolate_read(
282 path: &Path,
283 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
284) -> Result<TrackMeta, String> {
285 match catch_unwind(AssertUnwindSafe(|| read(path))) {
286 Ok(result) => result.map_err(|e| e.to_string()),
287 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
288 }
289}
290
291pub fn full_scan(
293 db: &Database,
294 folders: &[PathBuf],
295 opts: ScanOptions,
296 on_track: Option<&dyn Fn(ScanEvent)>,
297) -> ScanResult {
298 let mut total = ScanResult::default();
299 for folder in folders {
300 if !folder.exists() {
301 log::warn!("library folder does not exist: {}", folder.display());
302 continue;
303 }
304 let r = scan_folder(db, folder, opts, on_track);
305 total.added += r.added;
306 total.updated += r.updated;
307 total.removed += r.removed;
308 total.skipped += r.skipped;
309 total.unreadable += r.unreadable;
310 total.removed_paths.extend(r.removed_paths);
311 total.errors.extend(r.errors);
312 }
313 total
314}
315
316pub struct AnalysisEvent<'a> {
318 pub path: &'a str,
319 pub success: bool,
320 pub current: usize,
321 pub total: usize,
322}
323
324pub fn analyze_missing(
327 db: &Database,
328 on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
329) -> (usize, usize) {
330 let missing = match queries::tracks_missing_vectors(&db.conn) {
331 Ok(m) => m,
332 Err(e) => {
333 log::error!("failed to query missing vectors: {}", e);
334 return (0, 0);
335 }
336 };
337
338 if missing.is_empty() {
339 return (0, 0);
340 }
341
342 let total = missing.len();
343 log::info!("analyzing {} tracks for acoustic features", total);
344
345 let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
347 .par_iter()
348 .enumerate()
349 .map(|(i, (track_id, path))| {
350 let result = match catch_unwind(AssertUnwindSafe(|| {
351 features::analyze_track(Path::new(path))
352 })) {
353 Ok(r) => r,
354 Err(_) => Err(features::AnalysisError::Bliss(format!(
355 "panicked while analyzing {}",
356 path
357 ))),
358 };
359 if let Some(cb) = &on_track {
360 cb(AnalysisEvent {
361 path,
362 success: result.is_ok(),
363 current: i + 1,
364 total,
365 });
366 }
367 (*track_id, path.clone(), result)
368 })
369 .collect();
370
371 let mut analyzed = 0usize;
373 let mut errors = 0usize;
374 let tx = match db.conn.unchecked_transaction() {
375 Ok(tx) => tx,
376 Err(e) => {
377 log::error!("failed to begin analysis transaction: {}", e);
378 return (0, 0);
379 }
380 };
381 for (track_id, path, result) in results {
382 match result {
383 Ok(embedding) => {
384 if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
385 log::warn!("failed to store vector for {}: {}", path, e);
386 errors += 1;
387 } else {
388 analyzed += 1;
389 }
390 }
391 Err(e) => {
392 log::warn!("analysis failed for {}: {}", path, e);
393 errors += 1;
394 }
395 }
396 }
397 if let Err(e) = tx.commit() {
398 log::error!("failed to commit analysis transaction: {}", e);
399 }
400
401 log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
402 (analyzed, errors)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::db::connection::Database;
409 use crate::db::queries;
410 use crate::test_utils;
411
412 fn test_db(dir: &Path) -> Database {
413 let db_path = dir.join("test.db");
414 Database::open(&db_path).unwrap()
415 }
416
417 #[test]
418 fn scan_folder_indexes_new_files() {
419 let dir = tempfile::tempdir().unwrap();
420 let music_dir = dir.path().join("music");
421 std::fs::create_dir_all(&music_dir).unwrap();
422
423 let wav_path = music_dir.join("silence.wav");
425 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
426
427 let db = test_db(dir.path());
428 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
429
430 assert_eq!(result.added, 1, "expected 1 track added");
431 assert_eq!(result.skipped, 0);
432 assert_eq!(result.removed, 0);
433 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
434
435 let stats = queries::library_stats(&db.conn).unwrap();
437 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
438 }
439
440 #[test]
441 fn scan_folder_skips_unchanged_files() {
442 let dir = tempfile::tempdir().unwrap();
443 let music_dir = dir.path().join("music");
444 std::fs::create_dir_all(&music_dir).unwrap();
445
446 let wav_path = music_dir.join("unchanged.wav");
447 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
448
449 let db = test_db(dir.path());
450
451 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
453 assert_eq!(r1.added, 1);
454
455 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
457 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
458 assert_eq!(r2.added, 0, "no new files should be added");
459 }
460
461 #[test]
462 fn scan_folder_removes_deleted_tracks() {
463 let dir = tempfile::tempdir().unwrap();
464 let music_dir = dir.path().join("music");
465 std::fs::create_dir_all(&music_dir).unwrap();
466
467 let wav_path = music_dir.join("ephemeral.wav");
468 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
469 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
470
471 let db = test_db(dir.path());
472
473 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
475 assert_eq!(r1.added, 2);
476
477 std::fs::remove_file(&wav_path).unwrap();
479
480 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
482 assert_eq!(
483 r2.removed, 1,
484 "expected 1 track removed after file deletion"
485 );
486
487 let stats = queries::library_stats(&db.conn).unwrap();
488 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
489 }
490
491 #[test]
492 fn empty_folder_does_not_wipe_the_library() {
493 let dir = tempfile::tempdir().unwrap();
494 let music_dir = dir.path().join("music");
495 std::fs::create_dir_all(&music_dir).unwrap();
496 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
497 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
498
499 let db = test_db(dir.path());
500 assert_eq!(
501 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
502 2
503 );
504
505 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
508 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
509
510 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
511 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
512 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
513 }
514
515 #[cfg(unix)]
516 #[test]
517 fn unreadable_folder_is_not_a_deletion() {
518 use std::os::unix::fs::PermissionsExt;
519
520 let dir = tempfile::tempdir().unwrap();
521 let music_dir = dir.path().join("music");
522 let locked_dir = music_dir.join("locked");
523 std::fs::create_dir_all(&locked_dir).unwrap();
524 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
525 let locked_file = locked_dir.join("locked.wav");
526 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
527
528 let db = test_db(dir.path());
529 assert_eq!(
530 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
531 2
532 );
533
534 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
535 if locked_file.try_exists().is_ok() {
536 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
538 return;
539 }
540
541 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
542 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
543
544 assert!(
545 r.unreadable >= 1,
546 "the unreadable subtree should be counted"
547 );
548 assert_eq!(r.removed, 0, "an IO error is not a deletion");
549 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
550 }
551
552 #[test]
553 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
554 let dir = tempfile::tempdir().unwrap();
555 let music_dir = dir.path().join("music");
556 std::fs::create_dir_all(&music_dir).unwrap();
557 for i in 0..6 {
558 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
559 }
560
561 let db = test_db(dir.path());
562
563 let seen = std::cell::Cell::new(0usize);
565 let abort = |_: ScanEvent| {
566 seen.set(seen.get() + 1);
567 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
568 };
569 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
570 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
571 }));
572 assert!(panicked.is_err());
573
574 assert_eq!(
576 queries::library_stats(&db.conn).unwrap().total_tracks,
577 CHUNK_SIZE as i64
578 );
579
580 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
582 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
583 assert_eq!(r.added, 6 - CHUNK_SIZE);
584 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
585 }
586
587 #[test]
588 fn failing_file_is_named_and_the_scan_continues() {
589 let dir = tempfile::tempdir().unwrap();
590 let music_dir = dir.path().join("music");
591 std::fs::create_dir_all(&music_dir).unwrap();
592 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
593 let broken = music_dir.join("broken.flac");
594 std::fs::write(&broken, b"").unwrap();
595
596 let db = test_db(dir.path());
597 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
598
599 assert_eq!(r.added, 1, "the good file must still be indexed");
600 assert_eq!(r.errors.len(), 1);
601 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
602 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
603 }
604
605 #[test]
606 fn a_panicking_tag_read_becomes_an_error() {
607 let path = Path::new("/music/hostile.mp3");
608 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
609 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
610 }
611
612 #[test]
613 fn scan_folder_updates_modified_files() {
614 let dir = tempfile::tempdir().unwrap();
615 let music_dir = dir.path().join("music");
616 std::fs::create_dir_all(&music_dir).unwrap();
617
618 let wav_path = music_dir.join("modified.wav");
619 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
620
621 let db = test_db(dir.path());
622
623 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
625 assert_eq!(r1.added, 1);
626
627 std::thread::sleep(std::time::Duration::from_millis(1100));
630 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
631
632 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
634 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
635 assert_eq!(r2.added, 0, "the row already exists");
636 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
637 }
638}