1use std::ffi::OsString;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use clap::{Parser, ValueEnum};
8use serde_json::{json, Value};
9use xil_core::fsutil::abspath;
10use xil_core::log;
11use xil_core::pyjson::{dumps, Style};
12use xil_core::workspace::{set_active_show, show_slug, workspace_root};
13
14#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
15#[value(rename_all = "lower")]
16pub enum ContentType {
17 Podcast,
18 Audiobook,
19 Drama,
20 Special,
21}
22
23impl ContentType {
24 fn name(self) -> &'static str {
25 match self {
26 ContentType::Podcast => "podcast",
27 ContentType::Audiobook => "audiobook",
28 ContentType::Drama => "drama",
29 ContentType::Special => "special",
30 }
31 }
32
33 fn speakers(self) -> &'static [(&'static str, &'static str)] {
34 match self {
35 ContentType::Podcast => &[
36 ("HOST", "host"),
37 ("CO-HOST", "co_host"),
38 ("CALLER", "caller"),
39 ("NARRATOR", "narrator"),
40 ],
41 ContentType::Audiobook => &[("NARRATOR", "narrator")],
42 ContentType::Drama => &[
43 ("NARRATOR", "narrator"),
44 ("ALICE", "alice"),
45 ("BOB", "bob"),
46 ("CHARLIE", "charlie"),
47 ("DEZ", "dez"),
48 ],
49 ContentType::Special => &[("HOST", "host"), ("NARRATOR", "narrator")],
50 }
51 }
52
53 fn sample_tag(self) -> &'static str {
54 match self {
55 ContentType::Podcast | ContentType::Drama => "S01E01",
56 ContentType::Audiobook => "V01C01",
57 ContentType::Special => "SP001",
58 }
59 }
60
61 fn script(self) -> &'static str {
62 match self {
63 ContentType::Podcast => PODCAST_SCRIPT,
64 ContentType::Audiobook => AUDIOBOOK_SCRIPT,
65 ContentType::Drama => DRAMA_SCRIPT,
66 ContentType::Special => SPECIAL_SCRIPT,
67 }
68 }
69}
70
71#[derive(Parser)]
72#[command(
73 name = "xil-init",
74 about = "Scaffold a new xil-pipeline project workspace"
75)]
76struct Args {
77 directory: Option<String>,
79 #[arg(long, default_value = "Sample Show")]
81 show: String,
82 #[arg(long = "type", value_enum, default_value_t = ContentType::Podcast)]
84 content_type: ContentType,
85 #[arg(long, allow_hyphen_values = true)]
87 season: Option<i64>,
88 #[arg(long, value_name = "TITLE")]
90 season_title: Option<String>,
91 #[arg(long, overrides_with = "no_flat")]
93 flat: bool,
94 #[arg(long, overrides_with = "flat")]
96 no_flat: bool,
97}
98
99fn write_json(path: &Path, v: &Value) -> std::io::Result<()> {
100 fs::write(path, format!("{}\n", dumps(v, Style::INDENT2)))
101}
102
103pub fn scaffold(
104 directory: &Path,
105 show_name: &str,
106 content_type: ContentType,
107 season: Option<i64>,
108 season_title: Option<&str>,
109 flat_layout: bool,
110) -> anyhow::Result<()> {
111 fs::create_dir_all(directory)?;
112 let slug = show_slug(show_name);
113
114 let mut project = serde_json::Map::new();
115 project.insert("show".into(), Value::String(show_name.to_string()));
116 project.insert(
117 "type".into(),
118 Value::String(content_type.name().to_string()),
119 );
120 if let Some(s) = season {
121 project.insert("season".into(), Value::from(s));
122 }
123 if let Some(t) = season_title {
124 project.insert("season_title".into(), Value::String(t.to_string()));
125 }
126 if content_type == ContentType::Audiobook {
127 project.insert(
128 "tag_format".into(),
129 Value::String("V{volume:02d}C{chapter:02d}".into()),
130 );
131 }
132
133 let project_path = if flat_layout {
134 for shared in ["scripts", "SFX", "voice_samples"] {
135 fs::create_dir_all(directory.join(shared))?;
136 }
137 for slug_dir in [format!("scripts/{slug}"), format!("SFX/{slug}")] {
138 fs::create_dir_all(directory.join(slug_dir))?;
139 }
140 fs::create_dir_all(directory.join("configs").join(&slug))?;
141 for category in ["daw", "stems", "masters", "parsed", "cues", "posts"] {
142 fs::create_dir_all(directory.join(category).join(&slug))?;
143 }
144 directory.join("configs").join(&slug).join("project.json")
145 } else {
146 for sub in [
147 "scripts",
148 &format!("configs/{slug}"),
149 "parsed",
150 "stems",
151 "SFX",
152 "daw",
153 "masters",
154 "cues",
155 ] {
156 fs::create_dir_all(directory.join(sub))?;
157 }
158 directory.join("project.json")
159 };
160
161 if !project_path.exists() {
162 write_json(&project_path, &Value::Object(project))?;
163 log::info(&format!(" Created {}", project_path.display()));
164 } else {
165 log::info(&format!(
166 " Skipped {} (already exists)",
167 project_path.display()
168 ));
169 }
170
171 let speakers_path = directory.join("configs").join(&slug).join("speakers.json");
172 if !speakers_path.exists() {
173 let speakers: Vec<Value> = content_type
174 .speakers()
175 .iter()
176 .map(|(display, key)| json!({"display": display, "key": key}))
177 .collect();
178 write_json(&speakers_path, &Value::Array(speakers))?;
179 log::info(&format!(" Created {}", speakers_path.display()));
180 } else {
181 log::info(&format!(
182 " Skipped {} (already exists)",
183 speakers_path.display()
184 ));
185 }
186
187 let tag = content_type.sample_tag();
188 let season_part = season.map(|s| format!(" Season {s}:")).unwrap_or_default();
189 let arc_part = season_title
190 .filter(|t| !t.is_empty())
191 .map(|t| format!(" Arc: \"{t}\""))
192 .unwrap_or_default();
193 let script_path = if flat_layout {
194 directory
195 .join("scripts")
196 .join(&slug)
197 .join(format!("sample_{tag}.md"))
198 } else {
199 directory.join("scripts").join(format!("sample_{tag}.md"))
200 };
201 if !script_path.exists() {
202 let body = content_type
203 .script()
204 .replace("{show}", show_name)
205 .replace("{season_part}", &season_part)
206 .replace("{arc_part}", &arc_part);
207 fs::write(&script_path, body)?;
208 log::info(&format!(" Created {}", script_path.display()));
209 } else {
210 log::info(&format!(
211 " Skipped {} (already exists)",
212 script_path.display()
213 ));
214 }
215
216 if flat_layout {
217 set_active_show(&slug)?;
218 log::info(&format!(" Active show set to: {slug}"));
219 }
220 Ok(())
221}
222
223fn print_getting_started(directory: Option<&str>, content_type: ContentType) {
226 let dir = directory.unwrap_or("None");
227 let cd_prefix = if dir != "." {
228 format!("cd {dir} && ")
229 } else {
230 String::new()
231 };
232 let tag = content_type.sample_tag();
233 log::info(&format!(
234 "\nGetting Started\n===============\n\n1. Install the pipeline:\n pip install xil-pipeline\n\n\
235 2. Scan the sample script (pre-flight check):\n {cd_prefix}xil-scan scripts/sample_{tag}.md\n\n\
236 3. Parse the script into structured JSON:\n {cd_prefix}xil-parse scripts/sample_{tag}.md --episode {tag}\n\n\
237 4. Preview voice generation (no API key needed):\n {cd_prefix}xil-produce --episode {tag} --dry-run\n\n\
238 5. To use your own script:\n - Edit configs/<slug>/speakers.json with your cast\n - Write your script in scripts/\n \
239 - Set your ElevenLabs API key: export ELEVENLABS_API_KEY=your-key\n - Run the pipeline stages in order (see README.md)\n"
240 ));
241}
242
243pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
244 log::init("init");
245 let a: Args = match super::parse_or_exit("xil-init", args) {
246 Ok(a) => a,
247 Err(code) => return Ok(code),
248 };
249 let flat_layout = !a.no_flat;
250 let directory: PathBuf = match &a.directory {
251 None => workspace_root(),
252 Some(d) => abspath(Path::new(d)),
253 };
254 log::info(&format!(
255 "\nScaffolding xil-pipeline workspace in: {}",
256 directory.display()
257 ));
258 log::info(&format!(
259 "Show: {} Type: {} Flat layout: {}\n",
260 a.show,
261 a.content_type.name(),
262 if flat_layout { "True" } else { "False" }
263 ));
264 scaffold(
265 &directory,
266 &a.show,
267 a.content_type,
268 a.season,
269 a.season_title.as_deref(),
270 flat_layout,
271 )?;
272 print_getting_started(a.directory.as_deref(), a.content_type);
273 Ok(0)
274}
275
276const PODCAST_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Pilot"{arc_part}
277
278CAST:
279* HOST — the radio host
280* CO-HOST — the co-host
281* CALLER — a mysterious caller
282
283===
284
285COLD OPEN
286
287SCENE 1: THE STUDIO
288
289[AMBIENCE: Radio station studio, low hum of equipment]
290
291HOST
292Good evening, and welcome to the show. I'm your host.
293
294CO-HOST
295And I'm your co-host. We have a packed show tonight.
296
297[SFX: Phone ringing]
298
299HOST (picking up phone)
300We have our first caller of the night. You're on the air.
301
302CALLER (distorted, nervous)
303Hi... I wasn't sure I should call, but something strange happened last night.
304
305[BEAT]
306
307HOST
308Take your time. Tell us what happened.
309
310CALLER
311I was driving home when the radio cut out. Just static. And then... a voice.
312
313[BEAT — 3 SECONDS]
314
315===
316
317ACT ONE
318
319SCENE 2: THE INTERVIEW
320
321[AMBIENCE: Same studio, quieter now]
322
323HOST
324We're going to dig into that after this break. Don't go anywhere.
325
326CO-HOST
327When we come back — the story that has our phones ringing off the hook.
328
329[MUSIC: Upbeat podcast bumper]
330
331[BEAT]
332
333===
334
335MID-EPISODE BREAK
336
337HOST
338You're listening to {show}. I'm your host, back with my co-host.
339
340===
341
342ACT TWO
343
344SCENE 3: WRAP-UP
345
346CO-HOST
347Fascinating stuff. Thank you to everyone who called in tonight.
348
349HOST
350That's all for this episode. Until next time.
351
352[MUSIC: Closing theme, fade out]
353
354===
355
356CLOSING
357
358HOST
359{show} is produced by [Your Name]. Subscribe wherever you get your podcasts.
360
361===
362
363END OF EPISODE
364"#;
365
366const AUDIOBOOK_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Chapter One"{arc_part}
367
368CAST:
369* NARRATOR — the narrator
370
371===
372
373PROLOGUE
374
375NARRATOR
376Before we begin, a note from the author.
377
378[BEAT — 2 SECONDS]
379
380NARRATOR
381The events in this story are entirely fictional. Any resemblance to actual
382persons, living or dead, is purely coincidental.
383
384[BEAT — 3 SECONDS]
385
386===
387
388CHAPTER ONE
389
390NARRATOR
391It began, as most things do, on an ordinary morning.
392
393[BEAT]
394
395NARRATOR
396The sun rose over the hills, casting long shadows across the valley below.
397Nothing about that day seemed remarkable. And yet, by nightfall, everything
398would change.
399
400[SFX: Birds chirping, distant wind]
401
402NARRATOR
403She stepped onto the porch with her coffee and looked out at the horizon.
404Something was different. She couldn't say what — only that the light seemed
405wrong, somehow. Too bright. Too still.
406
407[BEAT — 2 SECONDS]
408
409NARRATOR
410She went back inside.
411
412[BEAT — 3 SECONDS]
413
414===
415
416CHAPTER TWO
417
418NARRATOR
419Three days passed before anyone noticed she was gone.
420
421[BEAT]
422
423NARRATOR
424The neighbor across the lane saw the mail piling up, the lights going dark
425one by one. She mentioned it to her husband. He said to leave it alone.
426
427[BEAT — 2 SECONDS]
428
429NARRATOR
430She didn't leave it alone.
431
432[BEAT — 3 SECONDS]
433
434===
435
436END OF EPISODE
437"#;
438
439const DRAMA_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Pilot"{arc_part}
440
441CAST:
442* NARRATOR — the narrator
443* ALICE — protagonist
444* BOB — antagonist
445* CHARLIE — a bystander
446
447===
448
449ACT ONE
450
451SCENE 1: THE STREET
452
453[AMBIENCE: City street, distant traffic, wind]
454
455NARRATOR
456The city never sleeps. But tonight, it should have.
457
458[BEAT]
459
460ALICE (quietly)
461I shouldn't be here.
462
463BOB (behind her)
464And yet, here you are.
465
466[SFX: Footsteps on wet pavement]
467
468ALICE
469How long have you been following me?
470
471BOB (calm)
472Long enough to know you found the documents.
473
474[BEAT — 2 SECONDS]
475
476ALICE
477I don't know what you're talking about.
478
479BOB
480We both know that's not true.
481
482[SFX: Car horn, distant]
483
484CHARLIE (interrupting)
485Hey — is everything all right over there?
486
487[BEAT]
488
489BOB (forced smile)
490Just old friends catching up.
491
492===
493
494ACT TWO
495
496SCENE 2: THE ALLEY
497
498[AMBIENCE: Narrow alley, dripping water, distant music]
499
500NARRATOR
501Alice ran. She didn't look back.
502
503[SFX: Running footsteps, splashing]
504
505ALICE (breathless, to herself)
506I need to get to the bridge. I need to warn them.
507
508[BEAT — 3 SECONDS]
509
510NARRATOR
511The bridge was three blocks away. Three very long blocks.
512
513[MUSIC: Tense underscore, building]
514
515[BEAT]
516
517CHARLIE (stepping out of shadow)
518I was hoping I'd find you first.
519
520ALICE
521Charlie? What are you doing here?
522
523CHARLIE (serious)
524Keeping you alive. Come with me.
525
526[SFX: Door opening]
527
528===
529
530END OF EPISODE
531"#;
532
533const SPECIAL_SCRIPT: &str = r#"{show}{season_part} Episode 1: "Special Presentation"{arc_part}
534
535CAST:
536* HOST — the host
537* NARRATOR — the narrator
538
539===
540
541INTRO
542
543[MUSIC: Fanfare, brief]
544
545HOST
546Welcome to this special presentation of {show}.
547
548NARRATOR
549What follows is a one-time look at something we don't often discuss.
550
551[BEAT — 2 SECONDS]
552
553===
554
555SEGMENT 1
556
557HOST
558Tonight we're exploring something that has fascinated people for generations.
559
560NARRATOR
561The question is simple. The answer, anything but.
562
563[SFX: Ambient texture, subtle]
564
565HOST
566Let's begin.
567
568[BEAT]
569
570===
571
572SEGMENT 2
573
574HOST
575Thank you for staying with us.
576
577NARRATOR
578We leave you with this thought: the most important stories are the ones
579we tell ourselves.
580
581[BEAT — 3 SECONDS]
582
583HOST
584Until next time.
585
586===
587
588OUTRO
589
590[MUSIC: Closing theme]
591
592HOST
593This has been {show}. Thank you for listening.
594
595===
596
597END OF EPISODE
598"#;
599
600pub fn command() -> clap::Command {
602 <Args as clap::CommandFactory>::command()
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn flat_scaffold_writes_project_speakers_script() {
611 let _env = crate::cmd::ENV_LOCK
612 .lock()
613 .unwrap_or_else(|e| e.into_inner());
614 let tmp = tempfile::tempdir().unwrap();
615 let ws = tmp.path().join("ws");
616 std::env::set_var("XIL_PROJECTROOT", &ws);
617 scaffold(
618 &ws,
619 "Night Owls",
620 ContentType::Audiobook,
621 Some(2),
622 Some("Arc Two"),
623 true,
624 )
625 .unwrap();
626 let pj = fs::read_to_string(ws.join("configs/nightowls/project.json")).unwrap();
627 assert_eq!(
628 pj,
629 "{\n \"show\": \"Night Owls\",\n \"type\": \"audiobook\",\n \"season\": 2,\n \"season_title\": \"Arc Two\",\n \"tag_format\": \"V{volume:02d}C{chapter:02d}\"\n}\n"
630 );
631 let sp = fs::read_to_string(ws.join("configs/nightowls/speakers.json")).unwrap();
632 assert_eq!(
633 sp,
634 "[\n {\n \"display\": \"NARRATOR\",\n \"key\": \"narrator\"\n }\n]\n"
635 );
636 let script = fs::read_to_string(ws.join("scripts/nightowls/sample_V01C01.md")).unwrap();
637 assert!(script
638 .starts_with("Night Owls Season 2: Episode 1: \"Chapter One\" Arc: \"Arc Two\"\n"));
639 assert!(ws.join("posts/nightowls").is_dir());
640 assert_eq!(
641 fs::read_to_string(ws.join(".active_show")).unwrap(),
642 "nightowls"
643 );
644 std::env::remove_var("XIL_PROJECTROOT");
645 }
646
647 #[test]
648 fn legacy_scaffold_puts_project_at_root() {
649 let tmp = tempfile::tempdir().unwrap();
650 let ws = tmp.path().join("legacy");
651 scaffold(&ws, "Sample Show", ContentType::Podcast, None, None, false).unwrap();
652 assert!(ws.join("project.json").exists());
653 assert!(ws.join("scripts/sample_S01E01.md").exists());
654 assert!(!ws.join("posts").exists());
655 }
656}