1use std::collections::HashSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7use std::time::{Duration, Instant};
8
9use regex::Regex;
10use serde_json::Value;
11use xil_core::fsutil::{basename, glob_children};
12use xil_core::pyfmt::{head, pad_right};
13use xil_core::workspace::{derive_paths, workspace_root};
14
15use crate::AppState;
16
17static TAG_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^cast_(.+?)_([A-Z0-9]+)\.json$").unwrap());
19static NEW_CAST_RE: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r"^cast_([A-Z0-9]+)\.json$").unwrap());
21static SLUG_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_-]+$").unwrap());
22static EP_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Z][A-Z0-9]+$").unwrap());
23static SEQ_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^n?(-?\d+)_").unwrap());
24
25pub const EPISODES_TTL: Duration = Duration::from_secs(300);
27
28pub fn is_legacy_cast_name(name: &str) -> bool {
30 TAG_RE.is_match(name)
31}
32
33pub(crate) fn child_dirs(dir: &Path) -> Vec<PathBuf> {
35 let mut v: Vec<PathBuf> = fs::read_dir(dir)
36 .map(|rd| {
37 rd.filter_map(Result::ok)
38 .map(|e| e.path())
39 .filter(|p| p.is_dir() && !basename(p).starts_with('.'))
40 .collect()
41 })
42 .unwrap_or_default();
43 v.sort();
44 v
45}
46
47pub fn find_episodes() -> Vec<(String, String)> {
50 let root = workspace_root();
51 let mut seen = HashSet::new();
52 let mut results = Vec::new();
53
54 for p in glob_children(&root, "cast_", ".json") {
55 if let Some(c) = TAG_RE.captures(&basename(&p)) {
56 let pair = (c[1].to_string(), c[2].to_string());
57 if seen.insert(pair.clone()) {
58 results.push(pair);
59 }
60 }
61 }
62 for dir in child_dirs(&root.join("configs")) {
63 let slug = basename(&dir);
64 for p in glob_children(&dir, "cast_", ".json") {
65 if let Some(c) = NEW_CAST_RE.captures(&basename(&p)) {
66 let pair = (slug.clone(), c[1].to_string());
67 if seen.insert(pair.clone()) {
68 results.push(pair);
69 }
70 }
71 }
72 }
73
74 let with_episodes: HashSet<String> = results.iter().map(|(s, _)| s.clone()).collect();
75 let configs = root.join("configs");
76 if configs.is_dir() {
77 let mut names: Vec<String> = fs::read_dir(&configs)
78 .map(|rd| {
79 rd.filter_map(Result::ok)
80 .map(|e| e.file_name().to_string_lossy().into_owned())
81 .collect()
82 })
83 .unwrap_or_default();
84 names.sort();
85 for name in names {
86 if !with_episodes.contains(&name) && configs.join(&name).join("project.json").exists() {
87 results.push((name, String::new()));
88 }
89 }
90 }
91 results.sort();
92 results
93}
94
95fn read_json(path: &Path) -> Option<Value> {
96 serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
97}
98
99fn str_field(v: &Value, key: &str) -> String {
100 v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
101}
102
103pub fn ep_meta(slug: &str, tag: &str) -> (String, String) {
105 let root = workspace_root();
106 for path in [
107 root.join("configs")
108 .join(slug)
109 .join(format!("cast_{tag}.json")),
110 root.join(format!("cast_{slug}_{tag}.json")),
111 ] {
112 if path.exists() {
113 if let Some(data) = read_json(&path) {
114 return (str_field(&data, "title"), str_field(&data, "season_title"));
115 }
116 }
117 }
118 (String::new(), String::new())
119}
120
121pub fn ep_choice(slug: &str, tag: &str) -> String {
123 if tag.is_empty() {
124 let pj = workspace_root()
125 .join("configs")
126 .join(slug)
127 .join("project.json");
128 let show = read_json(&pj)
129 .and_then(|d| d.get("show").and_then(Value::as_str).map(str::to_string))
130 .unwrap_or_else(|| slug.to_string());
131 return format!("{slug} [show] — {show}");
132 }
133 let (title, season_title) = ep_meta(slug, tag);
134 let mut label = format!("{slug} {tag}");
135 if !season_title.is_empty() {
136 label.push_str(&format!(" [{season_title}]"));
137 }
138 if !title.is_empty() {
139 label.push_str(&format!(" — {title}"));
140 }
141 label
142}
143
144pub fn episode_choices() -> Vec<String> {
146 find_episodes()
147 .iter()
148 .map(|(s, t)| ep_choice(s, t))
149 .collect()
150}
151
152pub fn is_safe_slug_or_tag(value: &str) -> bool {
155 SLUG_TAG_RE.is_match(value)
156}
157
158pub fn looks_like_tag(value: &str) -> bool {
160 EP_TAG_RE.is_match(value)
161}
162
163pub fn parse_choice(choice: &str) -> (String, String) {
166 let parts: Vec<&str> = choice.split_whitespace().collect();
167 let slug = parts.first().copied().unwrap_or("");
168 if !slug.is_empty() && !is_safe_slug_or_tag(slug) {
169 return (String::new(), String::new());
170 }
171 match parts.get(1) {
172 Some(t) if looks_like_tag(t) => (slug.to_string(), t.to_string()),
173 _ => (slug.to_string(), String::new()),
174 }
175}
176
177pub fn refresh_episodes(state: &AppState, force: bool) -> Vec<Vec<String>> {
181 let root = workspace_root();
182 if !force {
183 if let Ok(cache) = state.episodes_cache.lock() {
184 if let Some((at, rows)) = cache.get(&root) {
185 if at.elapsed() < EPISODES_TTL {
186 return rows.clone();
187 }
188 }
189 }
190 }
191 let episodes = find_episodes();
192 let eval = |(slug, tag): &(String, String)| -> Vec<String> {
193 let st = (state.status)(slug, tag);
194 let (title, season_title) = ep_meta(slug, tag);
195 let desc = match (season_title.is_empty(), title.is_empty()) {
196 (true, _) => title,
197 (false, false) => format!("[{season_title}] — {title}"),
198 (false, true) => format!("[{season_title}]"),
199 };
200 vec![
201 tag.clone(),
202 slug.clone(),
203 desc,
204 st.parse,
205 st.produce,
206 st.daw,
207 st.master,
208 st.overall,
209 ]
210 };
211 let mut rows: Vec<Option<Vec<String>>> = vec![None; episodes.len()];
212 if !episodes.is_empty() {
213 let workers = episodes.len().min(8);
214 let chunk = episodes.len().div_ceil(workers);
215 std::thread::scope(|s| {
216 for (eps, out) in episodes.chunks(chunk).zip(rows.chunks_mut(chunk)) {
217 s.spawn(move || {
218 for (e, slot) in eps.iter().zip(out.iter_mut()) {
219 *slot = Some(eval(e));
220 }
221 });
222 }
223 });
224 }
225 let rows: Vec<Vec<String>> = rows.into_iter().flatten().collect();
226 if let Ok(mut cache) = state.episodes_cache.lock() {
227 cache.insert(root, (Instant::now(), rows.clone()));
228 }
229 rows
230}
231
232pub fn load_stems(slug: &str, tag: &str, filter: &str) -> Vec<(String, PathBuf)> {
235 let p = derive_paths(slug, tag);
236 let stems_dir = &p["stems"];
237 if !stems_dir.is_dir() {
238 return Vec::new();
239 }
240 let mut index: std::collections::HashMap<i64, Value> = std::collections::HashMap::new();
241 if let Some(data) = read_json(&p["parsed"]) {
242 for e in data
243 .get("entries")
244 .and_then(Value::as_array)
245 .into_iter()
246 .flatten()
247 {
248 let seq = e.get("seq").and_then(Value::as_i64).unwrap_or(-99999);
249 index.insert(seq, e.clone());
250 }
251 }
252 let mut out = Vec::new();
253 for path in glob_children(stems_dir, "", ".mp3") {
254 let name = basename(&path);
255 let stem = name.strip_suffix(".mp3").unwrap_or(&name).to_string();
256 let seq = SEQ_RE
257 .captures(&stem)
258 .and_then(|c| c[1].parse::<i64>().ok())
259 .unwrap_or(-99999);
260 let entry = index.get(&seq);
261 let get = |k: &str| {
262 entry
263 .and_then(|e| e.get(k))
264 .and_then(Value::as_str)
265 .unwrap_or("")
266 };
267 let (etype, dtype) = (get("type"), get("direction_type"));
268 let keep = match filter {
269 "dialogue" => etype == "dialogue",
270 "sfx" => dtype == "SFX" || dtype == "BEAT",
271 "music" => dtype == "MUSIC",
272 "ambience" => dtype == "AMBIENCE",
273 _ => true,
274 };
275 if !keep {
276 continue;
277 }
278 let label = match entry {
279 Some(_) => {
280 let speaker = [get("speaker"), dtype]
281 .into_iter()
282 .find(|s| !s.is_empty())
283 .unwrap_or("?");
284 format!(
285 "{seq:4} {} {} {}",
286 pad_right(speaker, 12),
287 pad_right(&head(get("section"), 14), 14),
288 head(get("text"), 52)
289 )
290 }
291 None => stem,
292 };
293 out.push((label, path));
294 }
295 out
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn parse_choice_reads_labels_and_refuses_unsafe_slugs() {
304 assert_eq!(
305 parse_choice("the413 S03E03 [Arc] — Title"),
306 ("the413".into(), "S03E03".into())
307 );
308 assert_eq!(
309 parse_choice("mypodcast [show] — My Podcast"),
310 ("mypodcast".into(), "".into())
311 );
312 assert_eq!(parse_choice(""), ("".into(), "".into()));
313 assert_eq!(parse_choice("../etc S01E01"), ("".into(), "".into()));
314 assert_eq!(parse_choice("a/b S01E01"), ("".into(), "".into()));
315 }
316
317 #[test]
318 fn safe_slug_allowlist() {
319 assert!(is_safe_slug_or_tag("the413"));
320 assert!(is_safe_slug_or_tag("S01E01"));
321 assert!(is_safe_slug_or_tag("my-show_2"));
322 assert!(!is_safe_slug_or_tag(""));
323 assert!(!is_safe_slug_or_tag(".."));
324 assert!(!is_safe_slug_or_tag("a b"));
325 assert!(!is_safe_slug_or_tag("x\0"));
326 }
327}