Skip to main content

xil/cmd/
episode_summary.rs

1//! `xil episode-summary` — one-row-per-episode CSV. Port of `XILU014_episode_summary.py`.
2
3use std::ffi::OsString;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::{Map, Value};
10use xil_core::fsutil::{basename, glob_recursive};
11use xil_core::pycsv;
12use xil_core::workspace::workspace_root;
13use xil_core::{banner, log};
14
15const SCRIPT_NAME: &str = "XILU014_episode_summary.py";
16const SKIP_PREFIXES: [&str; 2] = ["roundtrip_", "pre_splice_"];
17const ALL_COLS: [&str; 9] = [
18    "show",
19    "tag",
20    "season",
21    "episode",
22    "title",
23    "season_title",
24    "dialogue_lines",
25    "words",
26    "tts_chars",
27];
28
29#[derive(Parser)]
30#[command(
31    name = "xil-episode-summary",
32    about = "Write a one-row-per-episode summary CSV from all parsed_<tag>.json files."
33)]
34struct Args {
35    /// Output CSV path (default: <workspace>/episode_summary.csv)
36    #[arg(long, short = 'o', value_name = "FILE")]
37    output: Option<PathBuf>,
38    /// Filter to a single show name, e.g. 'THE 413' (case-insensitive)
39    #[arg(long, value_name = "NAME")]
40    show: Option<String>,
41    /// Write CSV to stdout — no banner, safe to pipe
42    #[arg(long)]
43    stdout: bool,
44}
45
46fn collect_files(parsed_root: &Path) -> Vec<PathBuf> {
47    glob_recursive(parsed_root, "parsed_", ".json")
48        .into_iter()
49        .filter(|p| {
50            let name = basename(p);
51            !SKIP_PREFIXES.iter().any(|pfx| name.starts_with(pfx))
52        })
53        .collect()
54}
55
56/// `int(x)` for a CSV cell that may be an int, a numeric string, or blank;
57/// 999 when Python would raise.
58fn sort_int(v: &Value) -> i64 {
59    match v {
60        Value::Number(n) => n
61            .as_i64()
62            .or_else(|| n.as_f64().map(|f| f as i64))
63            .unwrap_or(999),
64        Value::String(s) => s.trim().parse().unwrap_or(999),
65        Value::Bool(b) => *b as i64,
66        _ => 999,
67    }
68}
69
70pub fn build_summary(parsed_root: &Path, show_filter: Option<&str>) -> Vec<Map<String, Value>> {
71    let mut rows = Vec::new();
72    for path in collect_files(parsed_root) {
73        let data: Value = match fs::read_to_string(&path)
74            .map_err(|e| e.to_string())
75            .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
76        {
77            Ok(v) => v,
78            Err(e) => {
79                log::warning(&format!("Skipping {} — {e}", basename(&path)));
80                continue;
81            }
82        };
83        let obj = data.as_object().cloned().unwrap_or_default();
84        let show = obj
85            .get("show")
86            .and_then(Value::as_str)
87            .unwrap_or("")
88            .to_string();
89        if let Some(f) = show_filter {
90            if show.to_lowercase() != f.to_lowercase() {
91                continue;
92            }
93        }
94        let name = basename(&path);
95        let tag = name
96            .strip_prefix("parsed_")
97            .unwrap_or(&name)
98            .strip_suffix(".json")
99            .unwrap_or(&name)
100            .to_string();
101        let stats = obj
102            .get("stats")
103            .and_then(Value::as_object)
104            .cloned()
105            .unwrap_or_default();
106        let words: usize = obj
107            .get("entries")
108            .and_then(Value::as_array)
109            .map(|es| {
110                es.iter()
111                    .filter(|e| e.get("type").and_then(Value::as_str) == Some("dialogue"))
112                    .map(|e| {
113                        e.get("text")
114                            .and_then(Value::as_str)
115                            .unwrap_or("")
116                            .split_whitespace()
117                            .count()
118                    })
119                    .sum()
120            })
121            .unwrap_or(0);
122
123        let none_to_blank = |k: &str| match obj.get(k) {
124            None | Some(Value::Null) => Value::String(String::new()),
125            Some(v) => v.clone(),
126        };
127        let mut row = Map::new();
128        row.insert("show".into(), Value::String(show));
129        row.insert("tag".into(), Value::String(tag));
130        row.insert("season".into(), none_to_blank("season"));
131        row.insert("episode".into(), none_to_blank("episode"));
132        row.insert("title".into(), super::get_or_empty(&obj, "title"));
133        row.insert(
134            "season_title".into(),
135            super::get_or_empty(&obj, "season_title"),
136        );
137        row.insert(
138            "dialogue_lines".into(),
139            stats
140                .get("dialogue_lines")
141                .cloned()
142                .unwrap_or(Value::from(0)),
143        );
144        row.insert("words".into(), Value::from(words));
145        row.insert(
146            "tts_chars".into(),
147            stats
148                .get("characters_for_tts")
149                .cloned()
150                .unwrap_or(Value::from(0)),
151        );
152        rows.push(row);
153    }
154    rows.sort_by(|a, b| {
155        let key = |r: &Map<String, Value>| {
156            (
157                r["show"].as_str().unwrap_or("").to_string(),
158                sort_int(&r["season"]),
159                sort_int(&r["episode"]),
160                r["tag"].as_str().unwrap_or("").to_string(),
161            )
162        };
163        key(a).cmp(&key(b))
164    });
165    rows
166}
167
168fn execute(a: &Args) -> anyhow::Result<i32> {
169    let workspace = workspace_root();
170    let parsed_root = workspace.join("parsed");
171    if !parsed_root.is_dir() {
172        log::error(&format!(
173            "parsed/ directory not found at: {}",
174            parsed_root.display()
175        ));
176        return Ok(1);
177    }
178    let rows = build_summary(&parsed_root, a.show.as_deref());
179    if rows.is_empty() {
180        log::warning(&format!(
181            "No parsed_*.json files found under {}",
182            parsed_root.display()
183        ));
184        return Ok(0);
185    }
186    if a.stdout {
187        let mut out = std::io::stdout().lock();
188        pycsv::write_dicts(&mut out, &ALL_COLS, &rows)?;
189        out.flush()?;
190        return Ok(0);
191    }
192    let output = a
193        .output
194        .clone()
195        .unwrap_or_else(|| workspace.join("episode_summary.csv"));
196    let mut f = fs::File::create(&output)?;
197    pycsv::write_dicts(&mut f, &ALL_COLS, &rows)?;
198    log::info(&format!("  Episodes:  {}", rows.len()));
199    log::info(&format!("  Written:   {}", output.display()));
200    Ok(0)
201}
202
203pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
204    log::init("episode-summary");
205    let a: Args = match super::parse_or_exit("xil-episode-summary", args) {
206        Ok(a) => a,
207        Err(code) => return Ok(code),
208    };
209    if a.stdout {
210        execute(&a)
211    } else {
212        let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
213        execute(&a)
214    }
215}
216
217/// The clap definition behind `--help`, for man pages.
218pub fn command() -> clap::Command {
219    <Args as clap::CommandFactory>::command()
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn parsed(dir: &Path, name: &str, body: &str) {
227        fs::create_dir_all(dir).unwrap();
228        fs::write(dir.join(name), body).unwrap();
229    }
230
231    #[test]
232    fn summary_counts_words_and_sorts() {
233        let tmp = tempfile::tempdir().unwrap();
234        let r = tmp.path();
235        parsed(
236            &r.join("s"),
237            "parsed_S01E02.json",
238            r#"{"show":"S","season":1,"episode":2,"title":"t2","stats":{"dialogue_lines":1,"characters_for_tts":9},
239                "entries":[{"type":"dialogue","text":"one two  three"},{"type":"direction","text":"x y"}]}"#,
240        );
241        parsed(
242            &r.join("s"),
243            "parsed_S01E01.json",
244            r#"{"show":"S","season":1,"episode":1,"entries":[]}"#,
245        );
246        parsed(
247            &r.join("s"),
248            "pre_splice_parsed_S01E03.json",
249            r#"{"show":"S"}"#,
250        );
251        let rows = build_summary(r, None);
252        assert_eq!(rows.len(), 2);
253        assert_eq!(rows[0]["tag"], "S01E01");
254        assert_eq!(rows[1]["tag"], "S01E02");
255        assert_eq!(rows[1]["words"], 3);
256        assert_eq!(rows[1]["tts_chars"], 9);
257        assert_eq!(rows[0]["dialogue_lines"], 0);
258        assert_eq!(rows[0]["title"], "");
259        assert!(build_summary(r, Some("other")).is_empty());
260        assert_eq!(build_summary(r, Some("s")).len(), 2);
261    }
262
263    #[test]
264    fn sort_int_falls_back_to_999() {
265        assert_eq!(sort_int(&Value::from(3)), 3);
266        assert_eq!(sort_int(&Value::from("4")), 4);
267        assert_eq!(sort_int(&Value::from("")), 999);
268        assert_eq!(sort_int(&Value::Null), 999);
269    }
270}