1use 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
17static SHELL_UNSAFE: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"[;|&$`()\[\]<>!\\\n\r]").unwrap());
20
21pub 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
55pub 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#[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
84pub 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#[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 pub seq_list: &'a str,
133 pub chatterbox_python: &'a str,
134 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
146pub 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
187pub 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#[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
211pub 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
227pub 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
243pub 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
269pub 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 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => continue,
287 Err(_) => break,
288 }
289 }
290}
291
292pub 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 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 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 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}