1use std::ffi::OsString;
6use std::fs;
7
8use crate::cmd::py_str;
9use clap::Parser;
10use indexmap::IndexMap;
11use serde_json::{Map, Value};
12use xil_audio::fx::py_repr;
13use xil_core::pyfmt::head;
14use xil_core::pyjson::{dumps, Style};
15use xil_core::{banner, log};
16
17#[derive(Parser)]
18#[command(
19 name = "xil-voices",
20 about = "List ElevenLabs voices with enriched metadata"
21)]
22struct Args {
23 #[arg(long, num_args = 1.., value_name = "CAT")]
25 category: Option<Vec<String>>,
26 #[arg(long, value_name = "TEXT")]
28 search: Option<String>,
29 #[arg(long, value_name = "VOICE_ID")]
31 id: Option<String>,
32 #[arg(long, short = 'v')]
34 verbose: bool,
35 #[arg(long)]
37 json: bool,
38 #[arg(long, value_name = "CAST_JSON")]
40 update_cast: Option<String>,
41 #[arg(long)]
43 dry_run: bool,
44}
45
46fn category_label(cat: &str) -> Option<&'static str> {
47 match cat {
48 "premade" => Some("Premade"),
49 "cloned" => Some("Instant Clone"),
50 "generated" => Some("Generated"),
51 "professional" => Some("Professional Clone (PVC)"),
52 _ => None,
53 }
54}
55
56fn sharing_label(cat: &str) -> Option<&'static str> {
57 match cat {
58 "professional" => Some("Professional Clone"),
59 "high_quality" => Some("High Quality"),
60 _ => None,
61 }
62}
63
64fn truthy(v: &Value) -> bool {
65 crate::cmd::truthy(v)
66}
67
68fn s(v: &Value) -> &str {
69 v.as_str().unwrap_or("")
70}
71
72fn fmt_unix(ts: &Value) -> String {
74 ts.as_i64()
75 .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
76 .map(|d| d.format("%Y-%m-%d").to_string())
77 .unwrap_or_default()
78}
79
80fn build_voice_record(v: &Value) -> Map<String, Value> {
82 let get = |k: &str| v.get(k).cloned().unwrap_or(Value::Null);
83 let labels = v
84 .get("labels")
85 .and_then(Value::as_object)
86 .cloned()
87 .unwrap_or_default();
88 let sharing = v.get("sharing").filter(|x| x.is_object());
89 let sfield = |k: &str| {
90 sharing
91 .and_then(|sh| sh.get(k))
92 .cloned()
93 .unwrap_or(Value::Null)
94 };
95 let sharing_cat = match sharing {
96 Some(_) => {
97 let c = sfield("category");
98 match c.as_str().and_then(sharing_label) {
99 Some(l) => Value::from(l),
100 None => c,
101 }
102 }
103 None => Value::Null,
104 };
105 let description = [get("description"), sfield("description")]
106 .into_iter()
107 .find(truthy)
108 .unwrap_or_else(|| Value::from(""));
109 let langs_all: Vec<String> = v
110 .get("verified_languages")
111 .and_then(Value::as_array)
112 .map(|a| {
113 a.iter()
114 .map(|l| {
115 l.get("language")
116 .map(py_str)
117 .unwrap_or_else(|| "None".into())
118 })
119 .collect()
120 })
121 .unwrap_or_default();
122 let mut langs: Vec<String> = Vec::new();
123 for l in &langs_all {
124 if !langs.contains(l) {
125 langs.push(l.clone());
126 }
127 }
128 let lang_str = if langs.is_empty() {
129 String::new()
130 } else if langs.len() <= 3 {
131 langs.join(", ")
132 } else {
133 format!("{} (+{})", langs[..3].join(", "), langs.len() - 3)
134 };
135 let label = |k: &str| labels.get(k).cloned().unwrap_or_else(|| Value::from(""));
136
137 let mut r = Map::new();
138 r.insert("voice_id".into(), get("voice_id"));
139 r.insert("name".into(), get("name"));
140 r.insert(
141 "library_name".into(),
142 if sharing.is_some() {
143 sfield("name")
144 } else {
145 Value::Null
146 },
147 );
148 r.insert("category".into(), get("category"));
149 r.insert("sharing_category".into(), sharing_cat);
150 r.insert("description".into(), description);
151 for k in [
152 "gender",
153 "age",
154 "accent",
155 "descriptive",
156 "use_case",
157 "language",
158 ] {
159 r.insert(k.into(), label(k));
160 }
161 r.insert("verified_languages".into(), lang_str.into());
162 r.insert("verified_lang_count".into(), langs.len().into());
163 let hq = v
164 .get("high_quality_base_model_ids")
165 .filter(|x| truthy(x))
166 .cloned()
167 .unwrap_or_else(|| Value::Array(vec![]));
168 r.insert("high_quality_models".into(), hq);
169 r.insert("is_owner".into(), get("is_owner"));
170 r.insert("is_bookmarked".into(), get("is_bookmarked"));
171 r.insert("permission".into(), get("permission_on_resource"));
172 r.insert(
173 "created_at".into(),
174 fmt_unix(&get("created_at_unix")).into(),
175 );
176 r.insert(
177 "notice_days".into(),
178 if sharing.is_some() {
179 sfield("notice_period")
180 } else {
181 Value::Null
182 },
183 );
184 r
185}
186
187fn or_dash(v: &Value) -> String {
188 if truthy(v) {
189 py_str(v)
190 } else {
191 "—".into()
192 }
193}
194
195fn cat_label(rec: &Map<String, Value>) -> String {
196 let cat = &rec["category"];
197 match cat.as_str().and_then(category_label) {
198 Some(l) => l.to_string(),
199 None if truthy(cat) => py_str(cat),
200 None => "?".into(),
201 }
202}
203
204fn print_verbose(rec: &Map<String, Value>) {
205 let mut label = cat_label(rec);
206 if truthy(&rec["sharing_category"]) {
207 label = format!("{label} / {}", py_str(&rec["sharing_category"]));
208 }
209 log::info(&format!(" Name : {}", py_str(&rec["name"])));
210 if truthy(&rec["library_name"]) && rec["library_name"] != rec["name"] {
211 log::info(&format!(
212 " Library name : {}",
213 py_str(&rec["library_name"])
214 ));
215 }
216 log::info(&format!(" Voice ID : {}", py_str(&rec["voice_id"])));
217 log::info(&format!(" Category : {label}"));
218 if truthy(&rec["description"]) {
219 log::info(&format!(" Description : {}", py_str(&rec["description"])));
220 }
221 log::info(&format!(" Gender : {}", or_dash(&rec["gender"])));
222 log::info(&format!(" Age : {}", or_dash(&rec["age"])));
223 log::info(&format!(" Accent : {}", or_dash(&rec["accent"])));
224 log::info(&format!(
225 " Tone/style : {}",
226 or_dash(&rec["descriptive"])
227 ));
228 log::info(&format!(" Use case : {}", or_dash(&rec["use_case"])));
229 log::info(&format!(" Language : {}", or_dash(&rec["language"])));
230 if truthy(&rec["verified_languages"]) {
231 log::info(&format!(
232 " Verified langs: {} ({} total)",
233 py_str(&rec["verified_languages"]),
234 py_str(&rec["verified_lang_count"])
235 ));
236 }
237 if truthy(&rec["high_quality_models"]) {
238 let models: Vec<String> = rec["high_quality_models"]
239 .as_array()
240 .into_iter()
241 .flatten()
242 .map(py_str)
243 .collect();
244 log::info(&format!(" HQ models : {}", models.join(", ")));
245 }
246 log::info(&format!(
247 " Owner : {}",
248 if truthy(&rec["is_owner"]) {
249 "Yes"
250 } else {
251 "No (library copy)"
252 }
253 ));
254 log::info(&format!(
255 " Bookmarked : {}",
256 if truthy(&rec["is_bookmarked"]) {
257 "Yes"
258 } else {
259 "No"
260 }
261 ));
262 log::info(&format!(
263 " Permission : {}",
264 if truthy(&rec["permission"]) {
265 py_str(&rec["permission"])
266 } else {
267 "none".into()
268 }
269 ));
270 log::info(&format!(" Created : {}", or_dash(&rec["created_at"])));
271 if truthy(&rec["notice_days"]) {
272 log::info(&format!(
273 " Notice period: {} days",
274 py_str(&rec["notice_days"])
275 ));
276 }
277 log::info("");
278}
279
280fn or_q(v: &Value) -> String {
281 if truthy(v) {
282 py_str(v)
283 } else {
284 "?".into()
285 }
286}
287
288fn print_compact(rec: &Map<String, Value>) {
289 let langs = if truthy(&rec["verified_languages"]) {
290 format!(" | langs: {}", py_str(&rec["verified_languages"]))
291 } else {
292 String::new()
293 };
294 let desc = if truthy(&rec["description"]) {
295 format!(" | {}", head(s(&rec["description"]), 60))
296 } else {
297 String::new()
298 };
299 let name = xil_core::pyfmt::pad_right(&py_str(&rec["name"]), 28);
300 log::info(&format!(
301 " {name} {} [{}]\n {}, {}, {}, {}{langs}{desc}",
302 py_str(&rec["voice_id"]),
303 cat_label(rec),
304 or_q(&rec["gender"]),
305 or_q(&rec["age"]),
306 or_q(&rec["accent"]),
307 or_q(&rec["descriptive"]),
308 ));
309}
310
311fn update_cast(
312 path: &str,
313 by_id: &IndexMap<String, Map<String, Value>>,
314 dry_run: bool,
315) -> anyhow::Result<()> {
316 let mut cast: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
317 let mut changes: Vec<String> = Vec::new();
318 if let Some(members) = cast.get_mut("cast").and_then(Value::as_object_mut) {
319 for (key, member) in members.iter_mut() {
320 let Some(m) = member.as_object_mut() else {
321 continue;
322 };
323 let vid = m
324 .get("voice_id")
325 .cloned()
326 .unwrap_or_else(|| Value::from("TBD"));
327 if vid == "TBD" {
328 log::info(&format!(" {key}: voice_id is TBD — skipping"));
329 continue;
330 }
331 let Some(rec) = vid.as_str().and_then(|v| by_id.get(v)) else {
332 log::info(&format!(
333 " {key} ({}): not found in workspace voices — skipping",
334 py_str(&vid)
335 ));
336 continue;
337 };
338 if m.get("role") == Some(&Value::from("TBD"))
339 && rec.get("description").is_some_and(truthy)
340 {
341 let old = py_str(&m["role"]);
342 m.insert("role".into(), rec["description"].clone());
343 changes.push(format!(
344 " {key}.role: {} → {}",
345 py_repr(&old),
346 py_repr(&py_str(&m["role"]))
347 ));
348 }
349 if !m.get("language_code").is_some_and(truthy)
350 && rec.get("language").is_some_and(truthy)
351 {
352 m.insert("language_code".into(), rec["language"].clone());
353 changes.push(format!(
354 " {key}.language_code: null → {}",
355 py_repr(&py_str(&m["language_code"]))
356 ));
357 }
358 }
359 }
360 if changes.is_empty() {
361 log::info(" No updates needed — cast file is already fully populated.");
362 return Ok(());
363 }
364 log::info(&format!(
365 " {}Changes ({}):",
366 if dry_run { "(dry run) " } else { "" },
367 changes.len()
368 ));
369 for c in &changes {
370 log::info(c);
371 }
372 if !dry_run {
373 fs::write(path, dumps(&cast, Style::INDENT2_UTF8) + "\n")?;
374 log::info(&format!(" Written: {path}"));
375 }
376 Ok(())
377}
378
379pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
380 log::init("voices");
381 let _banner = banner::begin(super::prog(), &super::argv_line(args));
382 let a: Args = match super::parse_or_exit("xil-voices", args) {
383 Ok(a) => a,
384 Err(code) => return Ok(code),
385 };
386 let client = xil_api::elevenlabs::Client::from_env();
387 let response = client
388 .voices_get_all()
389 .map_err(|e| anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {e}"))?;
390 let mut records: Vec<Map<String, Value>> = response["voices"]
391 .as_array()
392 .into_iter()
393 .flatten()
394 .map(build_voice_record)
395 .collect();
396 let by_id: IndexMap<String, Map<String, Value>> = records
397 .iter()
398 .map(|r| (py_str(&r["voice_id"]), r.clone()))
399 .collect();
400
401 if let Some(cast) = a.update_cast.as_ref().filter(|c| !c.is_empty()) {
402 log::info(&format!("\n--- Updating cast file: {cast} ---"));
403 update_cast(cast, &by_id, a.dry_run)?;
404 return Ok(0);
405 }
406 if let Some(id) = a.id.as_ref().filter(|i| !i.is_empty()) {
407 match records.iter().find(|r| r["voice_id"].as_str() == Some(id)) {
408 Some(r) => print_verbose(r),
409 None => log::info(&format!(
410 "Voice ID {} not found in your workspace.",
411 py_repr(id)
412 )),
413 }
414 return Ok(0);
415 }
416 if let Some(cats) = a.category.as_ref().filter(|c| !c.is_empty()) {
417 let cats: Vec<String> = cats.iter().map(|c| c.to_lowercase()).collect();
418 records.retain(|r| cats.contains(&s(&r["category"]).to_lowercase()));
419 }
420 if let Some(q) = a.search.as_ref().filter(|q| !q.is_empty()) {
421 let q = q.to_lowercase();
422 records.retain(|r| {
423 ["name", "description", "library_name"]
424 .iter()
425 .any(|k| s(&r[*k]).to_lowercase().contains(&q))
426 });
427 }
428 records.sort_by(|x, y| {
429 let kx = (!truthy(&x["is_bookmarked"]), s(&x["name"]).to_lowercase());
430 let ky = (!truthy(&y["is_bookmarked"]), s(&y["name"]).to_lowercase());
431 kx.cmp(&ky)
432 });
433
434 if a.json {
435 let arr = Value::Array(records.into_iter().map(Value::Object).collect());
436 println!("{}", dumps(&arr, Style::INDENT2));
437 return Ok(0);
438 }
439
440 let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
441 for r in &records {
442 let c = if truthy(&r["category"]) {
443 py_str(&r["category"])
444 } else {
445 "unknown".into()
446 };
447 *counts.entry(c).or_default() += 1;
448 }
449 log::info(&format!(
450 "\n--- ElevenLabs Voices ({} shown) ---",
451 records.len()
452 ));
453 for (cat, n) in &counts {
454 log::info(&format!(
455 " {}: {n}",
456 category_label(cat)
457 .map(str::to_string)
458 .unwrap_or_else(|| cat.clone())
459 ));
460 }
461 log::info("");
462 if a.verbose {
463 records.iter().for_each(print_verbose);
464 } else {
465 records.iter().for_each(print_compact);
466 log::info("");
467 log::info(" Use --verbose for full details, --json for machine-readable output,");
468 log::info(" --id <VOICE_ID> for a single voice, --category / --search to filter.");
469 }
470 Ok(0)
471}
472
473pub fn command() -> clap::Command {
475 <Args as clap::CommandFactory>::command()
476}