1use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::time::Instant;
13
14use crate::analysis;
15use crate::clock::{mtime_seconds, now_seconds};
16use crate::model::{self, Catalog, ScannedFile};
17use crate::tags::{self, RawTags};
18
19const COVER_NAMES: &[&str] = &["cover", "folder", "front", "albumart", "album", "artwork"];
21const COVER_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp"];
22
23#[derive(Debug, Clone)]
27pub struct ScanOptions {
28 pub force_read: bool,
32 pub threads: usize,
34 pub follow_symlinks: bool,
36 pub skip_hidden: bool,
38 pub excluded: Vec<PathBuf>,
49 pub unavailable_roots: Vec<PathBuf>,
51 pub same_artist: Vec<crate::model::identity::Chosen>,
64}
65
66impl Default for ScanOptions {
67 fn default() -> Self {
68 ScanOptions {
69 force_read: false,
70 threads: 0,
71 follow_symlinks: false,
72 skip_hidden: true,
73 excluded: Vec::new(),
74 unavailable_roots: Vec::new(),
75 same_artist: Vec::new(),
76 }
77 }
78}
79
80#[derive(Debug, Default)]
82pub struct ScanReport {
83 pub found: usize,
86 pub read: usize,
88 pub reused: usize,
90 pub removed: usize,
92 pub preserved: usize,
95 pub added_paths: Vec<String>,
97 pub changed_paths: Vec<String>,
99 pub removed_paths: Vec<String>,
101 pub failures: Vec<(String, String)>,
106 pub reports: usize,
108 pub attached: usize,
110 pub analyses: usize,
113 pub elapsed_ms: u128,
115}
116
117#[derive(Debug, Clone, Copy)]
119pub enum Progress {
120 Discovered(usize),
122 Read {
124 done: usize,
126 total: usize,
130 },
131}
132
133pub fn scan(
149 roots: &[PathBuf],
150 previous: Option<&Catalog>,
151 options: &ScanOptions,
152 mut on_progress: impl FnMut(Progress) + Send,
153) -> std::io::Result<(Catalog, ScanReport)> {
154 let started = Instant::now();
155 let mut report = ScanReport::default();
156 let mut roots_str: Vec<String> = roots
159 .iter()
160 .map(|path| {
161 path.to_str().map(str::to_owned).ok_or_else(|| {
162 std::io::Error::new(
163 std::io::ErrorKind::InvalidInput,
164 format!("{path:?}: path is not valid UTF-8"),
165 )
166 })
167 })
168 .collect::<std::io::Result<_>>()?;
169 roots_str.sort();
170 roots_str.dedup();
171 let mut traversal_roots: Vec<&PathBuf> = roots.iter().collect();
172 traversal_roots.sort();
173 traversal_roots.dedup();
174
175 let mut walker = Walker::new(options);
177 for root in traversal_roots {
178 if options.unavailable_roots.contains(root) {
179 walker.failure(root, "watched volume is not mounted");
180 } else {
181 walker.walk(root)?;
182 }
183 }
184 let Walker {
185 mut audio_files,
186 reports: mut reports_found,
187 folder_covers,
188 sidecars,
189 failures: traversal_failures,
190 inaccessible,
191 ..
192 } = walker;
193 reports_found.sort();
194 audio_files.sort();
195 audio_files.dedup();
196 report.found = audio_files.len();
197 on_progress(Progress::Discovered(audio_files.len()));
198
199 let mut cache: HashMap<&str, &model::AudioFile> = HashMap::new();
201 if let Some(prev) = previous {
202 for file in &prev.files {
203 cache.insert(file.path.as_str(), file);
204 }
205 }
206
207 let mut to_read: Vec<PathBuf> = Vec::new();
208 let mut reused: Vec<ScannedFile> = Vec::new();
209 let mut fractions = BTreeMap::new();
210
211 for path in &audio_files {
212 let path_str = path.to_string_lossy().to_string();
213 let Ok(meta) = file_metadata(path, options.follow_symlinks) else {
214 to_read.push(path.clone());
215 continue;
216 };
217 let size = meta.len();
218 let mtime = mtime_seconds(&meta);
219 let fraction = cache_modification_fraction(&meta);
220
221 match (cache.get(path_str.as_str()), fraction) {
222 (Some(old), Some(fraction))
223 if !options.force_read
224 && old.size == size
225 && old.mtime == mtime
226 && previous
227 .and_then(|catalog| catalog.file_mtime_subseconds.get(&path_str))
228 == Some(&fraction) =>
229 {
230 fractions.insert(path_str.clone(), fraction);
231 let mut tags = RawTags {
232 fields: old.tags.clone(),
233 properties: old.properties.clone(),
234 has_embedded_art: old.has_embedded_art,
235 };
236 tags.properties.container = old.properties.container.clone();
238 reused.push(ScannedFile {
239 path: path_str,
240 size,
241 mtime,
242 tags,
243 folder_cover: cover_for(&folder_covers, path),
244 sidecar: sidecar_for(&sidecars, path),
249 integrity: old.integrity.clone(),
253 fingerprint: old.fingerprint.clone(),
254 });
255 }
256 _ => to_read.push(path.clone()),
257 }
258 }
259 report.reused = reused.len();
260 report.read = to_read.len();
261
262 let thread_count = resolve_threads(options.threads).min(to_read.len().max(1));
264 let queue = Mutex::new(to_read);
265 let results: Mutex<Vec<(ScannedFile, Option<u32>)>> = Mutex::new(Vec::new());
266 let failures: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
267 let unavailable: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
268 let done = AtomicUsize::new(0);
269 let total = report.read;
270
271 std::thread::scope(|scope| {
272 for _ in 0..thread_count {
273 scope.spawn(|| {
274 loop {
275 let Some(path) = queue.lock().unwrap_or_else(|e| e.into_inner()).pop() else {
276 break;
277 };
278 let path_str = path.to_string_lossy().to_string();
279 let outcome = read_fresh(&path, options.follow_symlinks, tags::read);
280 match outcome {
281 Ok((metadata, tags)) => {
282 let fraction = cache_modification_fraction(&metadata);
283 let file = ScannedFile {
284 path: path_str,
285 size: metadata.len(),
286 mtime: mtime_seconds(&metadata),
287 tags,
288 folder_cover: cover_for(&folder_covers, &path),
289 sidecar: sidecar_for(&sidecars, &path),
290 integrity: None,
294 fingerprint: None,
295 };
296 results.lock().unwrap_or_else(|e| e.into_inner()).push((file, fraction));
297 }
298 Err(error) => {
299 if matches!(&error, tags::TagError::Io(error) if error.kind() != std::io::ErrorKind::NotFound) {
300 unavailable.lock().unwrap_or_else(|e| e.into_inner()).insert(path_str.clone());
301 }
302 failures
303 .lock()
304 .unwrap_or_else(|e| e.into_inner())
305 .push((path_str, error.to_string()));
306 }
307 }
308 done.fetch_add(1, Ordering::Relaxed);
309 }
310 });
311 }
312
313 let mut last = 0usize;
315 while done.load(Ordering::Relaxed) < total {
316 let current = done.load(Ordering::Relaxed);
317 if current != last {
318 last = current;
319 on_progress(Progress::Read {
320 done: current,
321 total,
322 });
323 }
324 std::thread::sleep(std::time::Duration::from_millis(60));
325 }
326 });
327
328 let mut scanned: Vec<ScannedFile> = results
329 .into_inner()
330 .unwrap_or_else(|e| e.into_inner())
331 .into_iter()
332 .map(|(file, fraction)| {
333 if let Some(fraction) = fraction {
334 fractions.insert(file.path.clone(), fraction);
335 }
336 file
337 })
338 .collect();
339 report.failures = failures.into_inner().unwrap_or_else(|e| e.into_inner());
340 report.failures.extend(traversal_failures.iter().cloned());
341 report.failures.sort();
342 report.failures.dedup();
343 scanned.extend(reused);
344 let unavailable = unavailable.into_inner().unwrap_or_else(|e| e.into_inner());
348 if let Some(previous) = previous {
349 let present: HashSet<&str> = scanned.iter().map(|file| file.path.as_str()).collect();
350 let retained: Vec<&model::AudioFile> = previous
351 .files
352 .iter()
353 .filter(|file| {
354 !present.contains(file.path.as_str())
355 && !options
356 .excluded
357 .iter()
358 .any(|folder| crate::text::is_under(&file.path, &folder.to_string_lossy()))
359 && (Path::new(&file.path)
360 .ancestors()
361 .any(|ancestor| inaccessible.contains(ancestor))
362 || unavailable.contains(&file.path))
363 })
364 .collect();
365 let covers: HashMap<model::Id, &str> = if retained.is_empty() {
366 HashMap::new()
367 } else {
368 previous
369 .tracks
370 .iter()
371 .filter_map(|track| {
372 let release = previous.release(track.release_id?)?;
373 Some((track.file_id, release.cover_path.as_deref()?))
374 })
375 .collect()
376 };
377 for file in retained {
378 if let Some(fraction) = previous.file_mtime_subseconds.get(&file.path) {
379 fractions.insert(file.path.clone(), *fraction);
380 }
381 scanned.push(cached_file(
382 file,
383 covers.get(&file.id).map(|path| (*path).to_owned()),
384 ));
385 report.preserved += 1;
386 }
387 }
388 on_progress(Progress::Read { done: total, total });
389
390 let mut catalog = model::build(scanned, roots_str, now_seconds(), &options.same_artist);
392 catalog.file_mtime_subseconds = fractions;
393 let previous_paths: HashSet<&str> = previous
394 .into_iter()
395 .flat_map(|c| c.files.iter().map(|f| f.path.as_str()))
396 .collect();
397 for file in &catalog.files {
398 let added = previous
399 .and_then(|c| c.file_added_at.get(&file.path))
400 .copied()
401 .or_else(|| {
402 (!previous_paths.contains(file.path.as_str())).then_some(catalog.scanned_at)
403 });
404 if let Some(added) = added {
405 catalog.file_added_at.insert(file.path.clone(), added);
406 }
407 }
408 let current_paths: HashSet<&str> = catalog
409 .files
410 .iter()
411 .map(|file| file.path.as_str())
412 .collect();
413 catalog
414 .file_mtime_subseconds
415 .retain(|path, _| current_paths.contains(path.as_str()));
416 record_changes(&catalog, previous, &mut report);
417
418 if let Some(previous) = previous {
429 catalog.analyses = previous.analyses.clone();
430 catalog.excluded = previous.excluded.clone();
431 }
432 report.reports = import_reports(&reports_found, &mut catalog);
435 report.attached = analysis::reconcile(&mut catalog);
439 report.analyses = catalog.analyses.len();
440
441 report.elapsed_ms = started.elapsed().as_millis();
442 Ok((catalog, report))
443}
444
445fn cache_modification_fraction(metadata: &std::fs::Metadata) -> Option<u32> {
449 metadata
450 .modified()
451 .ok()?
452 .duration_since(std::time::UNIX_EPOCH)
453 .ok()
454 .map(|modified| modified.subsec_nanos())
455}
456
457fn read_fresh(
458 path: &Path,
459 follow_symlinks: bool,
460 read_tags: impl FnOnce(&Path) -> Result<RawTags, tags::TagError>,
461) -> Result<(std::fs::Metadata, RawTags), tags::TagError> {
462 let before = file_metadata(path, follow_symlinks)?;
463 let tags = read_tags(path);
464 let after = file_metadata(path, follow_symlinks)?;
465 if !same_file_snapshot(&before, &after) {
466 return Err(std::io::Error::new(
467 std::io::ErrorKind::Interrupted,
468 "file changed while its tags were read; scan again once it is stable",
469 )
470 .into());
471 }
472 tags.map(|tags| (after, tags))
473}
474
475fn file_metadata(path: &Path, follow_symlinks: bool) -> std::io::Result<std::fs::Metadata> {
476 let metadata = if follow_symlinks {
477 std::fs::metadata(path)?
478 } else {
479 std::fs::symlink_metadata(path)?
480 };
481 if !metadata.is_file() {
482 return Err(std::io::Error::new(
483 std::io::ErrorKind::InvalidInput,
484 "audio path is no longer an ordinary file",
485 ));
486 }
487 Ok(metadata)
488}
489
490fn same_file_snapshot(before: &std::fs::Metadata, after: &std::fs::Metadata) -> bool {
491 if before.len() != after.len()
492 || before.modified().ok() != after.modified().ok()
493 || before.created().ok() != after.created().ok()
494 {
495 return false;
496 }
497 #[cfg(unix)]
498 {
499 use std::os::unix::fs::MetadataExt;
500 if before.dev() != after.dev() || before.ino() != after.ino() {
501 return false;
502 }
503 }
504 true
505}
506
507fn cached_file(file: &model::AudioFile, folder_cover: Option<String>) -> ScannedFile {
509 ScannedFile {
510 path: file.path.clone(),
511 size: file.size,
512 mtime: file.mtime,
513 tags: RawTags {
514 fields: file.tags.clone(),
515 properties: file.properties.clone(),
516 has_embedded_art: file.has_embedded_art,
517 },
518 folder_cover,
519 sidecar: file.lyrics_path.clone(),
520 integrity: file.integrity.clone(),
521 fingerprint: file.fingerprint.clone(),
522 }
523}
524
525fn record_changes(catalog: &Catalog, previous: Option<&Catalog>, report: &mut ScanReport) {
526 let old: HashMap<&str, &model::AudioFile> = previous
527 .into_iter()
528 .flat_map(|catalog| &catalog.files)
529 .map(|file| (file.path.as_str(), file))
530 .collect();
531 let current: HashSet<&str> = catalog
532 .files
533 .iter()
534 .map(|file| file.path.as_str())
535 .collect();
536 for file in &catalog.files {
537 match old.get(file.path.as_str()) {
538 None => report.added_paths.push(file.path.clone()),
539 Some(old)
540 if old.size != file.size
541 || old.mtime != file.mtime
542 || previous
543 .and_then(|catalog| catalog.file_mtime_subseconds.get(&file.path))
544 != catalog.file_mtime_subseconds.get(&file.path) =>
545 {
546 report.changed_paths.push(file.path.clone());
547 }
548 _ => {}
549 }
550 }
551 report.removed_paths = old
552 .keys()
553 .filter(|path| !current.contains(**path))
554 .map(|path| (*path).to_string())
555 .collect();
556 report.removed_paths.sort();
557 report.removed = report.removed_paths.len();
558}
559
560fn import_reports(found: &[PathBuf], catalog: &mut Catalog) -> usize {
570 let mut count = 0;
571 let now = now_seconds();
572 for path in found {
573 let Ok(report) = analysis::read_report(path) else {
574 continue;
575 };
576 count += 1;
577 analysis::merge_into(catalog, report.files, now);
578 }
579 count
580}
581
582pub fn resolve_threads(requested: usize) -> usize {
589 if requested > 0 {
590 return requested;
591 }
592 std::thread::available_parallelism()
593 .map(|n| n.get())
594 .unwrap_or(4)
595}
596
597fn sidecar_for(sidecars: &HashMap<PathBuf, PathBuf>, file: &Path) -> Option<String> {
599 let expected = crate::lyrics::sidecar_of(file);
600 sidecars
601 .get(&expected)
602 .map(|path| path.to_string_lossy().to_string())
603}
604
605fn cover_for(covers: &HashMap<PathBuf, PathBuf>, file: &Path) -> Option<String> {
606 let folder = file.parent()?;
607 covers.get(folder).map(|p| p.to_string_lossy().to_string())
608}
609
610struct Walker<'a> {
615 options: &'a ScanOptions,
616 audio_files: Vec<PathBuf>,
617 reports: Vec<PathBuf>,
619 folder_covers: HashMap<PathBuf, PathBuf>,
621 sidecars: HashMap<PathBuf, PathBuf>,
622 visited: HashSet<PathBuf>,
625 failures: Vec<(String, String)>,
627 inaccessible: HashSet<PathBuf>,
630}
631
632impl<'a> Walker<'a> {
633 fn new(options: &'a ScanOptions) -> Self {
634 Walker {
635 options,
636 audio_files: Vec::new(),
637 reports: Vec::new(),
638 folder_covers: HashMap::new(),
639 sidecars: HashMap::new(),
640 visited: HashSet::new(),
641 failures: Vec::new(),
642 inaccessible: HashSet::new(),
643 }
644 }
645
646 fn failure(&mut self, path: &Path, reason: impl Into<String>) {
647 let spelling = path
648 .to_str()
649 .map(str::to_owned)
650 .unwrap_or_else(|| format!("{path:?}"));
651 self.failures.push((spelling, reason.into()));
652 self.inaccessible.insert(path.to_path_buf());
653 }
654
655 fn is_excluded(&self, canonical: &Path) -> bool {
658 let path = canonical.to_string_lossy();
659 self.options
660 .excluded
661 .iter()
662 .any(|folder| crate::text::is_under(&path, &folder.to_string_lossy()))
663 }
664
665 fn walk(&mut self, dir: &Path) -> std::io::Result<()> {
666 let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
667 if self.is_excluded(&canonical) {
671 return Ok(());
672 }
673 if !self.visited.insert(canonical) {
674 return Ok(()); }
676
677 let entries = match std::fs::read_dir(dir) {
678 Ok(entries) => entries,
679 Err(error) => {
681 self.failure(dir, error.to_string());
682 return Ok(());
683 }
684 };
685
686 let mut best_cover: Option<(usize, PathBuf)> = None;
687
688 let mut paths = Vec::new();
692 for entry in entries {
693 let entry = match entry {
694 Ok(entry) => entry,
695 Err(error) => {
696 self.failure(dir, error.to_string());
697 continue;
698 }
699 };
700 paths.push((entry.path(), entry.file_type()));
701 }
702 paths.sort_by(|(left, _), (right, _)| left.cmp(right));
703 for (path, file_type) in paths {
704 let name = path
705 .file_name()
706 .map(|name| name.to_string_lossy())
707 .unwrap_or_default();
708 if self.options.skip_hidden && name.starts_with('.') {
709 continue;
710 }
711 if path.to_str().is_none() {
712 self.failure(
713 &path,
714 "path is not valid UTF-8 and cannot be stored without losing its identity",
715 );
716 continue;
717 }
718 let file_type = match file_type {
719 Ok(file_type) => file_type,
720 Err(error) => {
721 self.failure(&path, error.to_string());
722 continue;
723 }
724 };
725
726 if file_type.is_symlink() && !self.options.follow_symlinks {
727 continue;
728 }
729 let file_type = if file_type.is_symlink() {
730 match std::fs::metadata(&path) {
731 Ok(metadata) => metadata.file_type(),
732 Err(error) => {
733 self.failure(&path, error.to_string());
734 continue;
735 }
736 }
737 } else {
738 file_type
739 };
740
741 if file_type.is_dir() {
742 self.walk(&path)?;
743 } else if !file_type.is_file() {
744 continue;
747 } else if tags::is_audio_path(&path) {
748 self.audio_files.push(path);
749 } else if name.to_ascii_lowercase().ends_with(".json")
750 && analysis::looks_like_a_report(&path)
751 {
752 self.reports.push(path);
756 } else if crate::lyrics::is_sidecar(&name) {
757 let expected = path.with_extension(crate::lyrics::EXTENSION);
762 let selected = self
763 .sidecars
764 .entry(expected.clone())
765 .or_insert_with(|| path.clone());
766 if path == expected {
769 *selected = path;
770 }
771 } else if let Some(rank) = cover_rank(&name)
772 && best_cover
773 .as_ref()
774 .is_none_or(|(seen_rank, seen_path)| (rank, &path) < (*seen_rank, seen_path))
775 {
776 best_cover = Some((rank, path));
777 }
778 }
779
780 if let Some((_, cover)) = best_cover {
781 self.folder_covers.insert(dir.to_path_buf(), cover);
782 }
783 Ok(())
784 }
785}
786
787pub fn cover_in(folder: &std::path::Path) -> Option<std::path::PathBuf> {
794 let mut best: Option<(usize, std::path::PathBuf)> = None;
795 for entry in std::fs::read_dir(folder).ok()?.flatten() {
796 let path = entry.path();
797 if !path.is_file() {
798 continue;
799 }
800 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
801 continue;
802 };
803 if let Some(rank) = cover_rank(name)
804 && best
805 .as_ref()
806 .is_none_or(|(seen_rank, seen_path)| (rank, &path) < (*seen_rank, seen_path))
807 {
808 best = Some((rank, path));
809 }
810 }
811 best.map(|(_, path)| path)
812}
813
814fn cover_rank(name: &str) -> Option<usize> {
815 let lower = name.to_ascii_lowercase();
816 let (stem, ext) = lower.rsplit_once('.')?;
817 if !COVER_EXTENSIONS.contains(&ext) {
818 return None;
819 }
820 for (rank, candidate) in COVER_NAMES.iter().enumerate() {
821 if stem == *candidate || stem.starts_with(candidate) {
822 return Some(rank);
823 }
824 }
825 Some(COVER_NAMES.len())
827}
828
829#[cfg(test)]
830#[path = "scan_tests.rs"]
831mod tests;