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 }