aede_core/
scan.rs

1//! Directory traversal and parallel file reading.
2//!
3//! The scan is **incremental**: a file whose path, size and modification date
4//! have not changed is not read again, its tags are taken from the previous
5//! catalog. On a library of 50,000 titles, that is the difference between
6//! several minutes and a few seconds.
7
8use 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
19/// Image file names recognised as album cover art, in order of preference.
20const COVER_NAMES: &[&str] = &["cover", "folder", "front", "albumart", "album", "artwork"];
21const COVER_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp"];
22
23/// Knobs governing a traversal. [`Default`] gives the settings suited to a
24/// personal library: automatic parallelism, no symlink following, hidden
25/// entries left alone.
26#[derive(Debug, Clone)]
27pub struct ScanOptions {
28    /// Read every accessible file again while retaining the previous catalog
29    /// as evidence for inaccessible paths. This does not discard cached data
30    /// merely because a watched drive or subfolder cannot be read.
31    pub force_read: bool,
32    /// Number of reader threads. 0 = automatic detection.
33    pub threads: usize,
34    /// Follow symbolic links (with loop detection).
35    pub follow_symlinks: bool,
36    /// Skip files and folders starting with a dot.
37    pub skip_hidden: bool,
38    /// Folders never to walk into, canonical, whatever a root says.
39    ///
40    /// A music folder is rarely only music: `Audiobooks`, `Podcasts`,
41    /// `_incoming`, a `Samples` folder for a DAW. Without this the only way to
42    /// keep them out of the catalog is to reorganise the disk to suit the
43    /// program, which is the wrong way round.
44    ///
45    /// They live in the **catalog**, not in this run's options, because a
46    /// plain `aede scan` re-reads every watched root: an exclusion that had to
47    /// be retyped would be forgotten exactly when it mattered.
48    pub excluded: Vec<PathBuf>,
49    /// Roots explicitly known to be unmounted, even when an empty mountpoint remains.
50    pub unavailable_roots: Vec<PathBuf>,
51    /// Spellings the owner of the disk has said are one artist.
52    ///
53    /// The half of artist identity no identifier can settle, and the reason it
54    /// travels here rather than in the catalog: it is **the user's**, it lives
55    /// in `user.json`, and this crate does not read that file — the caller
56    /// does, as it does for every other statement in it.
57    ///
58    /// It is applied when the graph is built, which is why a merge takes
59    /// effect on the next `aede scan` and not before: the spelling a track is
60    /// filed under is decided as the artist is interned, and there is no later
61    /// moment at which one row can become another without rebuilding
62    /// everything that points at it.
63    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/// Summary of a scan.
81#[derive(Debug, Default)]
82pub struct ScanReport {
83    /// Audio files spotted during traversal, after deduplication. Always the
84    /// sum of `read` and `reused`, failures included.
85    pub found: usize,
86    /// Files selected for a fresh read, including unsuccessful attempts.
87    pub read: usize,
88    /// Files taken unchanged from the previous catalog.
89    pub reused: usize,
90    /// Files present in the old catalog and gone since.
91    pub removed: usize,
92    /// Previous files retained because their file or directory was inaccessible.
93    /// These are separate from unchanged files counted in `reused`.
94    pub preserved: usize,
95    /// File paths newly entering the catalog, sorted by path.
96    pub added_paths: Vec<String>,
97    /// Existing file paths whose size or precise timestamp changed.
98    pub changed_paths: Vec<String>,
99    /// Previously catalogued file paths absent from accessible scanned folders.
100    pub removed_paths: Vec<String>,
101    /// Files or directories that could not be read, sorted by path.
102    /// Inaccessible paths retain their previous entries. Invalid audio content
103    /// excludes the file, with its parsing failure reported here. Non-UTF-8
104    /// entries are skipped and reported with escaped native path spelling.
105    pub failures: Vec<(String, String)>,
106    /// Analysis reports found inside the folders and taken in.
107    pub reports: usize,
108    /// Imported analyses that were waiting for a file and found it this time.
109    pub attached: usize,
110    /// Imported analyses the catalog holds once the scan is done, the ones
111    /// carried over from the previous catalog included.
112    pub analyses: usize,
113    /// Wall-clock time of the whole scan in milliseconds, traversal included.
114    pub elapsed_ms: u128,
115}
116
117/// Progress event, for display.
118#[derive(Debug, Clone, Copy)]
119pub enum Progress {
120    /// Directory traversal finished: number of audio files spotted.
121    Discovered(usize),
122    /// `n` files processed out of `total`.
123    Read {
124        /// Files pulled off the read queue so far, failures included.
125        done: usize,
126        /// Files needing a fresh read. On an incremental scan this stays well
127        /// below the count announced by [`Progress::Discovered`], since reused
128        /// files never enter the queue.
129        total: usize,
130    },
131}
132
133/// Scans the given folders and builds a catalog.
134///
135/// `previous` enables incremental reuse and retention of inaccessible paths.
136/// To reread accessible files while keeping that evidence, set
137/// [`ScanOptions::force_read`]. Passing `None` starts without any prior data.
138///
139/// Only ordinary files are opened as audio. Roots and directory entries are
140/// traversed in native path order so followed directory aliases choose the
141/// same spelling. A root that cannot be represented exactly as UTF-8 is an
142/// invalid input; unrepresentable entries are reported and skipped.
143///
144/// Fresh reads compare filesystem metadata before and after tag parsing. An
145/// observed rewrite is reported as temporary unavailability rather than
146/// cached or mistaken for permanent corruption. This is a filesystem snapshot
147/// check, not a content hash or a transaction with concurrent file writers.
148pub 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    // The persistent path contract is UTF-8. Refuse an unrepresentable root
157    // before a lossy spelling could become watched or collide with another.
158    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    // --- 1. Traversal ------------------------------------------------------
176    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    // --- 2. What can be taken as is ----------------------------------------
200    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                // Cover art may have appeared in the folder since.
237                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                    // Read from the fresh walk rather than carried over: a
245                    // `.lrc` dropped beside a track nobody touched must attach
246                    // on the next scan, and the track's own bytes have not
247                    // changed to say so.
248                    sidecar: sidecar_for(&sidecars, path),
249                    // The file has not moved and has not changed, so what was
250                    // concluded about it still holds — both of the expensive
251                    // conclusions, the checksum verdict and the fingerprint.
252                    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    // --- 3. Parallel reading ----------------------------------------------
263    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                                // A file read again is a file that changed:
291                                // any earlier verdict is about other bytes,
292                                // and so is any earlier fingerprint.
293                                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        // The main thread only reports progress.
314        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    // An unsuccessful walk cannot establish absence. Preserve only entries
345    // below the failed paths, never excluded folders or intentionally removed
346    // roots. An I/O failure while opening an audio file has the same meaning.
347    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    // --- 4. Building the graph ---------------------------------------------
391    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    // Analyses are keyed by path, so they simply travel: nothing to remap, and
419    // nothing to lose. They are the one thing in a catalog that reading the
420    // files again cannot recompute.
421    //
422    // Exclusions travel for the same reason and it is the same rule — **a scan
423    // may not destroy what it cannot recompute**. They are typed by the user
424    // and derived from nothing, so a rebuild that dropped them would forget
425    // them on the very run they were meant to shape. That is not theory: the
426    // first version of this feature lost them exactly here, and the symptom
427    // was an exclusion that worked once and then vanished.
428    if let Some(previous) = previous {
429        catalog.analyses = previous.analyses.clone();
430        catalog.excluded = previous.excluded.clone();
431    }
432    // Reports lying in the library are taken in, so that analysing a folder and
433    // then scanning it works as well as the other way round.
434    report.reports = import_reports(&reports_found, &mut catalog);
435    // And whatever was waiting is given another chance now that the files are
436    // known — including records naming the same file by another route, which
437    // is what a report written against a symbolic link looks like.
438    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
445// Zero in the unsigned second field also represents unavailable/pre-epoch
446// times. Omitting their precision prevents that fallback from becoming cache
447// evidence that two otherwise unrelated file versions are identical.
448fn 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
507/// Rebuilds a cached file without inspecting an inaccessible directory.
508fn 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
560/// Reads the reports found while walking, and merges what they hold.
561///
562/// Returns how many were actually reports. A file is only parsed once
563/// [`analysis::looks_like_a_report`] has recognised it, and one that turns out
564/// to be unreadable is passed over: a scan is not the place to fail over a
565/// sidecar file nobody asked about.
566///
567/// The records go through the same matching as `aede import` — the scan has no
568/// business being more trusting than the command whose whole job this is.
569fn 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
582/// How many threads a run should use: what was asked for, or what the machine
583/// offers.
584///
585/// Shared with `spectrum`, which spawns one ffmpeg per track and has exactly
586/// the same question to answer. Two defaults that could disagree about what
587/// `--threads` with no value means is one too many.
588pub 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
597/// The `.lrc` beside this file, if the walk saw one.
598fn 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
610// --------------------------------------------------------------------------
611// Tree traversal
612// --------------------------------------------------------------------------
613
614struct Walker<'a> {
615    options: &'a ScanOptions,
616    audio_files: Vec<PathBuf>,
617    /// Analysis reports met on the way, to be taken in at the end.
618    reports: Vec<PathBuf>,
619    /// Best cover art found per folder.
620    folder_covers: HashMap<PathBuf, PathBuf>,
621    sidecars: HashMap<PathBuf, PathBuf>,
622    /// Folders already visited, so as not to go round in circles on a
623    /// circular symbolic link.
624    visited: HashSet<PathBuf>,
625    /// Failed paths are evidence of an unavailable subtree, not its deletion.
626    failures: Vec<(String, String)>,
627    /// Native paths used for ancestor lookups. One failed subtree must not
628    /// trigger a scan of every recorded failure for every previous file.
629    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    /// `true` when a folder is one the user asked never to read, or sits
656    /// inside one.
657    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        // Tested on the canonical path, so a folder reached through a symbolic
668        // link is excluded too — the same reason every comparison against a
669        // stored path in this program is made on a resolved one.
670        if self.is_excluded(&canonical) {
671            return Ok(());
672        }
673        if !self.visited.insert(canonical) {
674            return Ok(()); // already seen: symlink loop
675        }
676
677        let entries = match std::fs::read_dir(dir) {
678            Ok(entries) => entries,
679            // An unreadable folder must not interrupt the whole scan.
680            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        // Resolve directory aliases in a stable order. ReadDir order is not
689        // promised by the filesystem and otherwise decides which native path
690        // survives when two links reach the same directory.
691        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                // Opening a FIFO may wait forever; devices and sockets are
745                // not library audio regardless of their filename extension.
746                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                // Someone may analyse a folder before ever scanning it, and
753                // leave the report sitting in it. Picking it up here is what
754                // makes the order of the two operations irrelevant.
755                self.reports.push(path);
756            } else if crate::lyrics::is_sidecar(&name) {
757                // Noted while the folder is open rather than looked for later:
758                // the walk already has the names in hand, and asking the
759                // filesystem again once per track would be ten thousand
760                // questions with the answers already on the table.
761                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                // Keep the exact lowercase name when present; other suffix
767                // spellings retain the first path in the sorted traversal.
768                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
787/// The image already serving as cover art in a folder, if there is one.
788///
789/// Reads the folder rather than the catalog, and shares its list of cover names with
790/// the scanner so that "does this album have a cover" has one answer in this
791/// program rather than two that drift. Used before anything writes an image
792/// beside music: the catalog is a snapshot and the disk is not.
793pub 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    // Any image is still a candidate, but of last rank.
826    Some(COVER_NAMES.len())
827}
828
829#[cfg(test)]
830#[path = "scan_tests.rs"]
831mod tests;