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/cache.rs (5419B)

      1 //! On-disk cache of parsed tags, so only files that actually changed are read
      2 //! again.
      3 //!
      4 //! Scanning is dominated by opening and header-parsing every file: on a cold
      5 //! page cache that measured 0.72 ms per track, or roughly 3.6 seconds for a
      6 //! 5,000-track library, every single launch. A cached entry is reused whenever
      7 //! the file's modification time and size still match, which reduces a repeat
      8 //! scan to one `stat` per file.
      9 
     10 use std::collections::HashMap;
     11 use std::fs;
     12 use std::path::PathBuf;
     13 
     14 use serde::{Deserialize, Serialize};
     15 
     16 use super::track::Track;
     17 
     18 /// Bump when `Track`'s shape changes so old entries are discarded rather than
     19 /// deserialised into the wrong fields.
     20 const VERSION: u32 = 1;
     21 
     22 #[derive(Serialize, Deserialize)]
     23 pub struct Entry {
     24     pub track: Track,
     25     /// Seconds since the epoch; paired with `size` to detect edits.
     26     pub mtime: u64,
     27     pub size: u64,
     28 }
     29 
     30 #[derive(Deserialize)]
     31 struct CacheFile {
     32     version: u32,
     33     entries: Vec<Entry>,
     34 }
     35 
     36 /// Borrowing counterpart, so saving does not clone every entry.
     37 #[derive(Serialize)]
     38 struct CacheFileRef<'a> {
     39     version: u32,
     40     entries: &'a [Entry],
     41 }
     42 
     43 fn cache_path() -> Option<PathBuf> {
     44     let base = std::env::var_os("XDG_CACHE_HOME")
     45         .map(PathBuf::from)
     46         .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
     47     Some(base.join("ommp").join("library.json"))
     48 }
     49 
     50 /// Cached entries keyed by path. Any failure yields an empty map, which simply
     51 /// means every file gets parsed — the cache is never load-bearing for
     52 /// correctness.
     53 pub fn load() -> HashMap<PathBuf, Entry> {
     54     let Some(path) = cache_path() else {
     55         return HashMap::new();
     56     };
     57     let Ok(data) = fs::read_to_string(path) else {
     58         return HashMap::new();
     59     };
     60     let Ok(cache) = serde_json::from_str::<CacheFile>(&data) else {
     61         return HashMap::new();
     62     };
     63     if cache.version != VERSION {
     64         return HashMap::new();
     65     }
     66     cache
     67         .entries
     68         .into_iter()
     69         .map(|e| (e.track.path.clone(), e))
     70         .collect()
     71 }
     72 
     73 pub fn save(entries: &[Entry]) {
     74     let Some(path) = cache_path() else { return };
     75     if let Some(parent) = path.parent() {
     76         if fs::create_dir_all(parent).is_err() {
     77             return;
     78         }
     79     }
     80     let cache = CacheFileRef { version: VERSION, entries };
     81     let Ok(json) = serde_json::to_string(&cache) else {
     82         return;
     83     };
     84     // Write-then-rename, so an interrupted save leaves the previous cache
     85     // intact instead of a truncated file that parses to nothing.
     86     let tmp = path.with_extension("json.tmp");
     87     if fs::write(&tmp, json).is_ok() {
     88         let _ = fs::rename(&tmp, &path);
     89     }
     90 }
     91 
     92 /// File identity as far as the cache is concerned.
     93 pub fn stamp(meta: &fs::Metadata) -> (u64, u64) {
     94     let mtime = meta
     95         .modified()
     96         .ok()
     97         .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
     98         .map(|d| d.as_secs())
     99         .unwrap_or(0);
    100     (mtime, meta.len())
    101 }
    102 
    103 /// True when the cached entry still describes the file on disk.
    104 pub fn is_fresh(entry: &Entry, stamp: (u64, u64)) -> bool {
    105     entry.mtime == stamp.0 && entry.size == stamp.1
    106 }
    107 
    108 #[cfg(test)]
    109 mod tests {
    110     use super::*;
    111     use std::time::Duration;
    112 
    113     fn entry() -> Entry {
    114         Entry {
    115             track: Track {
    116                 path: PathBuf::from("/m/肺.flac"),
    117                 title: "肺".into(),
    118                 artist: "a子".into(),
    119                 album: "肺".into(),
    120                 album_artist: "a子".into(),
    121                 genre: String::new(),
    122                 year: Some(2020),
    123                 track_number: Some(1),
    124                 track_total: Some(1),
    125                 disc_number: Some(1),
    126                 disc_total: Some(1),
    127                 comment: Some("note".into()),
    128                 duration: Duration::from_secs(192),
    129                 bitrate: Some(866),
    130                 sample_rate: Some(44100),
    131                 bit_depth: Some(16),
    132                 channels: Some(2),
    133             },
    134             mtime: 1_700_000_000,
    135             size: 21_715_800,
    136         }
    137     }
    138 
    139     #[test]
    140     fn a_cached_track_survives_the_round_trip_intact() {
    141         let json = serde_json::to_string(&CacheFileRef { version: VERSION, entries: &[entry()] })
    142             .unwrap();
    143         let back: CacheFile = serde_json::from_str(&json).unwrap();
    144 
    145         let (a, b) = (&entry().track, &back.entries[0].track);
    146         assert_eq!(a.path, b.path);
    147         assert_eq!(a.title, b.title);
    148         assert_eq!(a.artist, b.artist);
    149         assert_eq!(a.year, b.year);
    150         assert_eq!(a.duration, b.duration);
    151         assert_eq!(a.sample_rate, b.sample_rate);
    152         assert_eq!(a.bit_depth, b.bit_depth);
    153         assert_eq!(a.channels, b.channels);
    154         assert_eq!(a.comment, b.comment);
    155     }
    156 
    157     #[test]
    158     fn an_edited_file_invalidates_its_entry() {
    159         let e = entry();
    160         assert!(is_fresh(&e, (e.mtime, e.size)));
    161         // Retagging changes both in practice; either alone is enough.
    162         assert!(!is_fresh(&e, (e.mtime + 1, e.size)));
    163         assert!(!is_fresh(&e, (e.mtime, e.size + 1)));
    164     }
    165 
    166     #[test]
    167     fn a_cache_from_an_older_build_is_ignored_rather_than_misread() {
    168         let json = format!(r#"{{"version":{},"entries":[]}}"#, VERSION + 1);
    169         let cache: CacheFile = serde_json::from_str(&json).unwrap();
    170         assert_ne!(cache.version, VERSION);
    171     }
    172 }