use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::webui::config_io::atomic_write;
use serde::{Deserialize, Serialize};
const CACHE_VERSION: u32 = 2;
const CACHE_FILENAME: &str = ".mtrack-cache.json";
#[derive(Serialize, Deserialize)]
struct SongCache {
version: u32,
tracks: HashMap<String, FileCacheEntry>,
}
#[derive(Serialize, Deserialize)]
struct FileCacheEntry {
mtime_secs: u64,
mtime_nanos: u32,
size: u64,
channels: HashMap<String, ChannelCache>,
}
#[derive(Serialize, Deserialize, Clone)]
struct ChannelCache {
peaks: Vec<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
beat_grid: Option<crate::audio::click_analysis::BeatGrid>,
}
struct FileMeta {
mtime_secs: u64,
mtime_nanos: u32,
size: u64,
}
fn get_file_meta(path: &Path) -> Option<FileMeta> {
let metadata = fs::metadata(path).ok()?;
let mtime = metadata.modified().ok()?;
let since_epoch = mtime.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(FileMeta {
mtime_secs: since_epoch.as_secs(),
mtime_nanos: since_epoch.subsec_nanos(),
size: metadata.len(),
})
}
fn meta_matches(entry: &FileCacheEntry, meta: &FileMeta) -> bool {
entry.mtime_secs == meta.mtime_secs
&& entry.mtime_nanos == meta.mtime_nanos
&& entry.size == meta.size
}
fn filename_key(file: &Path) -> Option<String> {
file.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
}
fn read_cache(song_dir: &Path) -> Option<SongCache> {
let cache_path = song_dir.join(CACHE_FILENAME);
let content = fs::read_to_string(&cache_path).ok()?;
let cache: SongCache = serde_json::from_str(&content).ok()?;
if cache.version != CACHE_VERSION {
return None;
}
Some(cache)
}
fn is_valid_cache_dir(song_dir: &Path) -> bool {
!song_dir.as_os_str().is_empty() && song_dir.is_dir()
}
pub fn load_cached_peaks(
song_dir: &Path,
tracks: &[(String, PathBuf, u16)],
) -> HashMap<String, Vec<f32>> {
let mut result = HashMap::new();
if !is_valid_cache_dir(song_dir) {
return result;
}
let cache = match read_cache(song_dir) {
Some(c) => c,
None => return result,
};
for (track_name, file, channel) in tracks {
let key = match filename_key(file) {
Some(k) => k,
None => continue,
};
let entry = match cache.tracks.get(&key) {
Some(e) => e,
None => continue,
};
let meta = match get_file_meta(file) {
Some(m) => m,
None => continue,
};
if !meta_matches(entry, &meta) {
continue;
}
let channel_key = channel.to_string();
if let Some(ch_cache) = entry.channels.get(&channel_key) {
if !ch_cache.peaks.is_empty() {
result.insert(track_name.clone(), ch_cache.peaks.clone());
}
}
}
result
}
pub fn save_peaks(
song_dir: &Path,
peaks: &[(String, PathBuf, u16, Vec<f32>)],
) -> Result<(), String> {
if !is_valid_cache_dir(song_dir) {
return Ok(());
}
let mut cache = read_cache(song_dir).unwrap_or(SongCache {
version: CACHE_VERSION,
tracks: HashMap::new(),
});
for (_track_name, file, channel, peak_data) in peaks {
let key = match filename_key(file) {
Some(k) => k,
None => continue,
};
let meta = match get_file_meta(file) {
Some(m) => m,
None => continue,
};
let entry = cache.tracks.entry(key).or_insert_with(|| FileCacheEntry {
mtime_secs: meta.mtime_secs,
mtime_nanos: meta.mtime_nanos,
size: meta.size,
channels: HashMap::new(),
});
entry.mtime_secs = meta.mtime_secs;
entry.mtime_nanos = meta.mtime_nanos;
entry.size = meta.size;
let channel_key = channel.to_string();
let existing_tempo_map = entry
.channels
.get(&channel_key)
.and_then(|c| c.beat_grid.clone());
entry.channels.insert(
channel_key,
ChannelCache {
peaks: peak_data.clone(),
beat_grid: existing_tempo_map,
},
);
}
cache.version = CACHE_VERSION;
let json = serde_json::to_string_pretty(&cache)
.map_err(|e| format!("Failed to serialize song cache: {}", e))?;
let cache_path = song_dir.join(CACHE_FILENAME);
atomic_write(&cache_path, &json)
}
pub fn load_cached_beat_grid(
song_dir: &Path,
file: &Path,
channel: u16,
) -> Option<crate::audio::click_analysis::BeatGrid> {
if !is_valid_cache_dir(song_dir) {
return None;
}
let cache = read_cache(song_dir)?;
let key = filename_key(file)?;
let entry = cache.tracks.get(&key)?;
let meta = get_file_meta(file)?;
if !meta_matches(entry, &meta) {
return None;
}
let channel_key = channel.to_string();
entry.channels.get(&channel_key)?.beat_grid.clone()
}
pub fn save_beat_grid(
song_dir: &Path,
file: &Path,
channel: u16,
map: &crate::audio::click_analysis::BeatGrid,
) -> Result<(), String> {
if !is_valid_cache_dir(song_dir) {
return Ok(());
}
let mut cache = read_cache(song_dir).unwrap_or(SongCache {
version: CACHE_VERSION,
tracks: HashMap::new(),
});
let key = match filename_key(file) {
Some(k) => k,
None => return Ok(()),
};
let meta = match get_file_meta(file) {
Some(m) => m,
None => return Ok(()),
};
let entry = cache.tracks.entry(key).or_insert_with(|| FileCacheEntry {
mtime_secs: meta.mtime_secs,
mtime_nanos: meta.mtime_nanos,
size: meta.size,
channels: HashMap::new(),
});
entry.mtime_secs = meta.mtime_secs;
entry.mtime_nanos = meta.mtime_nanos;
entry.size = meta.size;
let channel_key = channel.to_string();
let existing_peaks = entry
.channels
.get(&channel_key)
.map(|c| c.peaks.clone())
.unwrap_or_default();
entry.channels.insert(
channel_key,
ChannelCache {
peaks: existing_peaks,
beat_grid: Some(map.clone()),
},
);
cache.version = CACHE_VERSION;
let json = serde_json::to_string_pretty(&cache)
.map_err(|e| format!("Failed to serialize song cache: {}", e))?;
let cache_path = song_dir.join(CACHE_FILENAME);
atomic_write(&cache_path, &json)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn create_test_audio_file(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
f.write_all(content).unwrap();
path
}
#[test]
fn load_returns_empty_when_no_cache_file() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let tracks = vec![("click".to_string(), file, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert!(result.is_empty());
}
#[test]
fn save_and_load_roundtrip() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let peaks = vec![0.1, 0.5, 1.0, 0.3];
save_peaks(
dir.path(),
&[("click".to_string(), file.clone(), 1u16, peaks.clone())],
)
.unwrap();
let tracks = vec![("click".to_string(), file, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert_eq!(result.get("click").unwrap(), &peaks);
}
#[test]
fn cache_invalidated_when_file_changes() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let peaks = vec![0.1, 0.5, 1.0];
save_peaks(
dir.path(),
&[("click".to_string(), file.clone(), 1u16, peaks)],
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
fs::write(&file, b"modified audio data that is longer").unwrap();
let tracks = vec![("click".to_string(), file, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert!(result.is_empty());
}
#[test]
fn corrupt_cache_file_returns_empty() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
fs::write(dir.path().join(CACHE_FILENAME), "not valid json{{{").unwrap();
let tracks = vec![("click".to_string(), file, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert!(result.is_empty());
}
#[test]
fn version_mismatch_returns_empty() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let json = r#"{"version": 999, "tracks": {}}"#;
fs::write(dir.path().join(CACHE_FILENAME), json).unwrap();
let tracks = vec![("click".to_string(), file, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert!(result.is_empty());
}
#[test]
fn save_merges_with_existing_cache() {
let dir = TempDir::new().unwrap();
let file1 = create_test_audio_file(dir.path(), "click.wav", b"click data");
let file2 = create_test_audio_file(dir.path(), "backing.flac", b"backing data");
save_peaks(
dir.path(),
&[("click".to_string(), file1.clone(), 1u16, vec![0.1, 0.2])],
)
.unwrap();
save_peaks(
dir.path(),
&[("backing".to_string(), file2.clone(), 1u16, vec![0.5, 0.6])],
)
.unwrap();
let tracks = vec![
("click".to_string(), file1, 1u16),
("backing".to_string(), file2, 1u16),
];
let result = load_cached_peaks(dir.path(), &tracks);
assert_eq!(result.get("click").unwrap(), &vec![0.1, 0.2]);
assert_eq!(result.get("backing").unwrap(), &vec![0.5, 0.6]);
}
#[test]
fn multiple_channels_same_file() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "stereo.wav", b"stereo data");
save_peaks(
dir.path(),
&[
("stereo-l".to_string(), file.clone(), 1u16, vec![0.1, 0.2]),
("stereo-r".to_string(), file.clone(), 2u16, vec![0.8, 0.9]),
],
)
.unwrap();
let tracks = vec![
("stereo-l".to_string(), file.clone(), 1u16),
("stereo-r".to_string(), file, 2u16),
];
let result = load_cached_peaks(dir.path(), &tracks);
assert_eq!(result.get("stereo-l").unwrap(), &vec![0.1, 0.2]);
assert_eq!(result.get("stereo-r").unwrap(), &vec![0.8, 0.9]);
}
#[test]
fn missing_audio_file_skipped() {
let dir = TempDir::new().unwrap();
let nonexistent = dir.path().join("missing.wav");
let tracks = vec![("missing".to_string(), nonexistent, 1u16)];
let result = load_cached_peaks(dir.path(), &tracks);
assert!(result.is_empty());
}
#[test]
fn cache_file_created_with_pretty_json() {
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
save_peaks(dir.path(), &[("click".to_string(), file, 1u16, vec![0.5])]).unwrap();
let content = fs::read_to_string(dir.path().join(CACHE_FILENAME)).unwrap();
assert!(content.contains('\n'));
assert!(content.contains(&format!("\"version\": {}", CACHE_VERSION)));
}
#[test]
fn beat_grid_save_load_roundtrip() {
use crate::audio::click_analysis::BeatGrid;
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let grid = BeatGrid {
beats: vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5],
measure_starts: vec![0, 4],
};
save_beat_grid(dir.path(), &file, 1, &grid).unwrap();
let loaded = load_cached_beat_grid(dir.path(), &file, 1);
assert_eq!(loaded, Some(grid));
}
#[test]
fn beat_grid_preserved_when_saving_peaks() {
use crate::audio::click_analysis::BeatGrid;
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let grid = BeatGrid {
beats: vec![0.0, 0.5, 1.0, 1.5],
measure_starts: vec![0],
};
save_beat_grid(dir.path(), &file, 1, &grid).unwrap();
save_peaks(
dir.path(),
&[("click".to_string(), file.clone(), 1u16, vec![0.5, 0.8])],
)
.unwrap();
let loaded = load_cached_beat_grid(dir.path(), &file, 1);
assert_eq!(loaded, Some(grid));
let tracks = vec![("click".to_string(), file, 1u16)];
let peaks = load_cached_peaks(dir.path(), &tracks);
assert_eq!(peaks.get("click").unwrap(), &vec![0.5, 0.8]);
}
#[test]
fn beat_grid_invalidated_on_file_change() {
use crate::audio::click_analysis::BeatGrid;
let dir = TempDir::new().unwrap();
let file = create_test_audio_file(dir.path(), "click.wav", b"audio data");
let grid = BeatGrid {
beats: vec![0.0, 0.5, 1.0, 1.5],
measure_starts: vec![0],
};
save_beat_grid(dir.path(), &file, 1, &grid).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
fs::write(&file, b"modified audio data that is longer").unwrap();
let loaded = load_cached_beat_grid(dir.path(), &file, 1);
assert!(loaded.is_none());
}
}