Skip to main content

xil/cmd/
gui.rs

1//! `xil gui` — the web dashboard. Port of `xil_gui.py`'s entry point; the
2//! dashboard itself lives in the `xil-web` crate.
3//!
4//! Gradio's `--share` tunnel has no counterpart here; share the dashboard
5//! through an SSH or Tailscale tunnel instead.
6
7use 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    /// Port to listen on (default: 7860)
25    #[arg(long, default_value_t = 7860)]
26    port: u16,
27    /// Host address to bind (default: 127.0.0.1)
28    #[arg(long, default_value = "127.0.0.1")]
29    host: String,
30    /// Append a timestamped session activity log to FILE
31    #[arg(long, short = 'o', value_name = "FILE")]
32    output: Option<PathBuf>,
33    /// Log detailed activity for the Timeline audio-properties dialog (SFX open/save requests) to stdout and logs/xil_YYYY-MM-DD.log
34    #[arg(long, short = 'v')]
35    verbose: bool,
36}
37
38/// `_stage_status(slug, tag)`: parse/stems/daw/master freshness from the
39/// `xil status` engine. `✓` up to date, `⚠` stale, `○` not built; the stems
40/// cell keeps its file count, and overall is the worst of the four.
41pub 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
108/// The clap definition behind `--help`, for man pages.
109pub 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    /// `_scaffold_fresh`: a parse→master chain with ascending mtimes.
124    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    // One test, so the workspace variable is set once per process.
148    #[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}