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
commit 5309fc5c99d371a2d035996dd6a4d6b21bd3d9e0
parent d4e77dde3a0dd010e63847bd941a9b748e6e336a
Author: sqzer-x <110329244+sqzer-x@users.noreply.github.com>
Date: Sat, 1 Aug 2026 17:06:59 +0900

Scan in parallel and cache parsed tags between runs

Scanning opened and header-parsed every file on every launch, one at a
time. Measured on 5,000 files:

serial, no cache 362 ms
parallel, no cache 137 ms
parallel + cache 18 ms (31 ms with a cold page cache)

Files are now parsed across all cores with std::thread::scope, and parsed tags
are stored in $XDG_CACHE_HOME/ommp/library.json. An entry is reused when the
file's modification time and size still match, so a repeat scan costs one stat
per file instead of an open and a header parse; retagging or replacing a file
changes both and re-reads just that one.

The cache is never load-bearing: an unreadable, corrupt or older-version file
yields an empty map and everything gets parsed, and it is written via a
temporary file and renamed so an interrupted save keeps the previous one. All
5,000 tracks were verified byte-identical between a cached load and a fresh
parse.

The benchmark numbers understate the cold-disk case: the test set was
hardlinked, so it shared 50 inodes. Against genuinely distinct files the
uncached path measured 0.72 ms per track, about 3.6 seconds for 5,000.

Diffstat:
Asrc/library/cache.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/library/mod.rs | 1+
Msrc/library/scanner.rs | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Msrc/library/track.rs | 5++++-
4 files changed, 270 insertion(+), 36 deletion(-)

