Skip to main content

xil_web/
grades.rs

1//! The Audio Grading tab: SFX library grades kept in each MP3's
2//! `TXXX:XIL_GRADE` frame, with a persisted `(size, mtime)` cache so a rescan
3//! of a NAS library is one walk plus stats.
4//!
5//! The cache file (`SFX/.xil_grade_cache.json`) has the Python GUI's exact
6//! format, so both dashboards share it.
7
8use 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
26/// `_GRADE_GLYPH`.
27pub fn glyph(grade: &str) -> &'static str {
28    match grade {
29        SFX_GRADE_ACCURATE => "✓",
30        SFX_GRADE_REJECTED => "✗",
31        _ => "•",
32    }
33}
34
35/// `_sfx_dir(slug)`: the SFX library, scoped to `SFX/{slug}` when it exists.
36pub 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
47/// `_grade_cache_path()`.
48pub fn grade_cache_path() -> PathBuf {
49    sfx_dir("").join(".xil_grade_cache.json")
50}
51
52/// `_load_grade_cache_file()`: `{rel_path: {grade, size, mtime_ns}}`, empty
53/// when missing, corrupt or another version.
54pub 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
70/// `_save_grade_cache_file(files)`. Best-effort.
71pub 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    // Nanoseconds since 1970 fit an i64 until 2262.
97    r.insert("mtime_ns".into(), (mtime_ns as i64).into());
98    Value::Object(r)
99}
100
101/// `_scan_sfx_grades()`: rebuild the in-memory grade map for every
102/// `SFX/**/*.mp3`, reading ID3 only for files whose size or mtime changed.
103pub 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
143/// `_update_grade_cache_entry(path, grade)`: store the post-write stat so the
144/// next scan stays read-free.
145pub 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
158/// `_sfx_show_label(path, root)`: the per-show subdirectory, or `""` for the
159/// shared pool and anything not under `root`.
160pub 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
175/// `_sfx_choices(grade_filter)`: `[(glyph  [show] name, path)]`.
176pub 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
199/// `_sfx_summary()`.
200pub 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
220/// The grade of one cached path, `""` when ungraded or unknown.
221pub 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
229/// Write a grade to the file, the in-memory map and the persisted cache.
230/// Only paths the last scan found are accepted.
231pub 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}