Skip to main content

xil/cmd/
studio_onboard.rs

1//! `xil studio-onboard` — build an ElevenLabs Studio project from a parsed
2//! episode. Port of `XILP004_studio_onboard.py`.
3
4use 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    /// Episode tag (e.g. S01E02)
22    #[arg(long)]
23    episode: Option<String>,
24    /// Raw tag for non-episodic content (e.g. V01C03, D01)
25    #[arg(long)]
26    tag: Option<String>,
27    /// Show name override (default: from project.json)
28    #[arg(long)]
29    show: Option<String>,
30    /// Build and display content JSON without calling the API
31    #[arg(long)]
32    dry_run: bool,
33    /// Quality preset (default: standard)
34    #[arg(long, default_value = "standard", value_parser = ["standard", "high", "ultra", "ultra_lossless"])]
35    quality: String,
36    /// TTS model ID (default: eleven_v3)
37    #[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
49/// The narrator: the first `Host/Narrator`, else the first cast member.
50fn 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
58/// `build_content_json(parsed, cast)`.
59fn 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
267/// The clap definition behind `--help`, for man pages.
268pub fn command() -> clap::Command {
269    <Args as clap::CommandFactory>::command()
270}