1use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use clap::Parser;
12use xil_core::log;
13use xil_web::StageCells;
14
15use super::status::evaluate_episode;
16
17#[derive(Parser)]
18#[command(
19 name = "xil-gui",
20 about = "Launch the xil-pipeline web dashboard. Opens a browser UI with eleven tabs: Setup (initialize a workspace / select the active show), Project (edit project.json), Episodes (workspace overview with parse/stems/DAW/master status), Run Stage (launch pipeline stages with live log streaming; dry-run on by default), Speakers, Cast Config and SFX Config (edit the respective JSON configs), Edit Parsed JSON (edit an episode's generated parsed script JSON), Audio Preview (browse and play stems in the browser), Audio Grading (mark SFX library files accurate or rejected), and Timeline (interactive HTML timeline).",
21 after_help = "Remote access: the server binds 127.0.0.1 by default. To reach it from\nanother machine, forward the port, e.g.:\n ssh -L 7860:127.0.0.1:7860 <this-host>"
22)]
23struct Args {
24 #[arg(long, default_value_t = 7860)]
26 port: u16,
27 #[arg(long, default_value = "127.0.0.1")]
29 host: String,
30 #[arg(long, short = 'o', value_name = "FILE")]
32 output: Option<PathBuf>,
33 #[arg(long, short = 'v')]
35 verbose: bool,
36}
37
38pub fn stage_cells(slug: &str, tag: &str) -> StageCells {
42 let stages = evaluate_episode(slug, tag, Path::new(""), false);
43 let find = |name: &str| stages.iter().find(|s| s.name == name);
44 let glyph = |status: &str| match status {
45 "OK" => "✓",
46 "STALE" => "⚠",
47 _ => "○",
48 };
49 let g = |name: &str| {
50 find(name)
51 .map(|s| glyph(s.status))
52 .unwrap_or("○")
53 .to_string()
54 };
55 let produce = match find("stems") {
56 Some(s) if s.status != "MISSING" => format!("{} {}", glyph(s.status), s.output_count),
57 _ => "○".into(),
58 };
59 let shown: Vec<&str> = ["parsed", "stems", "daw", "master"]
60 .iter()
61 .filter_map(|n| find(n).map(|s| s.status))
62 .collect();
63 let overall = if shown.contains(&"MISSING") {
64 "○ missing"
65 } else if shown.contains(&"STALE") {
66 "⚠ stale"
67 } else {
68 "✓ OK"
69 };
70 StageCells {
71 parse: g("parsed"),
72 produce,
73 daw: g("daw"),
74 master: g("master"),
75 overall: overall.into(),
76 }
77}
78
79pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
80 if args.iter().any(|a| a == "--share") {
81 eprintln!(
82 "xil gui: --share was removed with the Python dashboard. \
83 Forward the port instead, e.g. ssh -L 7860:127.0.0.1:7860 <this-host>"
84 );
85 return Ok(2);
86 }
87 let a: Args = match super::parse_or_exit("xil-gui", args) {
88 Ok(a) => a,
89 Err(code) => return Ok(code),
90 };
91 xil_web::print_workspace_banner();
92 log::init("gui");
93 if a.verbose {
94 log::set_threshold(log::Level::Debug);
95 }
96 if let Some(path) = &a.output {
97 xil_web::activity::open(path)?;
98 }
99 let xil_exe = std::env::current_exe()?;
100 let state = xil_web::AppState::new(xil_exe, Arc::new(stage_cells));
101 let runtime = tokio::runtime::Builder::new_multi_thread()
102 .enable_all()
103 .build()?;
104 runtime.block_on(xil_web::serve(&a.host, a.port, state))?;
105 Ok(0)
106}
107
108pub fn command() -> clap::Command {
110 <Args as clap::CommandFactory>::command()
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 fn touch(path: &Path, mtime: i64) {
118 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
119 std::fs::write(path, "x").unwrap();
120 filetime::set_file_mtime(path, filetime::FileTime::from_unix_time(mtime, 0)).unwrap();
121 }
122
123 fn scaffold_fresh(root: &Path, slug: &str, tag: &str, stems: usize) {
125 touch(&root.join(format!("scripts/{tag}_{slug}_v1.md")), 1001);
126 touch(&root.join(format!("parsed/{slug}/parsed_{tag}.json")), 1002);
127 for i in 0..stems {
128 touch(
129 &root.join(format!("stems/{slug}/{tag}/{:03}_intro_host.mp3", i + 1)),
130 1003,
131 );
132 }
133 touch(
134 &root.join(format!("stems/{slug}/{tag}/{tag}_stem_manifest.json")),
135 1003,
136 );
137 touch(
138 &root.join(format!("daw/{slug}/{tag}/{tag}_layer_dialogue.wav")),
139 1004,
140 );
141 touch(
142 &root.join(format!("masters/{tag}_{slug}_2026-06-19.mp3")),
143 1005,
144 );
145 }
146
147 #[test]
149 fn stage_cells_track_freshness() {
150 let _env = crate::cmd::ENV_LOCK
151 .lock()
152 .unwrap_or_else(|e| e.into_inner());
153 let tmp = tempfile::tempdir().unwrap();
154 let root = tmp.path();
155 std::env::set_var("XIL_PROJECTROOT", root);
156
157 scaffold_fresh(root, "the413", "S01E01", 3);
158 let st = stage_cells("the413", "S01E01");
159 assert_eq!(
160 (
161 st.parse.as_str(),
162 st.produce.as_str(),
163 st.daw.as_str(),
164 st.master.as_str(),
165 st.overall.as_str()
166 ),
167 ("✓", "✓ 3", "✓", "✓", "✓ OK")
168 );
169
170 filetime::set_file_mtime(
171 root.join("parsed/the413/parsed_S01E01.json"),
172 filetime::FileTime::from_unix_time(9000, 0),
173 )
174 .unwrap();
175 let st = stage_cells("the413", "S01E01");
176 assert!(st.produce.starts_with('⚠'), "{st:?}");
177 assert_eq!(st.overall, "⚠ stale");
178
179 scaffold_fresh(root, "the413", "S01E02", 1);
180 std::fs::remove_file(root.join("masters/S01E02_the413_2026-06-19.mp3")).unwrap();
181 let st = stage_cells("the413", "S01E02");
182 assert_eq!(
183 (st.master.as_str(), st.overall.as_str()),
184 ("○", "○ missing")
185 );
186
187 let st = stage_cells("the413", "S09E09");
188 assert_eq!((st.parse.as_str(), st.produce.as_str()), ("○", "○"));
189 std::env::remove_var("XIL_PROJECTROOT");
190 }
191}