Skip to main content

xil/cmd/
import.rs

1//! `xil import` — extract an ElevenLabs Studio export ZIP into pipeline
2//! stems. Port of `XILP010_studio_import.py`.
3
4use std::collections::BTreeSet;
5use std::ffi::OsString;
6use std::fs;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10use clap::Parser;
11use indexmap::IndexMap;
12use serde_json::{Map, Value};
13use xil_core::workspace::{derive_paths, resolve_slug};
14use xil_core::{banner, log};
15
16use super::migrate::make_stem_name;
17
18const SCRIPT_NAME: &str = "XILP010 · Studio Import";
19
20#[derive(Parser)]
21#[command(
22    name = "xil-import",
23    about = "Import ElevenLabs Studio export ZIP into pipeline stems."
24)]
25#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
26struct Args {
27    /// Episode tag (e.g. S02E02) — derives parsed JSON and stems dir
28    #[arg(long)]
29    episode: Option<String>,
30    /// Raw tag for non-episodic content (e.g. V01C03, D01)
31    #[arg(long)]
32    tag: Option<String>,
33    /// Show name override (default: from project.json)
34    #[arg(long)]
35    show: Option<String>,
36    /// Path to the ElevenLabs Studio export ZIP file
37    #[arg(long = "zip", required = true)]
38    zip_path: String,
39    /// Override parsed JSON path (default: parsed/parsed_<slug>_{TAG}.json)
40    #[arg(long)]
41    parsed: Option<String>,
42    /// Override stems output directory (default: stems/{TAG})
43    #[arg(long)]
44    stems_dir: Option<String>,
45    /// Show extraction plan without writing files
46    #[arg(long)]
47    dry_run: bool,
48    /// Overwrite existing stems on disk
49    #[arg(long)]
50    force: bool,
51    /// Include SFX direction entries
52    #[arg(long)]
53    gen_sfx: bool,
54    /// Include MUSIC direction entries
55    #[arg(long)]
56    gen_music: bool,
57    /// Include BEAT direction entries
58    #[arg(long)]
59    gen_beats: bool,
60    /// Include all direction types (SFX, MUSIC, BEAT, AMBIENCE)
61    #[arg(long = "all")]
62    all_types: bool,
63}
64
65/// `_parse_zip_seq`: the leading `NNN_` of a member's basename.
66fn parse_zip_seq(member: &str) -> Option<i64> {
67    let base = member.rsplit('/').next().unwrap_or(member);
68    let digits: String = base.chars().take_while(|c| c.is_ascii_digit()).collect();
69    if digits.is_empty() || !base[digits.len()..].starts_with('_') {
70        return None;
71    }
72    digits.parse().ok()
73}
74
75#[derive(Default)]
76struct Stats {
77    extracted: usize,
78    skipped_exists: usize,
79    skipped_type: usize,
80    skipped_header: usize,
81    missing_seq: usize,
82}
83
84fn extract_stems(
85    zip_path: &Path,
86    entries: &[Map<String, Value>],
87    stems_dir: &str,
88    dry_run: bool,
89    force: bool,
90    include: &BTreeSet<&str>,
91) -> anyhow::Result<Stats> {
92    let mut by_seq: IndexMap<i64, &Map<String, Value>> = IndexMap::new();
93    for e in entries {
94        if let Some(seq) = e.get("seq").and_then(Value::as_i64) {
95            by_seq.insert(seq, e);
96        }
97    }
98    let mut stats = Stats::default();
99    if !dry_run {
100        fs::create_dir_all(stems_dir)?;
101    }
102    let mut zf = zip::ZipArchive::new(fs::File::open(zip_path)?)?;
103    let mut members: Vec<String> = zf.file_names().map(str::to_string).collect();
104    members.sort();
105    for member in members {
106        if !member.to_lowercase().ends_with(".mp3") {
107            continue;
108        }
109        let Some(seq) = parse_zip_seq(&member) else {
110            continue;
111        };
112        let Some(entry) = by_seq.get(&seq) else {
113            stats.missing_seq += 1;
114            log::info(&format!(
115                "  [MISSING]  {member}  → seq {seq} not in parsed JSON"
116            ));
117            continue;
118        };
119        let entry_type = entry.get("type").and_then(Value::as_str).unwrap_or("");
120        let text: String = entry
121            .get("text")
122            .and_then(Value::as_str)
123            .unwrap_or("")
124            .chars()
125            .take(50)
126            .collect();
127        let direction_type = entry
128            .get("direction_type")
129            .and_then(Value::as_str)
130            .unwrap_or("");
131        if entry_type == "section_header" || entry_type == "scene_header" {
132            stats.skipped_header += 1;
133            log::info(&format!("  [HEADER]   {member}  — {entry_type}: {text}"));
134            continue;
135        }
136        if entry_type == "direction" && !include.contains(direction_type) {
137            stats.skipped_type += 1;
138            let label = if direction_type.is_empty() {
139                String::new()
140            } else {
141                format!("{direction_type}: ")
142            };
143            log::info(&format!("  [SKIP]     {member}  — {label}{text}"));
144            continue;
145        }
146        let stem_name = make_stem_name(entry);
147        let dest = Path::new(stems_dir).join(&stem_name);
148        if dest.exists() && !force {
149            stats.skipped_exists += 1;
150            log::info(&format!("  [EXISTS]   {member}  → {stem_name}"));
151            continue;
152        }
153        let marker = if dry_run { "DRY-RUN" } else { "EXTRACT" };
154        let speaker = entry
155            .get("speaker")
156            .and_then(Value::as_str)
157            .filter(|s| !s.is_empty())
158            .unwrap_or("sfx");
159        log::info(&format!(
160            "  [{marker}]  {member}  → {stem_name}  ({speaker}: {text})"
161        ));
162        if !dry_run {
163            let mut data = Vec::new();
164            zf.by_name(&member)?.read_to_end(&mut data)?;
165            fs::write(&dest, data)?;
166        }
167        stats.extracted += 1;
168    }
169    Ok(stats)
170}
171
172fn print_summary(s: &Stats, dry_run: bool) {
173    let mode = if dry_run { "DRY-RUN" } else { "COMPLETE" };
174    let rule = "─".repeat(50);
175    log::info(&format!("\n{rule}"));
176    log::info(&format!("  SUMMARY ({mode})"));
177    log::info(&rule);
178    log::info(&format!("  Extracted:       {:>4}", s.extracted));
179    log::info(&format!("  Skipped (exist): {:>4}", s.skipped_exists));
180    log::info(&format!("  Skipped (type):  {:>4}", s.skipped_type));
181    log::info(&format!("  Skipped (header):{:>4}", s.skipped_header));
182    if s.missing_seq > 0 {
183        log::info(&format!("  Missing seq:     {:>4}  ⚠", s.missing_seq));
184    }
185    log::info("");
186}
187
188pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
189    log::init("import");
190    let a: Args = match super::parse_or_exit("xil-import", args) {
191        Ok(a) => a,
192        Err(code) => return Ok(code),
193    };
194    let mut include: BTreeSet<&str> = BTreeSet::new();
195    if a.gen_sfx || a.all_types {
196        include.insert("SFX");
197    }
198    if a.gen_music || a.all_types {
199        include.insert("MUSIC");
200    }
201    if a.gen_beats || a.all_types {
202        include.insert("BEAT");
203    }
204    if a.all_types {
205        include.insert("AMBIENCE");
206    }
207    let tag = a
208        .episode
209        .clone()
210        .or_else(|| a.tag.clone())
211        .unwrap_or_default();
212    let slug = resolve_slug(a.show.as_deref(), "project.json");
213    let p = derive_paths(&slug, &tag);
214    let parsed_path: PathBuf = a
215        .parsed
216        .clone()
217        .filter(|s| !s.is_empty())
218        .map(PathBuf::from)
219        .unwrap_or_else(|| p["parsed"].clone());
220    let stems_dir = a
221        .stems_dir
222        .clone()
223        .filter(|s| !s.is_empty())
224        .unwrap_or_else(|| p["stems"].to_string_lossy().into_owned());
225
226    let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
227    if !Path::new(&a.zip_path).is_file() {
228        log::error(&format!("ZIP file not found: {}", a.zip_path));
229        return Ok(0);
230    }
231    if !parsed_path.is_file() {
232        log::error(&format!("Parsed JSON not found: {}", parsed_path.display()));
233        return Ok(0);
234    }
235    let parsed: Value = serde_json::from_str(&fs::read_to_string(&parsed_path)?)?;
236    let entries: Vec<Map<String, Value>> = parsed["entries"]
237        .as_array()
238        .into_iter()
239        .flatten()
240        .filter_map(|e| e.as_object().cloned())
241        .collect();
242    let count = |t: &str| {
243        entries
244            .iter()
245            .filter(|e| e.get("type").and_then(Value::as_str) == Some(t))
246            .count()
247    };
248    let (total, dialogue, direction) = (entries.len(), count("dialogue"), count("direction"));
249    log::info(&format!("  Episode:    {tag}"));
250    log::info(&format!("  ZIP:        {}", a.zip_path));
251    log::info(&format!(
252        "  Parsed:     {}  ({total} entries)",
253        parsed_path.display()
254    ));
255    log::info(&format!("  Stems dir:  {stems_dir}"));
256    log::info(&format!(
257        "  Entries:    {dialogue} dialogue, {direction} directions, {} headers",
258        total - dialogue - direction
259    ));
260    let mut mode = Vec::new();
261    if a.dry_run {
262        mode.push("dry-run".to_string());
263    }
264    if a.force {
265        mode.push("force".to_string());
266    }
267    if !include.is_empty() {
268        mode.push(format!(
269            "include: {}",
270            include.iter().copied().collect::<Vec<_>>().join(", ")
271        ));
272    }
273    if !mode.is_empty() {
274        log::info(&format!("  Mode:       {}", mode.join(", ")));
275    }
276    log::info("");
277    let stats = extract_stems(
278        Path::new(&a.zip_path),
279        &entries,
280        &stems_dir,
281        a.dry_run,
282        a.force,
283        &include,
284    )?;
285    print_summary(&stats, a.dry_run);
286    Ok(0)
287}
288
289/// The clap definition behind `--help`, for man pages.
290pub fn command() -> clap::Command {
291    <Args as clap::CommandFactory>::command()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn zip_member_sequence() {
300        assert_eq!(parse_zip_seq("042_Chapter 1.mp3"), Some(42));
301        assert_eq!(parse_zip_seq("export/007_Chapter 2.mp3"), Some(7));
302        assert_eq!(parse_zip_seq("Chapter_1.mp3"), None);
303        assert_eq!(parse_zip_seq("12.mp3"), None);
304    }
305}