src/library/scanner.rs (4895B)
1 use rodio::Decoder;
2 use std::fs::File;
3 use std::io::BufReader;
4 use std::panic;
5 use std::path::{Path, PathBuf};
6 use symphonia::core::formats::FormatOptions;
7 use symphonia::core::io::MediaSourceStream;
8 use symphonia::core::meta::MetadataOptions;
9 use symphonia::core::probe::Hint;
10 use walkdir::WalkDir;
11
12 use super::cache::{self, Entry};
13 use super::track::Track;
14
15 const AUDIO_EXTENSIONS: &[&str] = &["flac", "mp3", "m4a", "ogg", "wav", "opus", "aac", "wma"];
16
17 /// A candidate file and the stamp that decides whether the cache still applies.
18 struct Candidate {
19 path: PathBuf,
20 stamp: (u64, u64),
21 }
22
23 /// Check if we can decode this file: try rodio first, then symphonia direct probe
24 fn is_decodable(path: &Path) -> bool {
25 let path = path.to_path_buf();
26 let result = panic::catch_unwind(move || {
27 // Try rodio auto-detect
28 if let Ok(file) = File::open(&path) {
29 if Decoder::new(BufReader::new(file)).is_ok() {
30 return true;
31 }
32 }
33
34 // Try symphonia direct probe (handles M4A/ALAC/MP4 that rodio can't)
35 if let Ok(file) = File::open(&path) {
36 let mss = MediaSourceStream::new(Box::new(file), Default::default());
37 let mut hint = Hint::new();
38 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
39 hint.with_extension(ext);
40 }
41 if symphonia::default::get_probe()
42 .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
43 .is_ok()
44 {
45 return true;
46 }
47 }
48
49 false
50 });
51 result.unwrap_or(false)
52 }
53
54 /// Walk the tree and list audio files with their stamps. Only `stat` here —
55 /// walkdir has already done it, so this costs nothing extra.
56 fn collect_candidates(root: &Path) -> Vec<Candidate> {
57 WalkDir::new(root)
58 .follow_links(true)
59 .into_iter()
60 .filter_map(|e| e.ok())
61 .filter_map(|entry| {
62 let path = entry.path();
63
64 // Skip macOS resource fork files
65 if path
66 .file_name()
67 .and_then(|n| n.to_str())
68 .is_some_and(|n| n.starts_with("._"))
69 {
70 return None;
71 }
72
73 let ext = path.extension().and_then(|e| e.to_str())?.to_lowercase();
74 if !AUDIO_EXTENSIONS.contains(&ext.as_str()) {
75 return None;
76 }
77
78 let meta = entry.metadata().ok()?;
79 if !meta.is_file() {
80 return None;
81 }
82 Some(Candidate { path: path.to_path_buf(), stamp: cache::stamp(&meta) })
83 })
84 .collect()
85 }
86
87 fn parse_one(candidate: &Candidate) -> Option<Entry> {
88 if !is_decodable(&candidate.path) {
89 return None;
90 }
91 Track::from_path(&candidate.path).map(|track| Entry {
92 track,
93 mtime: candidate.stamp.0,
94 size: candidate.stamp.1,
95 })
96 }
97
98 /// Parse across all cores. Reading a file is mostly waiting on the disk and
99 /// then parsing a header, both of which overlap well; scanning was serial, so a
100 /// large library paid the full per-file cost one file at a time.
101 fn parse_all(candidates: &[Candidate]) -> Vec<Entry> {
102 if candidates.is_empty() {
103 return Vec::new();
104 }
105
106 let workers = std::thread::available_parallelism()
107 .map(|n| n.get())
108 .unwrap_or(4)
109 .min(candidates.len());
110 let chunk = candidates.len().div_ceil(workers);
111
112 std::thread::scope(|scope| {
113 let handles: Vec<_> = candidates
114 .chunks(chunk)
115 .map(|c| scope.spawn(move || c.iter().filter_map(parse_one).collect::<Vec<Entry>>()))
116 .collect();
117 handles
118 .into_iter()
119 // A panicking worker loses its chunk rather than the whole scan.
120 .flat_map(|h| h.join().unwrap_or_default())
121 .collect()
122 })
123 }
124
125 pub fn scan_directory(path: &Path) -> Vec<Track> {
126 let candidates = collect_candidates(path);
127 let mut cached = cache::load();
128
129 // Reuse whatever still matches on disk; parse the rest.
130 let mut entries: Vec<Entry> = Vec::with_capacity(candidates.len());
131 let mut stale: Vec<Candidate> = Vec::new();
132 for candidate in candidates {
133 match cached.remove(&candidate.path) {
134 Some(entry) if cache::is_fresh(&entry, candidate.stamp) => entries.push(entry),
135 _ => stale.push(candidate),
136 }
137 }
138 entries.extend(parse_all(&stale));
139
140 entries.sort_by(|a, b| {
141 a.track
142 .album_artist
143 .cmp(&b.track.album_artist)
144 .then(a.track.album.cmp(&b.track.album))
145 .then(a.track.track_number.cmp(&b.track.track_number))
146 .then(a.track.title.cmp(&b.track.title))
147 });
148
149 cache::save(&entries);
150 entries.into_iter().map(|e| e.track).collect()
151 }