diff --git a/src/library/cache.rs b/src/library/cache.rs
@@ -0,0 +1,172 @@
+//! On-disk cache of parsed tags, so only files that actually changed are read
+//! again.
+//!
+//! Scanning is dominated by opening and header-parsing every file: on a cold
+//! page cache that measured 0.72 ms per track, or roughly 3.6 seconds for a
+//! 5,000-track library, every single launch. A cached entry is reused whenever
+//! the file's modification time and size still match, which reduces a repeat
+//! scan to one `stat` per file.
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+use super::track::Track;
+
+/// Bump when `Track`'s shape changes so old entries are discarded rather than
+/// deserialised into the wrong fields.
+const VERSION: u32 = 1;
+
+#[derive(Serialize, Deserialize)]
+pub struct Entry {
+    pub track: Track,
+    /// Seconds since the epoch; paired with `size` to detect edits.
+    pub mtime: u64,
+    pub size: u64,
+}
+
+#[derive(Deserialize)]
+struct CacheFile {
+    version: u32,
+    entries: Vec<Entry>,
+}
+
+/// Borrowing counterpart, so saving does not clone every entry.
+#[derive(Serialize)]
+struct CacheFileRef<'a> {
+    version: u32,
+    entries: &'a [Entry],
+}
+
+fn cache_path() -> Option<PathBuf> {
+    let base = std::env::var_os("XDG_CACHE_HOME")
+        .map(PathBuf::from)
+        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
+    Some(base.join("ommp").join("library.json"))
+}
+
+/// Cached entries keyed by path. Any failure yields an empty map, which simply
+/// means every file gets parsed — the cache is never load-bearing for
+/// correctness.
+pub fn load() -> HashMap<PathBuf, Entry> {
+    let Some(path) = cache_path() else {
+        return HashMap::new();
+    };
+    let Ok(data) = fs::read_to_string(path) else {
+        return HashMap::new();
+    };
+    let Ok(cache) = serde_json::from_str::<CacheFile>(&data) else {
+        return HashMap::new();
+    };
+    if cache.version != VERSION {
+        return HashMap::new();
+    }
+    cache
+        .entries
+        .into_iter()
+        .map(|e| (e.track.path.clone(), e))
+        .collect()
+}
+
+pub fn save(entries: &[Entry]) {
+    let Some(path) = cache_path() else { return };
+    if let Some(parent) = path.parent() {
+        if fs::create_dir_all(parent).is_err() {
+            return;
+        }
+    }
+    let cache = CacheFileRef { version: VERSION, entries };
+    let Ok(json) = serde_json::to_string(&cache) else {
+        return;
+    };
+    // Write-then-rename, so an interrupted save leaves the previous cache
+    // intact instead of a truncated file that parses to nothing.
+    let tmp = path.with_extension("json.tmp");
+    if fs::write(&tmp, json).is_ok() {
+        let _ = fs::rename(&tmp, &path);
+    }
+}
+
+/// File identity as far as the cache is concerned.
+pub fn stamp(meta: &fs::Metadata) -> (u64, u64) {
+    let mtime = meta
+        .modified()
+        .ok()
+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+        .map(|d| d.as_secs())
+        .unwrap_or(0);
+    (mtime, meta.len())
+}
+
+/// True when the cached entry still describes the file on disk.
+pub fn is_fresh(entry: &Entry, stamp: (u64, u64)) -> bool {
+    entry.mtime == stamp.0 && entry.size == stamp.1
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::time::Duration;
+
+    fn entry() -> Entry {
+        Entry {
+            track: Track {
+                path: PathBuf::from("/m/肺.flac"),
+                title: "肺".into(),
+                artist: "a子".into(),
+                album: "肺".into(),
+                album_artist: "a子".into(),
+                genre: String::new(),
+                year: Some(2020),
+                track_number: Some(1),
+                track_total: Some(1),
+                disc_number: Some(1),
+                disc_total: Some(1),
+                comment: Some("note".into()),
+                duration: Duration::from_secs(192),
+                bitrate: Some(866),
+                sample_rate: Some(44100),
+                bit_depth: Some(16),
+                channels: Some(2),
+            },
+            mtime: 1_700_000_000,
+            size: 21_715_800,
+        }
+    }
+
+    #[test]
+    fn a_cached_track_survives_the_round_trip_intact() {
+        let json = serde_json::to_string(&CacheFileRef { version: VERSION, entries: &[entry()] })
+            .unwrap();
+        let back: CacheFile = serde_json::from_str(&json).unwrap();
+
+        let (a, b) = (&entry().track, &back.entries[0].track);
+        assert_eq!(a.path, b.path);
+        assert_eq!(a.title, b.title);
+        assert_eq!(a.artist, b.artist);
+        assert_eq!(a.year, b.year);
+        assert_eq!(a.duration, b.duration);
+        assert_eq!(a.sample_rate, b.sample_rate);
+        assert_eq!(a.bit_depth, b.bit_depth);
+        assert_eq!(a.channels, b.channels);
+        assert_eq!(a.comment, b.comment);
+    }
+
+    #[test]
+    fn an_edited_file_invalidates_its_entry() {
+        let e = entry();
+        assert!(is_fresh(&e, (e.mtime, e.size)));
+        // Retagging changes both in practice; either alone is enough.
+        assert!(!is_fresh(&e, (e.mtime + 1, e.size)));
+        assert!(!is_fresh(&e, (e.mtime, e.size + 1)));
+    }
+
+    #[test]
+    fn a_cache_from_an_older_build_is_ignored_rather_than_misread() {
+        let json = format!(r#"{{"version":{},"entries":[]}}"#, VERSION + 1);
+        let cache: CacheFile = serde_json::from_str(&json).unwrap();
+        assert_ne!(cache.version, VERSION);
+    }
+}
diff --git a/src/library/mod.rs b/src/library/mod.rs
@@ -1,3 +1,4 @@
+pub mod cache;
 pub mod scanner;
 pub mod track;
 pub mod watcher;
diff --git a/src/library/scanner.rs b/src/library/scanner.rs
@@ -2,17 +2,24 @@ use rodio::Decoder;
 use std::fs::File;
 use std::io::BufReader;
 use std::panic;
-use std::path::Path;
+use std::path::{Path, PathBuf};
 use symphonia::core::formats::FormatOptions;
 use symphonia::core::io::MediaSourceStream;
 use symphonia::core::meta::MetadataOptions;
 use symphonia::core::probe::Hint;
 use walkdir::WalkDir;
 
+use super::cache::{self, Entry};
 use super::track::Track;
 
 const AUDIO_EXTENSIONS: &[&str] = &["flac", "mp3", "m4a", "ogg", "wav", "opus", "aac", "wma"];
 
+/// A candidate file and the stamp that decides whether the cache still applies.
+struct Candidate {
+    path: PathBuf,
+    stamp: (u64, u64),
+}
+
 /// Check if we can decode this file: try rodio first, then symphonia direct probe
 fn is_decodable(path: &Path) -> bool {
     let path = path.to_path_buf();
@@ -44,50 +51,101 @@ fn is_decodable(path: &Path) -> bool {
     result.unwrap_or(false)
 }
 
-pub fn scan_directory(path: &Path) -> Vec<Track> {
-    let mut tracks = Vec::new();
-
-    for entry in WalkDir::new(path)
+/// Walk the tree and list audio files with their stamps. Only `stat` here —
+/// walkdir has already done it, so this costs nothing extra.
+fn collect_candidates(root: &Path) -> Vec<Candidate> {
+    WalkDir::new(root)
         .follow_links(true)
         .into_iter()
         .filter_map(|e| e.ok())
-    {
-        let path = entry.path();
-        if !path.is_file() {
-            continue;
-        }
+        .filter_map(|entry| {
+            let path = entry.path();
 
-        // Skip macOS resource fork files
-        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
-            if name.starts_with("._") {
-                continue;
+            // Skip macOS resource fork files
+            if path
+                .file_name()
+                .and_then(|n| n.to_str())
+                .is_some_and(|n| n.starts_with("._"))
+            {
+                return None;
+            }
+
+            let ext = path.extension().and_then(|e| e.to_str())?.to_lowercase();
+            if !AUDIO_EXTENSIONS.contains(&ext.as_str()) {
+                return None;
             }
-        }
 
-        let ext = path
-            .extension()
-            .and_then(|e| e.to_str())
-            .map(|e| e.to_lowercase());
-
-        if let Some(ext) = ext {
-            if AUDIO_EXTENSIONS.contains(&ext.as_str()) {
-                if !is_decodable(path) {
-                    continue;
-                }
-                if let Some(track) = Track::from_path(path) {
-                    tracks.push(track);
-                }
+            let meta = entry.metadata().ok()?;
+            if !meta.is_file() {
+                return None;
             }
+            Some(Candidate { path: path.to_path_buf(), stamp: cache::stamp(&meta) })
+        })
+        .collect()
+}
+
+fn parse_one(candidate: &Candidate) -> Option<Entry> {
+    if !is_decodable(&candidate.path) {
+        return None;
+    }
+    Track::from_path(&candidate.path).map(|track| Entry {
+        track,
+        mtime: candidate.stamp.0,
+        size: candidate.stamp.1,
+    })
+}
+
+/// Parse across all cores. Reading a file is mostly waiting on the disk and
+/// then parsing a header, both of which overlap well; scanning was serial, so a
+/// large library paid the full per-file cost one file at a time.
+fn parse_all(candidates: &[Candidate]) -> Vec<Entry> {
+    if candidates.is_empty() {
+        return Vec::new();
+    }
+
+    let workers = std::thread::available_parallelism()
+        .map(|n| n.get())
+        .unwrap_or(4)
+        .min(candidates.len());
+    let chunk = candidates.len().div_ceil(workers);
+
+    std::thread::scope(|scope| {
+        let handles: Vec<_> = candidates
+            .chunks(chunk)
+            .map(|c| scope.spawn(move || c.iter().filter_map(parse_one).collect::<Vec<Entry>>()))
+            .collect();
+        handles
+            .into_iter()
+            // A panicking worker loses its chunk rather than the whole scan.
+            .flat_map(|h| h.join().unwrap_or_default())
+            .collect()
+    })
+}
+
+pub fn scan_directory(path: &Path) -> Vec<Track> {
+    let candidates = collect_candidates(path);
+    let mut cached = cache::load();
+
+    // Reuse whatever still matches on disk; parse the rest.
+    let mut entries: Vec<Entry> = Vec::with_capacity(candidates.len());
+    let mut stale: Vec<Candidate> = Vec::new();
+    for candidate in candidates {
+        match cached.remove(&candidate.path) {
+            Some(entry) if cache::is_fresh(&entry, candidate.stamp) => entries.push(entry),
+            _ => stale.push(candidate),
         }
     }
+    entries.extend(parse_all(&stale));
 
-    tracks.sort_by(|a, b| {
-        a.album_artist
-            .cmp(&b.album_artist)
-            .then(a.album.cmp(&b.album))
-            .then(a.track_number.cmp(&b.track_number))
-            .then(a.title.cmp(&b.title))
+    entries.sort_by(|a, b| {
+        a.track
+            .album_artist
+            .cmp(&b.track.album_artist)
+            .then(a.track.album.cmp(&b.track.album))
+            .then(a.track.track_number.cmp(&b.track.track_number))
+            .then(a.track.title.cmp(&b.track.title))
     });
 
-    tracks
+    cache::save(&entries);
+    entries.into_iter().map(|e| e.track).collect()
 }
diff --git a/src/library/track.rs b/src/library/track.rs
@@ -2,10 +2,13 @@ use lofty::config::ParseOptions;
 use lofty::file::AudioFile;
 use lofty::prelude::*;
 use lofty::probe::Probe;
+use serde::{Deserialize, Serialize};
 use std::path::{Path, PathBuf};
 use std::time::Duration;
 
-#[derive(Debug, Clone)]
+/// Serializable so the scan cache can store parsed tags verbatim; every field
+/// here came out of the file, so restoring one is equivalent to re-reading it.
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Track {
     pub path: PathBuf,
     pub title: String,