1use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use serde_json::{Map, Value};
13use xil_audio::tags::{read_sfx_grade, write_sfx_grade, SFX_GRADE_ACCURATE, SFX_GRADE_REJECTED};
14use xil_core::fsutil::{basename, glob_recursive, relpath};
15use xil_core::pyjson::{dumps, Style};
16use xil_core::workspace::workspace_root;
17
18use crate::AppState;
19
20const CACHE_VERSION: i64 = 1;
21
22pub fn is_grade(g: &str) -> bool {
23 g == SFX_GRADE_ACCURATE || g == SFX_GRADE_REJECTED
24}
25
26pub fn glyph(grade: &str) -> &'static str {
28 match grade {
29 SFX_GRADE_ACCURATE => "✓",
30 SFX_GRADE_REJECTED => "✗",
31 _ => "•",
32 }
33}
34
35pub fn sfx_dir(slug: &str) -> PathBuf {
37 let root = workspace_root();
38 if !slug.is_empty() {
39 let per_show = root.join("SFX").join(slug);
40 if per_show.is_dir() {
41 return per_show;
42 }
43 }
44 root.join("SFX")
45}
46
47pub fn grade_cache_path() -> PathBuf {
49 sfx_dir("").join(".xil_grade_cache.json")
50}
51
52pub fn load_grade_cache_file() -> Map<String, Value> {
55 let Ok(text) = fs::read_to_string(grade_cache_path()) else {
56 return Map::new();
57 };
58 let Ok(Value::Object(data)) = serde_json::from_str::<Value>(&text) else {
59 return Map::new();
60 };
61 if data.get("version").and_then(Value::as_i64) != Some(CACHE_VERSION) {
62 return Map::new();
63 }
64 match data.get("files") {
65 Some(Value::Object(files)) => files.clone(),
66 _ => Map::new(),
67 }
68}
69
70pub fn save_grade_cache_file(files: &Map<String, Value>) {
72 let mut data = Map::new();
73 data.insert("version".into(), CACHE_VERSION.into());
74 data.insert("files".into(), Value::Object(files.clone()));
75 let _ = fs::write(
76 grade_cache_path(),
77 dumps(&Value::Object(data), Style::INDENT2) + "\n",
78 );
79}
80
81fn stat(path: &Path) -> Option<(u64, u128)> {
82 let m = fs::metadata(path).ok()?;
83 let mtime_ns = m
84 .modified()
85 .ok()?
86 .duration_since(UNIX_EPOCH)
87 .ok()?
88 .as_nanos();
89 Some((m.len(), mtime_ns))
90}
91
92fn record(grade: &str, size: u64, mtime_ns: u128) -> Value {
93 let mut r = Map::new();
94 r.insert("grade".into(), grade.into());
95 r.insert("size".into(), size.into());
96 r.insert("mtime_ns".into(), (mtime_ns as i64).into());
98 Value::Object(r)
99}
100
101pub fn scan_sfx_grades(state: &AppState) {
104 let mut cache = state.grade_cache.lock().unwrap_or_else(|e| e.into_inner());
105 cache.clear();
106 let dir = sfx_dir("");
107 if !dir.is_dir() {
108 return;
109 }
110 let persisted = load_grade_cache_file();
111 let mut fresh = Map::new();
112 let mut changed = false;
113 for path in glob_recursive(&dir, "", ".mp3") {
114 let Some((size, mtime_ns)) = stat(&path) else {
115 continue;
116 };
117 let rel = relpath(&path, &dir).to_string_lossy().into_owned();
118 let hit = persisted.get(&rel).filter(|r| {
119 r.get("size").and_then(Value::as_u64) == Some(size)
120 && r.get("mtime_ns").and_then(Value::as_i64) == Some(mtime_ns as i64)
121 });
122 let grade = match hit {
123 Some(r) => r
124 .get("grade")
125 .and_then(Value::as_str)
126 .unwrap_or("")
127 .to_string(),
128 None => {
129 changed = true;
130 read_sfx_grade(&path)
131 }
132 };
133 fresh.insert(rel, record(&grade, size, mtime_ns));
134 cache.insert(path.to_string_lossy().into_owned(), grade);
135 }
136 let same_keys =
137 fresh.len() == persisted.len() && fresh.keys().all(|k| persisted.contains_key(k));
138 if changed || !same_keys {
139 save_grade_cache_file(&fresh);
140 }
141}
142
143pub fn update_grade_cache_entry(path: &Path, grade: &str) {
146 let Some((size, mtime_ns)) = stat(path) else {
147 return;
148 };
149 let rel = relpath(path, &sfx_dir("")).to_string_lossy().into_owned();
150 let mut files = load_grade_cache_file();
151 files.insert(
152 rel,
153 record(if is_grade(grade) { grade } else { "" }, size, mtime_ns),
154 );
155 save_grade_cache_file(&files);
156}
157
158pub fn sfx_show_label(path: &Path, root: &Path) -> String {
161 let Some(dir) = path.parent() else {
162 return String::new();
163 };
164 let rel = relpath(dir, root);
165 let s = rel.to_string_lossy();
166 if s.is_empty() || s == "." || s.starts_with("..") {
167 return String::new();
168 }
169 rel.components()
170 .next()
171 .map(|c| c.as_os_str().to_string_lossy().into_owned())
172 .unwrap_or_default()
173}
174
175pub fn sfx_choices(state: &AppState, filter: &str) -> Vec<(String, String)> {
177 let root = sfx_dir("");
178 let cache = state.grade_cache.lock().unwrap_or_else(|e| e.into_inner());
179 cache
180 .iter()
181 .filter(|(_, g)| match filter {
182 "ungraded" => g.is_empty(),
183 f if is_grade(f) => g.as_str() == f,
184 _ => true,
185 })
186 .map(|(path, grade)| {
187 let p = Path::new(path);
188 let show = sfx_show_label(p, &root);
189 let name = if show.is_empty() {
190 basename(p)
191 } else {
192 format!("[{show}] {}", basename(p))
193 };
194 (format!("{} {name}", glyph(grade)), path.clone())
195 })
196 .collect()
197}
198
199pub fn sfx_summary(state: &AppState) -> String {
201 let cache = state.grade_cache.lock().unwrap_or_else(|e| e.into_inner());
202 let total = cache.len();
203 if total == 0 {
204 return "No SFX files found (click Load to scan SFX/).".into();
205 }
206 let acc = cache
207 .values()
208 .filter(|g| g.as_str() == SFX_GRADE_ACCURATE)
209 .count();
210 let rej = cache
211 .values()
212 .filter(|g| g.as_str() == SFX_GRADE_REJECTED)
213 .count();
214 format!(
215 "{total} files — {acc} ✓ accurate · {rej} ✗ rejected · {} • ungraded",
216 total - acc - rej
217 )
218}
219
220pub fn grade_of(state: &AppState, path: &str) -> String {
222 state
223 .grade_cache
224 .lock()
225 .map(|c| c.get(path).cloned().unwrap_or_default())
226 .unwrap_or_default()
227}
228
229pub fn apply_grade(state: &AppState, path: &str, grade: &str) -> std::io::Result<()> {
232 {
233 let cache = state.grade_cache.lock().unwrap_or_else(|e| e.into_inner());
234 if !cache.contains_key(path) {
235 return Err(std::io::Error::new(
236 std::io::ErrorKind::NotFound,
237 "not a scanned SFX file",
238 ));
239 }
240 }
241 write_sfx_grade(Path::new(path), grade)?;
242 let g = if is_grade(grade) { grade } else { "" };
243 if let Ok(mut cache) = state.grade_cache.lock() {
244 cache.insert(path.to_string(), g.to_string());
245 }
246 update_grade_cache_entry(Path::new(path), grade);
247 Ok(())
248}