ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

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 }