1use std::fs;
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use sha2::{Digest, Sha256};
11use xil_core::fsutil::basename;
12
13use crate::episodes::load_stems;
14
15pub const AUDIO_CACHE_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
17const COPY_CHUNK: usize = 4 * 1024 * 1024;
18
19pub fn audio_cache_dir() -> PathBuf {
21 let base = std::env::var("XDG_CACHE_HOME")
22 .ok()
23 .filter(|v| !v.is_empty())
24 .map(PathBuf::from)
25 .unwrap_or_else(|| {
26 std::env::var("HOME")
27 .map(PathBuf::from)
28 .unwrap_or_else(|_| PathBuf::from("."))
29 .join(".cache")
30 });
31 let d = base.join("xil-gui").join("audio");
32 let _ = fs::create_dir_all(&d);
33 d
34}
35
36fn mtime_ns(m: &fs::Metadata) -> u128 {
37 m.modified()
38 .ok()
39 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
40 .map(|d| d.as_nanos())
41 .unwrap_or(0)
42}
43
44fn hex(digest: &[u8]) -> String {
45 digest.iter().map(|b| format!("{b:02x}")).collect()
46}
47
48fn touch(path: &Path) {
49 let _ = filetime::set_file_mtime(path, filetime::FileTime::now());
50}
51
52pub fn evict_audio_cache(cache_dir: &Path, keep: &Path) {
55 let Ok(rd) = fs::read_dir(cache_dir) else {
56 return;
57 };
58 let mut entries: Vec<(SystemTime, u64, PathBuf)> = rd
59 .filter_map(Result::ok)
60 .filter_map(|e| {
61 let p = e.path();
62 let m = e.metadata().ok()?;
63 (m.is_file() && p != keep).then(|| (m.modified().unwrap_or(UNIX_EPOCH), m.len(), p))
64 })
65 .collect();
66 let mut total: u64 = entries.iter().map(|e| e.1).sum();
67 total += fs::metadata(keep).map(|m| m.len()).unwrap_or(0);
68 entries.sort();
69 for (_, size, path) in entries {
70 if total <= AUDIO_CACHE_MAX_BYTES {
71 break;
72 }
73 if fs::remove_file(&path).is_ok() {
74 total -= size;
75 }
76 }
77}
78
79pub fn cached_audio_path(src: &Path) -> PathBuf {
82 let attempt = || -> std::io::Result<PathBuf> {
83 let m = fs::metadata(src)?;
84 let dir = audio_cache_dir();
85 let abs = std::path::absolute(src)?;
86 let key = hex(&Sha256::digest(
87 format!("{}|{}|{}", abs.display(), m.len(), mtime_ns(&m)).as_bytes(),
88 ));
89 let ext = src
90 .extension()
91 .map(|e| format!(".{}", e.to_string_lossy()))
92 .unwrap_or_default();
93 let dest = dir.join(format!("{key}{ext}"));
94 if dest.exists() {
95 touch(&dest);
96 return Ok(dest);
97 }
98 let part = dir.join(format!("{key}{ext}.part"));
99 {
100 let mut fin = fs::File::open(src)?;
101 let mut fout = fs::File::create(&part)?;
102 let mut buf = vec![0u8; COPY_CHUNK];
103 loop {
104 let n = fin.read(&mut buf)?;
105 if n == 0 {
106 break;
107 }
108 fout.write_all(&buf[..n])?;
109 }
110 }
111 fs::rename(&part, &dest)?;
112 evict_audio_cache(&dir, &dest);
113 Ok(dest)
114 };
115 attempt().unwrap_or_else(|_| src.to_path_buf())
116}
117
118pub fn concatenate_stems(slug: &str, tag: &str, filter: &str) -> Option<PathBuf> {
125 let stems = load_stems(slug, tag, filter);
126 if stems.is_empty() {
127 return None;
128 }
129 let mut sig = vec![filter.to_string()];
130 for (_, path) in &stems {
131 let m = fs::metadata(path).ok()?;
132 let abs = std::path::absolute(path).ok()?;
133 sig.push(format!("{}|{}|{}", abs.display(), m.len(), mtime_ns(&m)));
134 }
135 let key = hex(&Sha256::digest(sig.join("\n").as_bytes()));
136 let dir = audio_cache_dir();
137 let out = dir.join(format!("concat_{key}.mp3"));
138 if out.exists() {
139 touch(&out);
140 return Some(out);
141 }
142 let part = dir.join(format!("concat_{key}.part.mp3"));
143 let mut cmd = Command::new("ffmpeg");
144 cmd.args(["-hide_banner", "-loglevel", "error", "-y"]);
145 let mut graph = String::new();
146 for (i, (_, path)) in stems.iter().enumerate() {
147 cmd.arg("-i").arg(cached_audio_path(path));
148 graph.push_str(&format!(
149 "[{i}:a]aresample=44100,aformat=sample_fmts=s16:channel_layouts=stereo[a{i}];"
150 ));
151 }
152 for i in 0..stems.len() {
153 graph.push_str(&format!("[a{i}]"));
154 }
155 graph.push_str(&format!("concat=n={}:v=0:a=1[out]", stems.len()));
156 cmd.args(["-filter_complex", &graph, "-map", "[out]", "-b:a", "128k"])
157 .arg(&part);
158 let ok = cmd.output().map(|o| o.status.success()).unwrap_or(false);
159 if !ok {
160 let _ = fs::remove_file(&part);
161 return None;
162 }
163 fs::rename(&part, &out).ok()?;
164 evict_audio_cache(&dir, &out);
165 Some(out)
166}
167
168pub fn cache_url(path: &Path) -> Option<String> {
171 let dir = audio_cache_dir();
172 path.parent()
173 .filter(|p| *p == dir)
174 .map(|_| format!("/cache/{}", basename(path)))
175}