1use 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 #[arg(long)]
29 episode: Option<String>,
30 #[arg(long)]
32 tag: Option<String>,
33 #[arg(long)]
35 show: Option<String>,
36 #[arg(long = "zip", required = true)]
38 zip_path: String,
39 #[arg(long)]
41 parsed: Option<String>,
42 #[arg(long)]
44 stems_dir: Option<String>,
45 #[arg(long)]
47 dry_run: bool,
48 #[arg(long)]
50 force: bool,
51 #[arg(long)]
53 gen_sfx: bool,
54 #[arg(long)]
56 gen_music: bool,
57 #[arg(long)]
59 gen_beats: bool,
60 #[arg(long = "all")]
62 all_types: bool,
63}
64
65fn 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
289pub 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}