Skip to main content

xil_web/
episodes.rs

1//! Episode discovery, dropdown labels, the Episodes table and stem listing.
2
3use 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
25/// How long Episodes-table rows stay memoised (`_EPISODES_TTL_S`).
26pub const EPISODES_TTL: Duration = Duration::from_secs(300);
27
28/// Legacy cast file names: `cast_{slug}_{tag}.json` in the workspace root.
29pub fn is_legacy_cast_name(name: &str) -> bool {
30    TAG_RE.is_match(name)
31}
32
33/// Subdirectories of `dir`, sorted, hidden ones skipped (`glob("dir/*")`).
34pub(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
47/// `_find_episodes()`: `(slug, tag)` pairs from both cast layouts, plus
48/// `(slug, "")` stubs for shows with a project.json and no episode yet.
49pub 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
103/// `_ep_meta(slug, tag)` → `(title, season_title)` from the cast config.
104pub 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
121/// `_ep_choice(slug, tag)`: the dropdown label.
122pub 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
144/// `_episode_choices()`.
145pub fn episode_choices() -> Vec<String> {
146    find_episodes()
147        .iter()
148        .map(|(s, t)| ep_choice(s, t))
149        .collect()
150}
151
152/// `_is_safe_slug_or_tag(value)`: a bare path component, checked before any
153/// path is built from request data.
154pub fn is_safe_slug_or_tag(value: &str) -> bool {
155    SLUG_TAG_RE.is_match(value)
156}
157
158/// `True` for a raw episode tag such as `S04E04`.
159pub fn looks_like_tag(value: &str) -> bool {
160    EP_TAG_RE.is_match(value)
161}
162
163/// `_parse_choice(choice)`: a dropdown label back to `(slug, tag)`. An unsafe
164/// slug yields `("", "")`.
165pub 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
177/// `_refresh_episodes(force)`: Episodes-table rows, memoised for
178/// [`EPISODES_TTL`] per workspace root. The staleness scan stats every stem,
179/// so rows are evaluated on up to eight threads.
180pub 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
232/// `_load_stems(slug, tag, filter_type)`: `[(label, path)]` for the stem
233/// dropdown, sorted by file name, labelled from the parsed script.
234pub 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}