1use std::collections::BTreeSet;
5use std::env;
6use std::ffi::OsString;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10use std::time::UNIX_EPOCH;
11
12use chrono::{Local, TimeZone};
13use clap::Parser;
14use regex::Regex;
15use serde_json::{json, Value};
16use xil_audio::tags::{read_sfx_grade, SFX_GRADE_REJECTED};
17use xil_core::fsutil::{basename, list_dir_raw, pathlib_glob, pathlib_rglob_contains, sort_py};
18use xil_core::log;
19use xil_core::pyjson::{dumps, Style};
20use xil_core::sfxlib::shared_sfx_path;
21use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
22
23static TAG_SEARCH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)S\d+E\d+").unwrap());
24static TAG_FULL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^S\d+E\d+$").unwrap());
25
26const OK: &str = "OK";
27const STALE: &str = "STALE";
28const MISSING: &str = "MISSING";
29const NONE: &str = "-";
30
31#[derive(Parser)]
32#[command(
33 name = "xil-status",
34 about = "Make-style staleness checker for episode pipeline artifacts. Reports, per stage, whether outputs \
35 are up to date with their inputs, and prints the xil commands needed to refresh anything stale. \
36 Nothing is rebuilt.",
37 after_help = "Examples:\n xil status --episode S01E01\n xil status S01E01 --show \"Night Owls\"\n \
38 xil status --all\n xil status --episode S01E01 --json\n"
39)]
40struct Args {
41 #[arg(value_name = "TAG")]
43 episode: Option<String>,
44 #[arg(long = "episode", short = 'e', value_name = "TAG")]
46 episode_flag: Option<String>,
47 #[arg(long, short = 's', value_name = "SHOW")]
49 show: Option<String>,
50 #[arg(long, short = 'a')]
52 all: bool,
53 #[arg(long, value_name = "DIR")]
55 gdoc_dir: Option<PathBuf>,
56 #[arg(long)]
58 json: bool,
59 #[arg(long, short = 'v')]
61 verbose: bool,
62}
63
64#[derive(Debug, Clone)]
66pub struct StageStatus {
67 pub name: &'static str,
68 pub status: &'static str,
69 pub newest_input: Option<f64>,
70 pub newest_output: Option<f64>,
71 pub oldest_output: Option<f64>,
72 pub output_count: usize,
73 pub note: String,
74 pub refresh: String,
75 pub output_files: Vec<PathBuf>,
76}
77
78fn mtime(p: &Path) -> Option<f64> {
80 let d = fs::metadata(p)
81 .ok()?
82 .modified()
83 .ok()?
84 .duration_since(UNIX_EPOCH)
85 .ok()?;
86 Some(d.as_secs() as f64 + d.subsec_nanos() as f64 * 1e-9)
87}
88
89fn mtimes_and_files(paths: &[PathBuf]) -> (Vec<f64>, Vec<PathBuf>) {
98 let mut out = Vec::new();
99 let mut files = Vec::new();
100 for p in paths {
101 let Ok(meta) = fs::metadata(p) else { continue };
102 if meta.is_dir() {
103 let mut stack = vec![p.clone()];
104 while let Some(d) = stack.pop() {
105 let Ok(rd) = fs::read_dir(&d) else { continue };
106 for e in rd.filter_map(Result::ok) {
107 let path = e.path();
108 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
109 stack.push(path);
110 } else if let Ok(m) = fs::metadata(&path) {
111 if m.is_file() {
112 out.extend(meta_mtime(&m));
113 }
114 }
115 }
116 }
117 } else if meta.is_file() {
118 out.extend(meta_mtime(&meta));
119 files.push(p.clone());
120 }
121 }
122 (out, files)
123}
124
125fn mtimes(paths: &[PathBuf]) -> Vec<f64> {
126 mtimes_and_files(paths).0
127}
128
129fn meta_mtime(m: &fs::Metadata) -> Option<f64> {
130 let d = m.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
131 Some(d.as_secs() as f64 + d.subsec_nanos() as f64 * 1e-9)
132}
133
134fn fmt_time(t: Option<f64>) -> String {
135 match t {
136 None => "—".to_string(),
137 Some(t) => {
138 let secs = t.floor() as i64;
139 let nanos = ((t - t.floor()) * 1e9) as u32;
140 Local
141 .timestamp_opt(secs, nanos)
142 .single()
143 .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
144 .unwrap_or_else(|| "—".to_string())
145 }
146 }
147}
148
149fn gdoc_files(gdoc_dir: &Path, tag: &str) -> Vec<PathBuf> {
150 if !gdoc_dir.is_dir() {
151 return Vec::new();
152 }
153 let mut set: BTreeSet<PathBuf> = pathlib_glob(gdoc_dir, tag, ".gdoc").into_iter().collect();
154 for p in list_dir_raw(gdoc_dir) {
155 if let Some(n) = p.file_name().and_then(|n| n.to_str()) {
156 if let Some(stem) = n.strip_suffix(".gdoc") {
157 if stem.contains(tag) {
158 set.insert(p);
159 }
160 }
161 }
162 }
163 let mut v: Vec<PathBuf> = set.into_iter().collect();
164 sort_py(&mut v);
165 v
166}
167
168fn script_files(root: &Path, tag: &str) -> Vec<PathBuf> {
169 let scripts = root.join("scripts");
170 if !scripts.is_dir() {
171 return Vec::new();
172 }
173 pathlib_rglob_contains(&scripts, tag, ".md")
174 .into_iter()
175 .filter(|p| !basename(p).starts_with("revised_"))
176 .collect()
177}
178
179fn glob_in(dir: &Path, suffix: &str) -> Vec<PathBuf> {
180 if dir.is_dir() {
181 pathlib_glob(dir, "", suffix)
182 } else {
183 Vec::new()
184 }
185}
186
187fn master_files(root: &Path, slug: &str, tag: &str) -> Vec<PathBuf> {
188 let masters = root.join("masters");
189 let mut found = Vec::new();
190 if masters.is_dir() {
191 found.extend(pathlib_glob(&masters, &format!("{tag}_{slug}_"), ".mp3"));
192 let sub = masters.join(slug);
193 if sub.is_dir() {
194 found.extend(pathlib_glob(&sub, &format!("{tag}_"), ".mp3"));
195 }
196 }
197 let legacy = root.join(format!("{slug}_{tag}_master.mp3"));
198 if legacy.is_file() {
199 found.push(legacy);
200 }
201 let set: BTreeSet<PathBuf> = found.into_iter().filter(|p| p.is_file()).collect();
202 let mut v: Vec<PathBuf> = set.into_iter().collect();
203 sort_py(&mut v);
204 v
205}
206
207fn fmax(v: &[f64]) -> Option<f64> {
208 v.iter().copied().reduce(f64::max)
209}
210fn fmin(v: &[f64]) -> Option<f64> {
211 v.iter().copied().reduce(f64::min)
212}
213
214fn evaluate_stage(
215 name: &'static str,
216 inputs: &[PathBuf],
217 outputs: &[PathBuf],
218 refresh: &str,
219 count: Option<usize>,
220) -> StageStatus {
221 let in_times = mtimes(inputs);
222 let (out_times, output_files) = mtimes_and_files(outputs);
223 let newest_in = fmax(&in_times);
224 let newest_out = fmax(&out_times);
225 let oldest_out = fmin(&out_times);
226 let (status, suggested) = if out_times.is_empty() {
227 (MISSING, refresh.to_string())
228 } else if matches!((newest_in, newest_out), (Some(i), Some(o)) if i > o) {
229 (STALE, refresh.to_string())
230 } else {
231 (OK, String::new())
232 };
233 StageStatus {
234 name,
235 status,
236 newest_input: newest_in,
237 newest_output: newest_out,
238 oldest_output: oldest_out,
239 output_count: count.unwrap_or(out_times.len()),
240 note: String::new(),
241 refresh: suggested,
242 output_files,
243 }
244}
245
246pub fn evaluate_episode(
247 slug: &str,
248 tag: &str,
249 gdoc_dir: &Path,
250 include_source: bool,
251) -> Vec<StageStatus> {
252 let root = workspace_root();
253 let paths = derive_paths(slug, tag);
254
255 let gdocs = if include_source {
256 gdoc_files(gdoc_dir, tag)
257 } else {
258 Vec::new()
259 };
260 let scripts = script_files(&root, tag);
261 let parsed = vec![paths["parsed"].clone()];
262 let stems = glob_in(&paths["stems"], ".mp3");
263 let stems_manifest = glob_in(&paths["stems"], "_stem_manifest.json");
264 let daw = glob_in(&paths["daw"], ".wav");
265 let masters = master_files(&root, slug, tag);
266
267 let script_refresh = "(re-import the production doc in xil-gui)";
268 let newest_script = scripts.iter().fold(None::<(&PathBuf, f64)>, |best, p| {
270 let t = mtime(p).unwrap_or(0.0);
271 match best {
272 Some((_, bt)) if bt >= t => best,
273 _ => Some((p, t)),
274 }
275 });
276 let parse_refresh = match newest_script {
277 Some((p, _)) => format!(
278 "xil parse {} --episode {tag}",
279 p.strip_prefix(&root)
280 .map(|r| r.display().to_string())
281 .unwrap_or_else(|_| p.display().to_string())
282 ),
283 None => format!("xil parse <script> --episode {tag}"),
284 };
285
286 let mut stems_outputs = stems.clone();
287 stems_outputs.extend(stems_manifest);
288
289 let mut stages = Vec::new();
290 if include_source {
291 stages.push(evaluate_stage("source", &[], &gdocs, "", None));
292 stages.push(evaluate_stage(
293 "script",
294 &gdocs,
295 &scripts,
296 script_refresh,
297 None,
298 ));
299 if gdocs.is_empty() {
300 let src = &mut stages[0];
301 src.status = NONE;
302 src.note = if gdoc_dir.is_dir() {
303 "no source doc"
304 } else {
305 "no gdoc dir"
306 }
307 .to_string();
308 src.refresh = String::new();
309 }
310 }
311
312 let mut daw_inputs = stems.clone();
313 if paths["sfx"].exists() {
314 daw_inputs.push(paths["sfx"].clone());
315 }
316
317 stages.push(evaluate_stage(
318 "parsed",
319 &scripts,
320 &parsed,
321 &parse_refresh,
322 None,
323 ));
324 stages.push(evaluate_stage(
325 "stems",
326 &parsed,
327 &stems_outputs,
328 &format!("xil produce --episode {tag}"),
329 Some(stems.len()),
330 ));
331 stages.push(evaluate_stage(
332 "daw",
333 &daw_inputs,
334 &daw,
335 &format!("xil daw --episode {tag}"),
336 None,
337 ));
338 stages.push(evaluate_stage(
339 "master",
340 &daw,
341 &masters,
342 &format!("xil master --episode {tag}"),
343 None,
344 ));
345 stages
346}
347
348fn worst(stages: &[StageStatus]) -> &'static str {
349 let relevant: Vec<&str> = stages
350 .iter()
351 .filter(|s| s.name != "source")
352 .map(|s| s.status)
353 .collect();
354 if relevant.contains(&MISSING) {
355 MISSING
356 } else if relevant.contains(&STALE) {
357 STALE
358 } else {
359 OK
360 }
361}
362
363pub fn rejected_sfx(slug: &str, tag: &str) -> Vec<String> {
366 let root = workspace_root();
367 let cfg_path = root
368 .join("configs")
369 .join(slug)
370 .join(format!("sfx_{tag}.json"));
371 let Ok(text) = fs::read_to_string(&cfg_path) else {
372 return Vec::new();
373 };
374 let Ok(cfg) = serde_json::from_str::<Value>(&text) else {
375 return Vec::new();
376 };
377 let sfx_dir = root.join("SFX");
378 let mut rejected = Vec::new();
379 for (key, eff) in cfg
380 .get("effects")
381 .and_then(Value::as_object)
382 .cloned()
383 .unwrap_or_default()
384 {
385 let mut candidates: Vec<PathBuf> = Vec::new();
386 let src = eff
387 .get("source")
388 .and_then(Value::as_str)
389 .filter(|s| !s.is_empty());
390 if let Some(src) = src {
391 let p = Path::new(src);
392 candidates.push(if p.is_absolute() {
393 p.to_path_buf()
394 } else {
395 root.join(p)
396 });
397 } else {
398 let base = shared_sfx_path(&sfx_dir, &key, "elevenlabs");
399 let stem = basename(&base).trim_end_matches(".mp3").to_string();
400 candidates.push(base);
401 for p in list_dir_raw(&sfx_dir) {
403 let n = basename(&p);
404 if !n.starts_with('.')
405 && n.starts_with(&format!("{stem}."))
406 && n.ends_with(".mp3")
407 && n.len() >= stem.len() + 5
408 {
409 candidates.push(p);
410 }
411 }
412 }
413 if candidates
414 .iter()
415 .any(|c| c.exists() && read_sfx_grade(c) == SFX_GRADE_REJECTED)
416 {
417 rejected.push(key);
418 }
419 }
420 rejected.sort();
421 rejected
422}
423
424fn print_episode(slug: &str, tag: &str, stages: &[StageStatus], verbose: bool) {
425 let root = workspace_root();
426 log::info(&format!("Episode {tag} (show: {slug})"));
427 log::info("");
428 log::info(&format!(
429 " {:<9} {:<8} {:<18} {:<18} FILES",
430 "STAGE", "STATUS", "NEWEST INPUT", "NEWEST OUTPUT"
431 ));
432 for s in stages {
433 let in_str = fmt_time(s.newest_input);
434 let out_str = if s.note.is_empty() {
435 fmt_time(s.newest_output)
436 } else {
437 format!("({})", s.note)
438 };
439 let count = if s.name == "source" && s.status == NONE {
440 "—".to_string()
441 } else {
442 s.output_count.to_string()
443 };
444 let marker = match s.status {
445 STALE => " ← input is newer (stage not re-run)",
446 MISSING => " ← not built yet",
447 _ => "",
448 };
449 log::info(&format!(
450 " {:<9} {:<8} {:<18} {:<18} {count}{marker}",
451 s.name, s.status, in_str, out_str
452 ));
453 if verbose && !s.output_files.is_empty() {
454 for f in &s.output_files {
455 let rel = f
456 .strip_prefix(&root)
457 .map(|r| r.display().to_string())
458 .unwrap_or_else(|_| f.display().to_string());
459 log::info(&format!(" {} {rel}", fmt_time(mtime(f))));
460 }
461 }
462 }
463 let refreshes: Vec<&str> = stages
464 .iter()
465 .filter(|s| !s.refresh.is_empty())
466 .map(|s| s.refresh.as_str())
467 .collect();
468 if !refreshes.is_empty() {
469 log::info("");
470 log::info("Stale/missing — refresh with:");
471 for cmd in refreshes {
472 log::info(&format!(" {cmd}"));
473 }
474 }
475 let rejected = rejected_sfx(slug, tag);
476 if !rejected.is_empty() {
477 log::info("");
478 log::warning(&format!(
479 "{} SFX graded rejected — omitted from production (fill the gap or re-grade in xil-gui):",
480 rejected.len()
481 ));
482 for key in rejected {
483 log::info(&format!(" ✗ {key}"));
484 }
485 }
486}
487
488fn opt_f(v: Option<f64>) -> Value {
489 v.map(Value::from).unwrap_or(Value::Null)
490}
491
492fn emit_json(slug: &str, tag: &str, stages: &[StageStatus]) {
493 let payload = json!({
494 "show": slug,
495 "episode": tag,
496 "overall": worst(stages),
497 "rejected_sfx": rejected_sfx(slug, tag),
498 "stages": stages.iter().map(|s| json!({
499 "name": s.name,
500 "status": s.status,
501 "newest_input": opt_f(s.newest_input),
502 "newest_output": opt_f(s.newest_output),
503 "oldest_output": opt_f(s.oldest_output),
504 "output_count": s.output_count,
505 "note": s.note,
506 "refresh": s.refresh,
507 })).collect::<Vec<_>>(),
508 });
509 println!("{}", dumps(&payload, Style::INDENT2));
510}
511
512fn stem_of(p: &Path) -> String {
513 p.file_stem()
514 .map(|s| s.to_string_lossy().into_owned())
515 .unwrap_or_default()
516}
517
518pub fn discover_tags(slug: &str) -> Vec<String> {
519 let root = workspace_root();
520 let mut tags = BTreeSet::new();
521 let parsed_dir = root.join("parsed").join(slug);
522 if parsed_dir.is_dir() {
523 for p in pathlib_glob(&parsed_dir, "parsed_", ".json") {
524 if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
525 tags.insert(m.as_str().to_uppercase());
526 }
527 }
528 }
529 for sub in ["stems", "daw"] {
530 let base = root.join(sub).join(slug);
531 if base.is_dir() {
532 for child in list_dir_raw(&base) {
533 if child.is_dir() && TAG_FULL.is_match(&basename(&child)) {
534 tags.insert(basename(&child).to_uppercase());
535 }
536 }
537 }
538 }
539 let masters_dir = root.join("masters");
540 if masters_dir.is_dir() {
541 for p in list_dir_raw(&masters_dir) {
542 let n = basename(&p);
543 if n.contains(&format!("_{slug}_")) && n.ends_with(".mp3") && n.len() > slug.len() + 6 {
544 if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
545 tags.insert(m.as_str().to_uppercase());
546 }
547 }
548 }
549 let sub = masters_dir.join(slug);
550 if sub.is_dir() {
551 for p in pathlib_glob(&sub, "", ".mp3") {
552 if let Some(m) = TAG_SEARCH.find(&stem_of(&p)) {
553 tags.insert(m.as_str().to_uppercase());
554 }
555 }
556 }
557 }
558 tags.into_iter().collect()
559}
560
561fn print_all(slug: &str, tags: &[String], gdoc_dir: &Path) -> i32 {
562 if tags.is_empty() {
563 log::info(&format!("No episodes found for show '{slug}'."));
564 return 0;
565 }
566 log::info(&format!("Show: {slug} — {} episode(s)", tags.len()));
567 log::info("");
568 log::info(&format!(" {:<10} {:<8} NEXT STEP", "EPISODE", "STATUS"));
569 let mut exit_code = 0;
570 for tag in tags {
571 let stages = evaluate_episode(slug, tag, gdoc_dir, true);
572 let w = worst(&stages);
573 if w != OK {
574 exit_code = 1;
575 }
576 let mut next_step = stages
577 .iter()
578 .find(|s| !s.refresh.is_empty())
579 .map(|s| s.refresh.clone())
580 .unwrap_or_default();
581 let rej = rejected_sfx(slug, tag);
582 if !rej.is_empty() {
583 let prefix = if next_step.is_empty() {
584 String::new()
585 } else {
586 format!("{next_step} ")
587 };
588 next_step = format!("{prefix}⚠ {} SFX rejected", rej.len());
589 }
590 log::info(&format!(" {tag:<10} {w:<8} {next_step}"));
591 }
592 exit_code
593}
594
595pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
596 log::init("status");
597 let a: Args = match super::parse_or_exit("xil-status", args) {
598 Ok(a) => a,
599 Err(code) => return Ok(code),
600 };
601 let slug = resolve_slug(a.show.as_deref(), "project.json");
602 let gdoc_dir = a.gdoc_dir.clone().unwrap_or_else(|| {
603 PathBuf::from(env::var("XIL_GDOC_DIR").unwrap_or_else(|_| "/mnt/i/My Drive".into()))
604 });
605 if !gdoc_dir.is_dir() {
606 log::warning(&format!(
607 "Google Drive dir not available: {} — skipping source check.",
608 gdoc_dir.display()
609 ));
610 }
611
612 if a.all {
613 if a.json {
614 log::error("--json is not supported with --all.");
615 return Ok(2);
616 }
617 let tags = discover_tags(&slug);
618 return Ok(print_all(&slug, &tags, &gdoc_dir));
619 }
620
621 let Some(tag) = a.episode_flag.clone().or(a.episode.clone()) else {
622 log::error("Provide an episode tag (e.g. S01E01) or use --all.");
623 return Ok(2);
624 };
625 let stages = evaluate_episode(&slug, &tag, &gdoc_dir, true);
626 if a.json {
627 emit_json(&slug, &tag, &stages);
628 } else {
629 print_episode(&slug, &tag, &stages, a.verbose);
630 }
631 Ok(if worst(&stages) == OK { 0 } else { 1 })
632}
633
634pub fn command() -> clap::Command {
636 <Args as clap::CommandFactory>::command()
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642
643 fn touch(p: &Path, secs: i64) {
644 fs::create_dir_all(p.parent().unwrap()).unwrap();
645 fs::write(p, "x").unwrap();
646 let t = filetime::FileTime::from_unix_time(secs, 0);
647 filetime::set_file_mtime(p, t).unwrap();
648 }
649
650 #[test]
651 fn stage_status_rules() {
652 let tmp = tempfile::tempdir().unwrap();
653 let r = tmp.path();
654 touch(&r.join("in.txt"), 1_700_000_010);
655 touch(&r.join("out_old.txt"), 1_700_000_005);
656 touch(&r.join("out_new.txt"), 1_700_000_020);
657 let missing = evaluate_stage("x", &[r.join("in.txt")], &[r.join("nope")], "fix", None);
658 assert_eq!(
659 (
660 missing.status,
661 missing.refresh.as_str(),
662 missing.output_count
663 ),
664 (MISSING, "fix", 0)
665 );
666 let stale = evaluate_stage(
667 "x",
668 &[r.join("in.txt")],
669 &[r.join("out_old.txt")],
670 "fix",
671 None,
672 );
673 assert_eq!(stale.status, STALE);
674 let ok = evaluate_stage(
675 "x",
676 &[r.join("in.txt")],
677 &[r.join("out_old.txt"), r.join("out_new.txt")],
678 "fix",
679 Some(7),
680 );
681 assert_eq!(
682 (ok.status, ok.refresh.as_str(), ok.output_count),
683 (OK, "", 7)
684 );
685 assert_eq!(ok.oldest_output, Some(1_700_000_005.0));
686 assert_eq!(worst(&[missing.clone(), stale.clone()]), MISSING);
687 assert_eq!(worst(&[ok.clone(), stale]), STALE);
688 assert_eq!(worst(&[ok]), OK);
689 }
690
691 #[test]
692 fn tag_discovery_across_layouts() {
693 let _env = crate::cmd::ENV_LOCK
694 .lock()
695 .unwrap_or_else(|e| e.into_inner());
696 let tmp = tempfile::tempdir().unwrap();
697 let r = tmp.path();
698 std::env::set_var("XIL_PROJECTROOT", r);
699 touch(&r.join("parsed/s/parsed_S01E01.json"), 1);
700 touch(&r.join("stems/s/s01e02/a.mp3"), 1);
701 touch(&r.join("daw/s/notatag/a.wav"), 1);
702 touch(&r.join("masters/S01E03_s_2026-01-01.mp3"), 1);
703 touch(&r.join("masters/S01E09_other_2026-01-01.mp3"), 1);
704 touch(&r.join("masters/s/S01E04_master.mp3"), 1);
705 assert_eq!(
706 discover_tags("s"),
707 vec!["S01E01", "S01E02", "S01E03", "S01E04"]
708 );
709 std::env::remove_var("XIL_PROJECTROOT");
710 }
711}