1use 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 #[arg(long, short = 'o', value_name = "FILE")]
37 output: Option<PathBuf>,
38 #[arg(long, value_name = "NAME")]
40 show: Option<String>,
41 #[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
56fn 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
217pub 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}