1use std::ffi::OsString;
5use std::fs;
6
7use clap::Parser;
8use serde_json::{json, Map, Value};
9use xil_core::pyfmt::commas;
10use xil_core::pyjson::{dumps, Style};
11use xil_core::workspace::{derive_paths, resolve_slug};
12use xil_core::{banner, log};
13
14#[derive(Parser)]
15#[command(
16 name = "xil-studio-onboard",
17 about = "Onboard an episode to an ElevenLabs Studio project."
18)]
19#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
20struct Args {
21 #[arg(long)]
23 episode: Option<String>,
24 #[arg(long)]
26 tag: Option<String>,
27 #[arg(long)]
29 show: Option<String>,
30 #[arg(long)]
32 dry_run: bool,
33 #[arg(long, default_value = "standard", value_parser = ["standard", "high", "ultra", "ultra_lossless"])]
35 quality: String,
36 #[arg(long, default_value = "eleven_v3")]
38 model: String,
39}
40
41fn str_of(v: &Value) -> String {
42 match v {
43 Value::String(s) => s.clone(),
44 Value::Null => "None".into(),
45 other => xil_core::pycsv::cell(other),
46 }
47}
48
49fn narrator_voice(cast: &Map<String, Value>) -> Value {
51 cast.values()
52 .find(|m| m.get("role").and_then(Value::as_str) == Some("Host/Narrator"))
53 .or_else(|| cast.values().next())
54 .and_then(|m| m.get("voice_id").cloned())
55 .unwrap_or(Value::Null)
56}
57
58fn build_content_json(parsed: &Value, cast: &Map<String, Value>) -> Vec<Value> {
60 let narrator = narrator_voice(cast);
61 let mut chapters: Vec<Value> = Vec::new();
62 let node = |text: &Value, voice: &Value, sub: &str| json!({"sub_type": sub, "nodes": [{"type": "tts_node", "text": text, "voice_id": voice}]});
63 for entry in parsed["entries"].as_array().into_iter().flatten() {
64 let text = entry.get("text").cloned().unwrap_or(Value::Null);
65 match entry.get("type").and_then(Value::as_str) {
66 Some("section_header") => chapters.push(json!({"name": text, "blocks": []})),
67 Some(kind @ ("scene_header" | "dialogue")) => {
68 if chapters.is_empty() {
69 chapters.push(json!({"name": "Untitled", "blocks": []}));
70 }
71 let block = if kind == "scene_header" {
72 node(&text, &narrator, "h2")
73 } else {
74 let voice = entry
75 .get("speaker")
76 .and_then(Value::as_str)
77 .and_then(|s| cast.get(s))
78 .and_then(|m| m.get("voice_id").cloned())
79 .unwrap_or_else(|| narrator.clone());
80 node(&text, &voice, "p")
81 };
82 chapters.last_mut().unwrap()["blocks"]
83 .as_array_mut()
84 .unwrap()
85 .push(block);
86 }
87 _ => {}
88 }
89 }
90 chapters
91}
92
93fn dry_run(chapters: &[Value], cast: &Map<String, Value>) {
94 let mut voice_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
95 for (key, info) in cast {
96 let name = info
97 .get("full_name")
98 .map(str_of)
99 .unwrap_or_else(|| key.clone());
100 voice_map.insert(str_of(&info["voice_id"]), name);
101 }
102 let bar = "=".repeat(60);
103 log::info(&format!("\n{bar}"));
104 log::info("STUDIO PROJECT — DRY RUN");
105 log::info(&bar);
106 let (mut total_blocks, mut total_chars) = (0usize, 0i64);
107 for ch in chapters {
108 log::info(&format!("\n Chapter: {}", str_of(&ch["name"])));
109 let blocks = ch["blocks"].as_array().cloned().unwrap_or_default();
110 total_blocks += blocks.len();
111 let nodes: Vec<&Value> = blocks
112 .iter()
113 .flat_map(|b| b["nodes"].as_array().into_iter().flatten())
114 .collect();
115 let chars: i64 = nodes
116 .iter()
117 .map(|n| n["text"].as_str().map_or(0, |t| t.chars().count()) as i64)
118 .sum();
119 total_chars += chars;
120 let mut used: Vec<String> = Vec::new();
121 for n in &nodes {
122 if let Some(vid) = n.get("voice_id").filter(|v| crate::cmd::truthy(v)) {
123 let label = voice_map
124 .get(&str_of(vid))
125 .cloned()
126 .unwrap_or_else(|| str_of(vid));
127 if !used.contains(&label) {
128 used.push(label);
129 }
130 }
131 }
132 used.sort();
133 log::info(&format!(
134 " Blocks: {} | Characters: {}",
135 blocks.len(),
136 commas(chars)
137 ));
138 log::info(&format!(" Voices: {}", used.join(", ")));
139 }
140 log::info(&format!(
141 "\n TOTAL: {} chapters, {total_blocks} blocks, {} characters",
142 chapters.len(),
143 commas(total_chars)
144 ));
145 log::info(&format!("{bar}\n"));
146}
147
148pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
149 log::init("studio-onboard");
150 let result = {
151 let _banner = banner::begin(super::prog(), &super::argv_line(args));
152 execute(args)
153 };
154 super::finish(result)
155}
156
157fn execute(args: &[OsString]) -> anyhow::Result<i32> {
158 let a: Args = match super::parse_or_exit("xil-studio-onboard", args) {
159 Ok(a) => a,
160 Err(code) => return Ok(code),
161 };
162 let key = std::env::var("ELEVENLABS_API_KEY")
163 .ok()
164 .filter(|k| !k.is_empty());
165 if !a.dry_run && key.is_none() {
166 return Err(super::SysExit(
167 "Error: ELEVENLABS_API_KEY environment variable is not set.".into(),
168 )
169 .into());
170 }
171 let tag = a
172 .episode
173 .clone()
174 .or_else(|| a.tag.clone())
175 .unwrap_or_default();
176 let slug = resolve_slug(a.show.as_deref(), "project.json");
177 let p = derive_paths(&slug, &tag);
178 if !p["parsed"].exists() {
179 log::error(&format!("Parsed file not found: {}", p["parsed"].display()));
180 return Ok(1);
181 }
182 if !p["cast"].exists() {
183 log::error(&format!("Cast file not found: {}", p["cast"].display()));
184 return Ok(1);
185 }
186 let parsed: Value = serde_json::from_str(&fs::read_to_string(&p["parsed"])?)?;
187 let cast_doc: Value = serde_json::from_str(&fs::read_to_string(&p["cast"])?)?;
188 let cast = cast_doc["cast"].as_object().cloned().unwrap_or_default();
189 let tbd: Vec<&str> = cast
190 .iter()
191 .filter(|(_, m)| m.get("voice_id").map_or(true, |v| v == "TBD"))
192 .map(|(k, _)| k.as_str())
193 .collect();
194 if !tbd.is_empty() {
195 log::error(&format!("TBD voice_id for: {}", tbd.join(", ")));
196 log::error(" Assign voice IDs in cast config before onboarding.");
197 return Ok(1);
198 }
199 let chapters = build_content_json(&parsed, &cast);
200 if a.dry_run {
201 dry_run(&chapters, &cast);
202 return Ok(0);
203 }
204
205 let narrator = narrator_voice(&cast);
206 let show = parsed
207 .get("show")
208 .map(str_of)
209 .unwrap_or_else(|| "Unknown Show".into());
210 let title = parsed
211 .get("title")
212 .map(str_of)
213 .unwrap_or_else(|| tag.clone());
214 let project_name = format!("XILP004 - {show} — {title} ({tag})");
215 log::info(&format!("Creating Studio project: {project_name}"));
216
217 let client = xil_api::elevenlabs::Client::new(key);
218 match client.user_get() {
219 Ok(user) => {
220 let sub = &user["subscription"];
221 let used = sub["character_count"].as_i64().unwrap_or(0);
222 let limit = sub["character_limit"].as_i64().unwrap_or(0);
223 let bar = "=".repeat(40);
224 log::info(&format!("\n{bar}"));
225 log::info("ELEVENLABS API STATUS:");
226 log::info(&format!(
227 " Tier: {}",
228 str_of(&sub["tier"]).to_uppercase()
229 ));
230 log::info(&format!(
231 " Usage: {} / {} characters",
232 commas(used),
233 commas(limit)
234 ));
235 log::info(&format!(" Remaining: {}", commas(limit - used)));
236 log::info(&format!("{bar}\n"));
237 }
238 Err(xil_api::ApiError::Status { status, body }) => {
239 log::warning("API Error: Unable to fetch subscription data.");
240 log::warning(&format!(" Details: status_code: {status}, body: {body}"));
241 }
242 Err(e) => return Err(anyhow::anyhow!("httpx.TransportError: {e}")),
243 }
244
245 let fields = [
246 ("name", project_name),
247 ("default_title_voice_id", str_of(&narrator)),
248 ("default_paragraph_voice_id", str_of(&narrator)),
249 ("default_model_id", a.model.clone()),
250 (
251 "from_content_json",
252 dumps(&Value::Array(chapters), Style::COMPACT),
253 ),
254 ("quality_preset", a.quality.clone()),
255 ];
256 let response = client
257 .studio_projects_create(&fields, None)
258 .map_err(|e| anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {e}"))?;
259 log::info("\nProject created successfully!");
260 log::info(&format!(
261 " Project ID: {}",
262 str_of(&response["project"]["project_id"])
263 ));
264 Ok(0)
265}
266
267pub fn command() -> clap::Command {
269 <Args as clap::CommandFactory>::command()
270}