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/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 }