src/library/mod.rs (13158B)
1 pub mod cache;
2 pub mod scanner;
3 pub mod track;
4 pub mod watcher;
5
6 use std::collections::{BTreeMap, BTreeSet, HashMap};
7 use std::path::{Path, PathBuf};
8 use track::Track;
9
10 /// Tracks with no artist tag are grouped under this name.
11 pub const UNKNOWN_ARTIST: &str = "Unknown Artist";
12
13 /// Views derived from `tracks`, built once when the library is constructed.
14 ///
15 /// Every one of these used to be recomputed on each access — `get_albums` alone
16 /// walked all tracks and cloned two strings each — and the render loop calls
17 /// several of them per frame at up to 20 frames a second.
18 #[derive(Debug, Default)]
19 struct Index {
20 artists: Vec<String>,
21 albums: Vec<(String, String)>,
22 genres: Vec<String>,
23 /// Format extension with how many tracks use it.
24 formats: Vec<(String, usize)>,
25 /// Basenames of every directory containing a track.
26 directories: Vec<String>,
27 by_artist: HashMap<String, Vec<usize>>,
28 by_album: HashMap<(String, String), Vec<usize>>,
29 by_genre: HashMap<String, Vec<usize>>,
30 by_format: HashMap<String, Vec<usize>>,
31 by_path: HashMap<PathBuf, usize>,
32 }
33
34 #[derive(Debug)]
35 pub struct Library {
36 pub tracks: Vec<Track>,
37 index: Index,
38 }
39
40 impl Library {
41 pub fn new() -> Self {
42 Self { tracks: Vec::new(), index: Index::default() }
43 }
44
45 pub fn from_tracks(tracks: Vec<Track>) -> Self {
46 let index = Index::build(&tracks);
47 Self { tracks, index }
48 }
49
50 pub fn scan(path: &Path) -> Self {
51 Self::from_tracks(scanner::scan_directory(path))
52 }
53
54 // ── Derived views: O(1), the work happened at construction ───────────
55
56 pub fn artists(&self) -> &[String] {
57 &self.index.artists
58 }
59
60 pub fn albums(&self) -> &[(String, String)] {
61 &self.index.albums
62 }
63
64 pub fn genres(&self) -> &[String] {
65 &self.index.genres
66 }
67
68 /// Format extensions paired with their track counts.
69 pub fn formats(&self) -> &[(String, usize)] {
70 &self.index.formats
71 }
72
73 pub fn directories(&self) -> &[String] {
74 &self.index.directories
75 }
76
77 // ── Lookups: one hash, then a slice ─────────────────────────────────
78
79 pub fn tracks_by_artist(&self, artist: &str) -> &[usize] {
80 Self::slice(self.index.by_artist.get(artist))
81 }
82
83 /// Tracks on `album` by `artist`. Matching on the title alone merged two
84 /// different records that happen to share a name, since `albums()` pairs
85 /// each album with its artist.
86 pub fn tracks_by_album(&self, album: &str, artist: &str) -> &[usize] {
87 Self::slice(self.index.by_album.get(&(album.to_string(), artist.to_string())))
88 }
89
90 pub fn tracks_by_genre(&self, genre: &str) -> &[usize] {
91 Self::slice(self.index.by_genre.get(genre))
92 }
93
94 pub fn tracks_by_format(&self, format: &str) -> &[usize] {
95 Self::slice(self.index.by_format.get(format))
96 }
97
98 fn slice(v: Option<&Vec<usize>>) -> &[usize] {
99 v.map(Vec::as_slice).unwrap_or(&[])
100 }
101
102 /// Was a linear scan over every path, so restoring a 1,000-entry playlist
103 /// against a 10,000-track library cost 10 million path comparisons.
104 pub fn path_to_index(&self, path: &Path) -> Option<usize> {
105 self.index.by_path.get(path).copied()
106 }
107
108 /// The artist an album is filed under: the album artist when tagged,
109 /// otherwise the track artist.
110 fn album_artist_of(t: &Track) -> &str {
111 if t.album_artist.is_empty() {
112 &t.artist
113 } else {
114 &t.album_artist
115 }
116 }
117
118 fn extension_of(t: &Track) -> String {
119 t.path
120 .extension()
121 .map(|e| e.to_string_lossy().to_lowercase())
122 .unwrap_or_default()
123 }
124
125 pub fn get_directory_entries(&self, dir: &Path) -> (Vec<String>, Vec<usize>) {
126 let mut subdirs = BTreeSet::new();
127 let mut tracks = Vec::new();
128
129 for (i, t) in self.tracks.iter().enumerate() {
130 if let Some(parent) = t.path.parent() {
131 if parent == dir {
132 tracks.push(i);
133 } else if let Ok(rel) = parent.strip_prefix(dir) {
134 if let Some(first) = rel.components().next() {
135 subdirs.insert(first.as_os_str().to_string_lossy().to_string());
136 }
137 }
138 }
139 }
140
141 (subdirs.into_iter().collect(), tracks)
142 }
143
144 pub fn search(&self, query: &str) -> Vec<usize> {
145 if query.is_empty() {
146 return Vec::new();
147 }
148
149 // Extension filter: *.flac, *.mp3, etc. — now an index lookup.
150 if let Some(ext) = query.strip_prefix("*.") {
151 return self.tracks_by_format(&ext.to_lowercase()).to_vec();
152 }
153
154 // Field-specific filter: artist:, album:, genre:, title:
155 if let Some((prefix, value)) = query.split_once(':') {
156 let field = prefix.trim().to_lowercase();
157 let v = value.trim().to_lowercase();
158 if !v.is_empty() {
159 let pick = |f: fn(&Track) -> &str| {
160 self.tracks
161 .iter()
162 .enumerate()
163 .filter(|(_, t)| contains_ci(f(t), &v))
164 .map(|(i, _)| i)
165 .collect::<Vec<_>>()
166 };
167 match field.as_str() {
168 "artist" => return pick(|t| &t.artist),
169 "album" => return pick(|t| &t.album),
170 "genre" => return pick(|t| &t.genre),
171 "title" => return pick(|t| &t.title),
172 _ => {} // unknown prefix, fall through to general search
173 }
174 }
175 }
176
177 // General search: title, artist, album, genre, filename
178 let q = query.to_lowercase();
179 self.tracks
180 .iter()
181 .enumerate()
182 .filter(|(_, t)| {
183 contains_ci(&t.title, &q)
184 || contains_ci(&t.artist, &q)
185 || contains_ci(&t.album, &q)
186 || contains_ci(&t.genre, &q)
187 || t.path
188 .file_name()
189 .is_some_and(|f| contains_ci(&f.to_string_lossy(), &q))
190 })
191 .map(|(i, _)| i)
192 .collect()
193 }
194 }
195
196 /// Case-insensitive substring test that does not allocate for ASCII text.
197 /// Search ran `to_lowercase()` on five fields of every track for every
198 /// keystroke, which is five heap allocations per track per character typed.
199 fn contains_ci(haystack: &str, needle_lower: &str) -> bool {
200 if needle_lower.is_empty() {
201 return true;
202 }
203 if haystack.is_ascii() {
204 let hay = haystack.as_bytes();
205 let ned = needle_lower.as_bytes();
206 return ned.len() <= hay.len()
207 && hay.windows(ned.len()).any(|w| w.eq_ignore_ascii_case(ned));
208 }
209 haystack.to_lowercase().contains(needle_lower)
210 }
211
212 impl Index {
213 fn build(tracks: &[Track]) -> Self {
214 let mut by_artist: HashMap<String, Vec<usize>> = HashMap::new();
215 let mut by_album: HashMap<(String, String), Vec<usize>> = HashMap::new();
216 let mut by_genre: HashMap<String, Vec<usize>> = HashMap::new();
217 let mut by_format: HashMap<String, Vec<usize>> = HashMap::new();
218 let mut by_path: HashMap<PathBuf, usize> = HashMap::with_capacity(tracks.len());
219 let mut directories: BTreeSet<String> = BTreeSet::new();
220
221 for (i, t) in tracks.iter().enumerate() {
222 let artist = if t.artist.is_empty() { UNKNOWN_ARTIST } else { &t.artist };
223 by_artist.entry(artist.to_string()).or_default().push(i);
224
225 if !t.album.is_empty() {
226 let key = (t.album.clone(), Library::album_artist_of(t).to_string());
227 by_album.entry(key).or_default().push(i);
228 }
229 if !t.genre.is_empty() {
230 by_genre.entry(t.genre.clone()).or_default().push(i);
231 }
232 let ext = Library::extension_of(t);
233 if !ext.is_empty() {
234 by_format.entry(ext).or_default().push(i);
235 }
236 // First path wins, matching the old `position()` behaviour when a
237 // symlink makes the same file appear under two paths.
238 by_path.entry(t.path.clone()).or_insert(i);
239
240 if let Some(name) = t
241 .path
242 .parent()
243 .and_then(|p| p.file_name())
244 .and_then(|n| n.to_str())
245 {
246 directories.insert(name.to_string());
247 }
248 }
249
250 // "Unknown Artist" sorts last, as it did when it was appended after the
251 // sorted set.
252 let mut artists: Vec<String> = by_artist.keys().cloned().collect();
253 artists.sort_by(|a, b| match (a == UNKNOWN_ARTIST, b == UNKNOWN_ARTIST) {
254 (true, false) => std::cmp::Ordering::Greater,
255 (false, true) => std::cmp::Ordering::Less,
256 _ => a.cmp(b),
257 });
258
259 let albums: Vec<(String, String)> =
260 by_album.keys().cloned().collect::<BTreeSet<_>>().into_iter().collect();
261 let genres: Vec<String> =
262 by_genre.keys().cloned().collect::<BTreeSet<_>>().into_iter().collect();
263 let formats: Vec<(String, usize)> = by_format
264 .iter()
265 .map(|(k, v)| (k.clone(), v.len()))
266 .collect::<BTreeMap<_, _>>()
267 .into_iter()
268 .collect();
269
270 Self {
271 artists,
272 albums,
273 genres,
274 formats,
275 directories: directories.into_iter().collect(),
276 by_artist,
277 by_album,
278 by_genre,
279 by_format,
280 by_path,
281 }
282 }
283 }
284
285 #[cfg(test)]
286 mod tests {
287 use super::*;
288 use std::time::Duration;
289
290 fn t(artist: &str, album_artist: &str, album: &str, genre: &str, path: &str) -> Track {
291 Track {
292 path: PathBuf::from(path),
293 title: path.to_string(),
294 artist: artist.to_string(),
295 album: album.to_string(),
296 album_artist: album_artist.to_string(),
297 genre: genre.to_string(),
298 year: None,
299 track_number: None,
300 track_total: None,
301 disc_number: None,
302 disc_total: None,
303 comment: None,
304 duration: Duration::ZERO,
305 bitrate: None,
306 sample_rate: None,
307 bit_depth: None,
308 channels: None,
309 }
310 }
311
312 fn library() -> Library {
313 Library::from_tracks(vec![
314 t("b", "", "Hits", "rock", "/m/b/1.flac"),
315 t("a", "", "Hits", "pop", "/m/a/2.mp3"),
316 t("", "", "Loose", "", "/m/c/3.flac"),
317 t("a", "", "Hits", "pop", "/m/a/4.flac"),
318 ])
319 }
320
321 #[test]
322 fn artists_are_sorted_with_unknown_last() {
323 assert_eq!(library().artists(), ["a", "b", UNKNOWN_ARTIST]);
324 }
325
326 #[test]
327 fn albums_keep_their_artist_so_same_named_records_stay_separate() {
328 let lib = library();
329 // "Hits" exists under both a and b and must not be merged.
330 assert_eq!(
331 lib.albums(),
332 [
333 ("Hits".into(), "a".into()),
334 ("Hits".into(), "b".into()),
335 ("Loose".into(), "".into()),
336 ]
337 );
338 assert_eq!(lib.tracks_by_album("Hits", "a"), [1, 3]);
339 assert_eq!(lib.tracks_by_album("Hits", "b"), [0]);
340 }
341
342 #[test]
343 fn untagged_tracks_group_under_unknown_artist() {
344 assert_eq!(library().tracks_by_artist(UNKNOWN_ARTIST), [2]);
345 assert_eq!(library().tracks_by_artist("a"), [1, 3]);
346 assert!(library().tracks_by_artist("nobody").is_empty());
347 }
348
349 #[test]
350 fn formats_carry_their_counts_and_index_their_tracks() {
351 let lib = library();
352 assert_eq!(lib.formats(), [("flac".into(), 3usize), ("mp3".into(), 1)]);
353 assert_eq!(lib.tracks_by_format("flac"), [0, 2, 3]);
354 }
355
356 #[test]
357 fn lookups_resolve_by_path_and_directory() {
358 let lib = library();
359 assert_eq!(lib.path_to_index(Path::new("/m/a/4.flac")), Some(3));
360 assert_eq!(lib.path_to_index(Path::new("/m/nope.flac")), None);
361 assert_eq!(lib.directories(), ["a", "b", "c"]);
362 }
363
364 #[test]
365 fn extension_search_uses_the_index_and_matches_the_scan() {
366 assert_eq!(library().search("*.flac"), vec![0, 2, 3]);
367 }
368
369 #[test]
370 fn case_insensitive_match_agrees_with_the_allocating_version() {
371 for (hay, needle) in [
372 ("Ghost In A Flower", "flower"),
373 ("Ghost In A Flower", "GHOST"),
374 ("肺", "肺"),
375 ("ヨルシカ Elma", "elma"),
376 ("short", "much longer needle"),
377 ("anything", ""),
378 ] {
379 assert_eq!(
380 contains_ci(hay, &needle.to_lowercase()),
381 hay.to_lowercase().contains(&needle.to_lowercase()),
382 "{hay:?} contains {needle:?}"
383 );
384 }
385 }
386 }