Skip to main content

xil_web/
runner.rs

1//! The Run Stage tab: command builders for each stage, extra-flag
2//! validation, and subprocess jobs whose merged output streams to the page.
3
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::sync::{Arc, LazyLock};
7
8use regex::Regex;
9use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
10use tokio::process::Command;
11use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
12use xil_core::fsutil::{glob_children, sort_py};
13use xil_core::workspace::workspace_root;
14
15use crate::{activity, AppState, JobEvent};
16
17/// Shell metacharacters refused in user-typed flags (`_SHELL_UNSAFE_RE`).
18static SHELL_UNSAFE: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"[;|&$`()\[\]<>!\\\n\r]").unwrap());
20
21/// `_sanitize_extra_flags(flags)`: shell-style split, then refuse any token
22/// carrying a shell metacharacter.
23pub fn sanitize_extra_flags(flags: &str) -> Result<Vec<String>, String> {
24    let tokens = shlex::split(flags.trim())
25        .ok_or_else(|| "Invalid flag syntax: No closing quotation".to_string())?;
26    for tok in &tokens {
27        if SHELL_UNSAFE.is_match(tok) {
28            return Err(format!(
29                "Unsafe character in flag argument: {}",
30                xil_core::script::hints::py_repr(tok)
31            ));
32        }
33    }
34    Ok(tokens)
35}
36
37fn base(exe: &Path, stage: &str) -> Vec<String> {
38    vec![exe.to_string_lossy().into_owned(), stage.to_string()]
39}
40
41fn opt(cmd: &mut Vec<String>, flag: &str, value: &str) {
42    let v = value.trim();
43    if !v.is_empty() {
44        cmd.push(flag.into());
45        cmd.push(v.into());
46    }
47}
48
49fn flag(cmd: &mut Vec<String>, name: &str, on: bool) {
50    if on {
51        cmd.push(name.into());
52    }
53}
54
55/// `_cmd_scan`.
56pub fn cmd_scan(
57    exe: &Path,
58    slug: &str,
59    script: &str,
60    speakers: &str,
61    json: bool,
62) -> Result<Vec<String>, String> {
63    if script.trim().is_empty() {
64        return Err("Scan requires a script — select one from the dropdown.".into());
65    }
66    let mut cmd = base(exe, "scan");
67    cmd.extend(["--show".into(), slug.into(), script.trim().into()]);
68    opt(&mut cmd, "--speakers", speakers);
69    flag(&mut cmd, "--json", json);
70    Ok(cmd)
71}
72
73/// Options of the Parse sub-tab.
74#[derive(Default)]
75pub struct ParseOpts<'a> {
76    pub script: &'a str,
77    pub preview: i64,
78    pub quiet: bool,
79    pub debug: bool,
80    pub stats: bool,
81    pub speakers: &'a str,
82}
83
84/// `_cmd_parse`: a blank script falls back to the first `.md` under
85/// `scripts/{slug}/`, then `scripts/`.
86pub fn cmd_parse(exe: &Path, slug: &str, tag: &str, o: &ParseOpts) -> Result<Vec<String>, String> {
87    let mut cmd = base(exe, "parse");
88    if !o.script.trim().is_empty() {
89        cmd.push(o.script.trim().into());
90    } else {
91        let root = workspace_root();
92        let mut candidates: Vec<PathBuf> = if slug.is_empty() {
93            Vec::new()
94        } else {
95            glob_children(&root.join("scripts").join(slug), "", ".md")
96        };
97        if candidates.is_empty() {
98            candidates = glob_children(&root.join("scripts"), "", ".md");
99        }
100        sort_py(&mut candidates);
101        let Some(first) = candidates.first() else {
102            return Err("No script path given and no .md files found in scripts/".into());
103        };
104        cmd.push(first.to_string_lossy().into_owned());
105    }
106    cmd.extend(["--episode".into(), tag.into()]);
107    if o.preview > 0 {
108        cmd.extend(["--preview".into(), o.preview.to_string()]);
109    }
110    flag(&mut cmd, "--quiet", o.quiet);
111    flag(&mut cmd, "--debug", o.debug);
112    flag(&mut cmd, "--stats", o.stats);
113    opt(&mut cmd, "--speakers", o.speakers);
114    Ok(cmd)
115}
116
117/// Options of the Produce sub-tab.
118#[derive(Default)]
119pub struct ProduceOpts<'a> {
120    pub dry_run: bool,
121    pub backend: &'a str,
122    pub gen_sfx: bool,
123    pub gen_music: bool,
124    pub gen_ambience: bool,
125    pub local_only: bool,
126    pub terse: bool,
127    pub start_from: i64,
128    pub stop_at: i64,
129    /// `--seq-list`: a comma-separated set of specific sequence numbers,
130    /// e.g. `12,47,48`. Takes precedence over `start_from`/`stop_at` in
131    /// `xil produce` itself; blank emits nothing.
132    pub seq_list: &'a str,
133    pub chatterbox_python: &'a str,
134    /// `--device`: "cuda" (default) or "cpu", Chatterbox Turbo only. The
135    /// worker already auto-falls-back to cpu on its own when cuda is
136    /// requested but unavailable — this exists to let someone deliberately
137    /// force cpu on a machine that *does* have a GPU (e.g. it's busy with
138    /// another job), or just make the choice visible instead of a log line.
139    pub device: &'a str,
140    pub force: bool,
141    pub sfx_backend: &'a str,
142    pub mmaudio_python: &'a str,
143    pub mmaudio_accept_nc: bool,
144}
145
146/// `_cmd_produce`.
147pub fn cmd_produce(exe: &Path, tag: &str, o: &ProduceOpts) -> Vec<String> {
148    let mut cmd = base(exe, "produce");
149    cmd.extend(["--episode".into(), tag.into()]);
150    flag(&mut cmd, "--dry-run", o.dry_run);
151    if !o.backend.is_empty() && o.backend != "elevenlabs" {
152        cmd.extend(["--backend".into(), o.backend.into()]);
153    }
154    flag(&mut cmd, "--gen-sfx", o.gen_sfx);
155    flag(&mut cmd, "--gen-music", o.gen_music);
156    flag(&mut cmd, "--gen-ambience", o.gen_ambience);
157    flag(&mut cmd, "--local-only", o.local_only);
158    flag(&mut cmd, "--terse", o.terse);
159    if o.start_from > 0 {
160        cmd.extend(["--start-from".into(), o.start_from.to_string()]);
161    }
162    if o.stop_at > 0 {
163        cmd.extend(["--stop-at".into(), o.stop_at.to_string()]);
164    }
165    opt(&mut cmd, "--seq-list", o.seq_list);
166    if o.backend == "chatterbox-turbo" {
167        opt(&mut cmd, "--chatterbox-python", o.chatterbox_python);
168        if o.device == "cpu" {
169            cmd.extend(["--device".into(), "cpu".into()]);
170        }
171    }
172    if !o.sfx_backend.is_empty() && o.sfx_backend != "elevenlabs" {
173        cmd.extend(["--sfx-backend".into(), o.sfx_backend.into()]);
174    }
175    if o.sfx_backend == "mmaudio" {
176        flag(
177            &mut cmd,
178            "--mmaudio-accept-noncommercial",
179            o.mmaudio_accept_nc,
180        );
181        opt(&mut cmd, "--mmaudio-python", o.mmaudio_python);
182    }
183    flag(&mut cmd, "--force", o.force);
184    cmd
185}
186
187/// `_cmd_assemble`.
188pub fn cmd_assemble(exe: &Path, tag: &str, gap_ms: i64, parsed: &str, output: &str) -> Vec<String> {
189    let mut cmd = base(exe, "assemble");
190    cmd.extend(["--episode".into(), tag.into()]);
191    if gap_ms != 600 {
192        cmd.extend(["--gap-ms".into(), gap_ms.to_string()]);
193    }
194    opt(&mut cmd, "--parsed", parsed);
195    opt(&mut cmd, "--output", output);
196    cmd
197}
198
199/// Options of the DAW sub-tab.
200#[derive(Default)]
201pub struct DawOpts<'a> {
202    pub dry_run: bool,
203    pub gap_ms: i64,
204    pub timeline: bool,
205    pub timeline_html: bool,
206    pub macro_: bool,
207    pub save_aup3: bool,
208    pub output_dir: &'a str,
209}
210
211/// `_cmd_daw`.
212pub fn cmd_daw(exe: &Path, tag: &str, o: &DawOpts) -> Vec<String> {
213    let mut cmd = base(exe, "daw");
214    cmd.extend(["--episode".into(), tag.into()]);
215    flag(&mut cmd, "--dry-run", o.dry_run);
216    if o.gap_ms != 600 {
217        cmd.extend(["--gap-ms".into(), o.gap_ms.to_string()]);
218    }
219    flag(&mut cmd, "--timeline", o.timeline);
220    flag(&mut cmd, "--timeline-html", o.timeline_html);
221    flag(&mut cmd, "--macro", o.macro_);
222    flag(&mut cmd, "--save-aup3", o.save_aup3);
223    opt(&mut cmd, "--output-dir", o.output_dir);
224    cmd
225}
226
227/// `_cmd_master`.
228pub fn cmd_master(
229    exe: &Path,
230    tag: &str,
231    dry_run: bool,
232    output: &str,
233    daw_dir: &str,
234) -> Vec<String> {
235    let mut cmd = base(exe, "master");
236    cmd.extend(["--episode".into(), tag.into()]);
237    flag(&mut cmd, "--dry-run", dry_run);
238    opt(&mut cmd, "--output", output);
239    opt(&mut cmd, "--daw-dir", daw_dir);
240    cmd
241}
242
243/// `run_init`'s command: `xil init --show NAME --type T --flat --season N`.
244pub fn cmd_init(
245    exe: &Path,
246    show: &str,
247    content_type: &str,
248    season: &str,
249    season_title: &str,
250) -> Vec<String> {
251    let mut cmd = base(exe, "init");
252    cmd.extend([
253        "--show".into(),
254        show.trim().into(),
255        "--type".into(),
256        content_type.into(),
257        "--flat".into(),
258        "--season".into(),
259        if season.trim().is_empty() {
260            "1".into()
261        } else {
262            season.trim().into()
263        },
264    ]);
265    opt(&mut cmd, "--season-title", season_title);
266    cmd
267}
268
269/// The `$ cmd` header every run log starts with.
270pub fn header(cmd: &[String]) -> String {
271    format!("$ {}\n\n", cmd.join(" "))
272}
273
274async fn pump<R: AsyncRead + Unpin>(reader: R, tx: UnboundedSender<JobEvent>) {
275    let mut lines = BufReader::new(reader).lines();
276    loop {
277        match lines.next_line().await {
278            Ok(Some(line)) => {
279                activity::log(&line);
280                if tx.send(JobEvent::Line(line)).is_err() {
281                    break;
282                }
283            }
284            Ok(None) => break,
285            // Not UTF-8: skip the line rather than lose the rest of the run.
286            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue,
287            Err(_) => break,
288        }
289    }
290}
291
292/// `_execute_cmd(cmd)`: start `cmd` in the workspace and register a job whose
293/// stdout and stderr lines stream to `/jobs/{id}/stream`. Returns the job id.
294pub fn start_job(state: &Arc<AppState>, cmd: Vec<String>) -> u64 {
295    let id = state.job_id();
296    let (tx, rx) = unbounded_channel();
297    if let Ok(mut jobs) = state.jobs.lock() {
298        jobs.insert(id, rx);
299    }
300    activity::log(&format!("CMD: {}", cmd.join(" ")));
301    let job_state = state.clone();
302    tokio::spawn(async move {
303        let spawned = Command::new(&cmd[0])
304            .args(&cmd[1..])
305            .current_dir(workspace_root())
306            .env("PYTHONUNBUFFERED", "1")
307            .stdin(Stdio::null())
308            .stdout(Stdio::piped())
309            .stderr(Stdio::piped())
310            .spawn();
311        let mut child = match spawned {
312            Ok(c) => c,
313            Err(e) => {
314                activity::log(&format!("[ERROR] {e}"));
315                let _ = tx.send(JobEvent::Exit(format!("[ERROR] {e}")));
316                return;
317            }
318        };
319        let out = child
320            .stdout
321            .take()
322            .map(|o| tokio::spawn(pump(o, tx.clone())));
323        let err = child
324            .stderr
325            .take()
326            .map(|e| tokio::spawn(pump(e, tx.clone())));
327        for task in [out, err].into_iter().flatten() {
328            let _ = task.await;
329        }
330        let code = match child.wait().await {
331            Ok(s) => s.code().unwrap_or(-1),
332            Err(_) => -1,
333        };
334        activity::log(&format!("[exit {code}]"));
335        // A stage can create episodes, scripts or configs.
336        job_state.invalidate_choices();
337        let _ = tx.send(JobEvent::Exit(format!("[exit {code}]")));
338    });
339    id
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn extra_flags_split_like_a_shell_and_refuse_metacharacters() {
348        assert_eq!(sanitize_extra_flags("").unwrap(), Vec::<String>::new());
349        assert_eq!(
350            sanitize_extra_flags("--dry-run --gap-ms 400").unwrap(),
351            ["--dry-run", "--gap-ms", "400"]
352        );
353        assert_eq!(
354            sanitize_extra_flags("--output \"my file.mp3\"").unwrap(),
355            ["--output", "my file.mp3"]
356        );
357        for bad in ["; rm -rf /", "a|b", "$(id)", "`id`", "x && y", "a>b", "!x"] {
358            assert!(sanitize_extra_flags(bad).is_err(), "{bad}");
359        }
360        assert!(sanitize_extra_flags("\"unbalanced")
361            .unwrap_err()
362            .starts_with("Invalid flag syntax"));
363    }
364
365    #[test]
366    fn produce_command_passes_only_non_default_options() {
367        let exe = Path::new("xil");
368        let o = ProduceOpts {
369            backend: "elevenlabs",
370            sfx_backend: "elevenlabs",
371            ..Default::default()
372        };
373        assert_eq!(
374            cmd_produce(exe, "S01E01", &o),
375            ["xil", "produce", "--episode", "S01E01"]
376        );
377        let o = ProduceOpts {
378            dry_run: true,
379            backend: "chatterbox-turbo",
380            chatterbox_python: " /venv/bin/python3 ",
381            start_from: 5,
382            stop_at: 9,
383            sfx_backend: "mmaudio",
384            mmaudio_accept_nc: true,
385            mmaudio_python: "",
386            force: true,
387            ..Default::default()
388        };
389        assert_eq!(
390            cmd_produce(exe, "S01E01", &o),
391            [
392                "xil",
393                "produce",
394                "--episode",
395                "S01E01",
396                "--dry-run",
397                "--backend",
398                "chatterbox-turbo",
399                "--start-from",
400                "5",
401                "--stop-at",
402                "9",
403                "--chatterbox-python",
404                "/venv/bin/python3",
405                "--sfx-backend",
406                "mmaudio",
407                "--mmaudio-accept-noncommercial",
408                "--force",
409            ]
410        );
411    }
412
413    #[test]
414    fn produce_seq_list_overrides_display_but_start_stop_still_pass() {
415        let exe = Path::new("xil");
416        let o = ProduceOpts {
417            seq_list: "12,47,48",
418            force: true,
419            backend: "elevenlabs",
420            sfx_backend: "elevenlabs",
421            ..Default::default()
422        };
423        assert_eq!(
424            cmd_produce(exe, "S01E01", &o),
425            [
426                "xil",
427                "produce",
428                "--episode",
429                "S01E01",
430                "--seq-list",
431                "12,47,48",
432                "--force",
433            ]
434        );
435    }
436
437    #[test]
438    fn device_only_emitted_as_cpu_and_only_for_chatterbox() {
439        let exe = Path::new("xil");
440        // Default "cuda" (or blank) never needs a flag — the worker already
441        // falls back to cpu on its own when cuda is unavailable.
442        let o = ProduceOpts {
443            backend: "chatterbox-turbo",
444            sfx_backend: "elevenlabs",
445            device: "cuda",
446            ..Default::default()
447        };
448        assert!(!cmd_produce(exe, "S01E01", &o).contains(&"--device".to_string()));
449
450        // Explicit cpu passes through, but only when the backend is
451        // Chatterbox Turbo — another backend never even looks at --device.
452        let o = ProduceOpts {
453            backend: "chatterbox-turbo",
454            sfx_backend: "elevenlabs",
455            device: "cpu",
456            ..Default::default()
457        };
458        assert_eq!(
459            cmd_produce(exe, "S01E01", &o),
460            [
461                "xil",
462                "produce",
463                "--episode",
464                "S01E01",
465                "--backend",
466                "chatterbox-turbo",
467                "--device",
468                "cpu"
469            ]
470        );
471
472        let o = ProduceOpts {
473            backend: "elevenlabs",
474            sfx_backend: "elevenlabs",
475            device: "cpu",
476            ..Default::default()
477        };
478        assert!(!cmd_produce(exe, "S01E01", &o).contains(&"--device".to_string()));
479    }
480
481    #[test]
482    fn scan_needs_a_script_and_daw_skips_default_gap() {
483        let exe = Path::new("xil");
484        assert!(cmd_scan(exe, "s", " ", "", false).is_err());
485        assert_eq!(
486            cmd_scan(exe, "s", "scripts/a.md", "sp.json", true).unwrap(),
487            [
488                "xil",
489                "scan",
490                "--show",
491                "s",
492                "scripts/a.md",
493                "--speakers",
494                "sp.json",
495                "--json"
496            ]
497        );
498        let o = DawOpts {
499            gap_ms: 600,
500            timeline_html: true,
501            macro_: true,
502            ..Default::default()
503        };
504        assert_eq!(
505            cmd_daw(exe, "T", &o),
506            ["xil", "daw", "--episode", "T", "--timeline-html", "--macro"]
507        );
508        assert_eq!(
509            cmd_assemble(exe, "T", 400, "", " out.mp3 "),
510            [
511                "xil",
512                "assemble",
513                "--episode",
514                "T",
515                "--gap-ms",
516                "400",
517                "--output",
518                "out.mp3"
519            ]
520        );
521        assert_eq!(
522            cmd_init(exe, " Night Owls ", "podcast", "", ""),
523            [
524                "xil",
525                "init",
526                "--show",
527                "Night Owls",
528                "--type",
529                "podcast",
530                "--flat",
531                "--season",
532                "1"
533            ]
534        );
535    }
536}