1use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use serde_json::{Map, Value};
11use xil_audio::mpeg;
12use xil_audio::tags::read_text_tags;
13use xil_core::fsutil::{basename, glob_recursive, relpath, sort_py};
14use xil_core::pyfmt::{fixed, head, pad_right, round_to};
15use xil_core::pyjson::{dumps, py_float, Style};
16use xil_core::workspace::{code_root, workspace_root};
17use xil_core::{banner, log};
18
19const REFERENCE_DOC: &str = "claude-scriptwriter-reference.md";
20
21#[derive(Parser)]
22#[command(
23 name = "xil-sfx-lib",
24 about = "Discover personally generated Sound Effects from the ElevenLabs account or local SFX/ directory"
25)]
26struct Args {
27 #[arg(long, conflicts_with = "local")]
29 api: bool,
30 #[arg(long)]
32 local: bool,
33 #[arg(long, value_name = "DIR")]
35 sfx_dir: Option<String>,
36 #[arg(long, value_name = "TEXT")]
38 search: Option<String>,
39 #[arg(long)]
41 all: bool,
42 #[arg(long, short = 'v')]
44 verbose: bool,
45 #[arg(long)]
47 json: bool,
48 #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = ".")]
50 export_kit: Option<String>,
51}
52
53fn fmt_duration(seconds: Option<f64>) -> String {
55 seconds
56 .map(|s| format!("{}s", fixed(s, 1)))
57 .unwrap_or_default()
58}
59
60fn fmt_size(bytes: u64) -> String {
62 format!("{} KB", fixed(bytes as f64 / 1024.0, 0))
63}
64
65fn get_str<'a>(rec: &'a Map<String, Value>, key: &str) -> &'a str {
66 rec.get(key).and_then(Value::as_str).unwrap_or("")
67}
68
69pub fn read_local_record(path: &Path, sfx_root: &Path) -> Map<String, Value> {
71 let filename = basename(path);
72 let size_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
73 let parent = path.parent().unwrap_or(Path::new(""));
74 let rel_dir = relpath(parent, sfx_root).to_string_lossy().into_owned();
75 let show = if rel_dir == "." {
76 String::new()
77 } else {
78 rel_dir.split('/').next().unwrap_or("").to_string()
79 };
80
81 let tags = read_text_tags(path);
82 let (duration, bitrate) = match mpeg::info(path) {
83 Ok(i) => (
84 py_float(round_to(i.length, 1)),
85 Value::from(i.bitrate / 1000),
86 ),
87 Err(_) => (Value::Null, Value::Null),
88 };
89
90 let mut rec = Map::new();
91 rec.insert("source".into(), "local".into());
92 rec.insert("filename".into(), filename.into());
93 rec.insert("path".into(), path.to_string_lossy().into_owned().into());
94 rec.insert("show".into(), show.into());
95 rec.insert("prompt".into(), tags.lyrics.into());
96 rec.insert("title".into(), tags.title.into());
97 rec.insert("artist".into(), tags.artist.into());
98 rec.insert("duration_seconds".into(), duration);
99 rec.insert("bitrate_kbps".into(), bitrate);
100 rec.insert("size_bytes".into(), Value::from(size_bytes));
101 rec.insert("date".into(), "".into());
102 rec
103}
104
105pub fn fetch_local_records(sfx_dir: &Path) -> Vec<Map<String, Value>> {
107 if !sfx_dir.is_dir() {
108 log::warning(&format!("SFX directory not found: {}", sfx_dir.display()));
109 return Vec::new();
110 }
111 let mut paths = glob_recursive(sfx_dir, "", ".mp3");
113 sort_py(&mut paths);
114 paths
115 .iter()
116 .map(|p| read_local_record(p, sfx_dir))
117 .collect()
118}
119
120fn print_verbose_local(rec: &Map<String, Value>) {
121 log::info(&format!(" File : {}", get_str(rec, "filename")));
122 let show = get_str(rec, "show");
123 log::info(&format!(
124 " Show : {}",
125 if show.is_empty() {
126 "(shared pool)"
127 } else {
128 show
129 }
130 ));
131 let prompt = get_str(rec, "prompt");
132 log::info(&format!(
133 " Prompt : {}",
134 if prompt.is_empty() {
135 "— (no prompt tag)"
136 } else {
137 prompt
138 }
139 ));
140 let title = get_str(rec, "title");
141 if !title.is_empty() {
142 log::info(&format!(" Title : {title}"));
143 }
144 if let Some(d) = rec.get("duration_seconds").and_then(Value::as_f64) {
145 log::info(&format!(" Duration : {}", fmt_duration(Some(d))));
146 }
147 if let Some(b) = rec.get("bitrate_kbps").and_then(Value::as_u64) {
148 log::info(&format!(" Bitrate : {b} kbps"));
149 }
150 log::info(&format!(
151 " Size : {}",
152 fmt_size(rec.get("size_bytes").and_then(Value::as_u64).unwrap_or(0))
153 ));
154 log::info("");
155}
156
157fn fetch_api_records(
160 api_key: &str,
161 max_items: Option<usize>,
162) -> anyhow::Result<Option<Vec<Map<String, Value>>>> {
163 let client = xil_api::elevenlabs::Client::new(Some(api_key.to_string()));
164 let mut records = Vec::new();
165 let mut start_after: Option<String> = None;
166 loop {
167 let data = match client.sound_generation_history(100, start_after.as_deref()) {
168 Ok(d) => d,
169 Err(xil_api::ApiError::Status { status: 401, body }) => {
170 let parsed: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
171 let status = parsed
172 .get("detail")
173 .and_then(|d| d.get("status"))
174 .and_then(Value::as_str)
175 .unwrap_or("");
176 log::warning("ElevenLabs API: permission denied for sound-generation history.");
177 log::warning("");
178 if matches!(status, "missing_permissions" | "needs_authorization") {
179 log::warning(" Fix: ElevenLabs dashboard → Profile → API Keys");
180 log::warning(" Edit your key → Endpoints → Sound Effects → set to 'Access'");
181 log::warning(" then re-run without --api to fall back to local scan,");
182 log::warning(" or with --api once the permission is active.");
183 } else {
184 log::warning(&format!(" Response: {}", head(&body, 200)));
185 }
186 return Ok(None);
187 }
188 Err(e) => anyhow::bail!("httpx.HTTPStatusError: {e}"),
189 };
190 let truthy = crate::cmd::truthy;
191 let first = |keys: &[&str]| {
192 keys.iter()
193 .filter_map(|k| data.get(*k))
194 .find(|v| truthy(v))
195 .cloned()
196 };
197 let items = first(&["history", "generations", "items"])
198 .and_then(|v| v.as_array().cloned())
199 .unwrap_or_default();
200
201 for item in &items {
202 let get = |k: &str| item.get(k).cloned();
203 let or = |a: &str, b: &str, default: Value| {
204 get(a).filter(truthy).or_else(|| get(b)).unwrap_or(default)
205 };
206 let cfg = [get("generation_config"), get("settings")]
207 .into_iter()
208 .flatten()
209 .find(truthy)
210 .unwrap_or(Value::Object(Map::new()));
211 let ts = get("date_unix")
212 .filter(truthy)
213 .or_else(|| get("created_at_unix"))
214 .filter(|v| !v.is_null());
215 let date = ts
216 .as_ref()
217 .and_then(Value::as_f64)
218 .and_then(|t| chrono::DateTime::from_timestamp(t.floor() as i64, 0))
219 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
220 .unwrap_or_default();
221 let count = |k: &str| item.get(k).and_then(Value::as_i64).unwrap_or(0);
222
223 let mut rec = Map::new();
224 rec.insert("source".into(), "api".into());
225 rec.insert(
226 "history_item_id".into(),
227 or("history_item_id", "id", "".into()),
228 );
229 rec.insert("prompt".into(), or("text", "prompt", "".into()));
230 rec.insert(
231 "model_id".into(),
232 get("model_id").unwrap_or_else(|| "".into()),
233 );
234 rec.insert("date".into(), date.into());
235 rec.insert(
236 "date_unix".into(),
237 ts.filter(truthy).unwrap_or_else(|| 0.into()),
238 );
239 rec.insert(
240 "duration_seconds".into(),
241 cfg.get("duration_seconds").cloned().unwrap_or(Value::Null),
242 );
243 rec.insert(
244 "prompt_influence".into(),
245 cfg.get("prompt_influence").cloned().unwrap_or(Value::Null),
246 );
247 rec.insert(
248 "credits_used".into(),
249 (count("character_count_change_to") - count("character_count_change_from")).into(),
250 );
251 rec.insert("filename".into(), "".into());
252 rec.insert("path".into(), "".into());
253 records.push(rec);
254
255 if max_items.is_some_and(|m| records.len() >= m) {
256 return Ok(Some(records));
257 }
258 }
259
260 if !data.get("has_more").is_some_and(truthy) {
261 break;
262 }
263 let next = data
264 .get("last_history_item_id")
265 .filter(|v| truthy(v))
266 .or_else(|| items.last().and_then(|i| i.get("history_item_id")))
267 .filter(|v| truthy(v));
268 match next {
269 Some(v) => start_after = Some(crate::cmd::py_str(v)),
270 None => break,
271 }
272 }
273 Ok(Some(records))
274}
275
276fn print_verbose_api(rec: &Map<String, Value>) {
277 let field = |k: &str| rec.get(k).map(crate::cmd::py_str).unwrap_or_default();
278 log::info(&format!(" Prompt : {}", field("prompt")));
279 log::info(&format!(" History ID : {}", field("history_item_id")));
280 if rec.get("model_id").is_some_and(crate::cmd::truthy) {
281 log::info(&format!(" Model : {}", field("model_id")));
282 }
283 let date = get_str(rec, "date");
284 log::info(&format!(
285 " Created : {}",
286 if date.is_empty() { "—" } else { date }
287 ));
288 if let Some(d) = rec.get("duration_seconds").and_then(Value::as_f64) {
289 log::info(&format!(" Duration : {}", fmt_duration(Some(d))));
290 }
291 if rec.get("prompt_influence").is_some_and(|v| !v.is_null()) {
292 log::info(&format!(" Prompt infl. : {}", field("prompt_influence")));
293 }
294 log::info(&format!(" Credits used : {}", field("credits_used")));
295 log::info("");
296}
297
298fn print_compact_api(rec: &Map<String, Value>) {
299 let dur = rec
300 .get("duration_seconds")
301 .filter(|v| crate::cmd::truthy(v))
302 .and_then(Value::as_f64)
303 .map(|d| fmt_duration(Some(d)))
304 .unwrap_or_default();
305 let prompt = get_str(rec, "prompt");
306 let prompt = if prompt.chars().count() > 72 {
307 format!("{}…", head(prompt, 72))
308 } else {
309 prompt.to_string()
310 };
311 log::info(&format!(
312 " {} {} {dur}",
313 get_str(rec, "date"),
314 rec.get("history_item_id")
315 .map(crate::cmd::py_str)
316 .unwrap_or_default()
317 ));
318 if !prompt.is_empty() {
319 log::info(&format!(" {prompt}"));
320 }
321}
322
323fn print_compact_local(rec: &Map<String, Value>) {
324 let dur = fmt_duration(rec.get("duration_seconds").and_then(Value::as_f64));
325 let size = fmt_size(rec.get("size_bytes").and_then(Value::as_u64).unwrap_or(0));
326 let prompt = get_str(rec, "prompt");
327 let prompt = if prompt.chars().count() > 72 {
328 format!("{}…", head(prompt, 72))
329 } else {
330 prompt.to_string()
331 };
332 let meta = format!("{} {}", pad_right(&dur, 6), pad_right(&size, 8));
333 let show = get_str(rec, "show");
334 let name = if show.is_empty() {
335 get_str(rec, "filename").to_string()
336 } else {
337 format!("[{show}] {}", get_str(rec, "filename"))
338 };
339 log::info(&format!(" {} {meta}", pad_right(&name, 38)));
340 if !prompt.is_empty() {
341 log::info(&format!(" {prompt}"));
342 }
343}
344
345fn category(filename: &str) -> &'static str {
347 let f = filename.to_lowercase();
348 if f.starts_with("ambience_") || f.starts_with("amb-") || f.starts_with("amb_") {
349 return "AMBIENCE";
350 }
351 if f.starts_with("amb") && f.chars().nth(3).is_some_and(char::is_alphabetic) {
352 return "AMBIENCE";
353 }
354 if f.starts_with("music_") || f.starts_with("mus-") || f.starts_with("mus_") {
355 return "MUSIC";
356 }
357 if f.starts_with("mus") && f.chars().nth(3).is_some_and(char::is_alphabetic) {
358 return "MUSIC";
359 }
360 if f.starts_with("beat") {
361 return "BEAT";
362 }
363 "SFX"
364}
365
366fn write_pipe_hints_md(
368 records: &[Map<String, Value>],
369 output_dir: &Path,
370) -> std::io::Result<PathBuf> {
371 let mut buckets: indexmap::IndexMap<&str, Vec<String>> = ["AMBIENCE", "MUSIC", "SFX", "BEAT"]
372 .into_iter()
373 .map(|k| (k, Vec::new()))
374 .collect();
375 let mut untitled: Vec<String> = Vec::new();
376 for rec in records {
377 let fnm = get_str(rec, "filename");
378 let title = get_str(rec, "title").trim();
379 if title.is_empty() {
380 untitled.push(fnm.to_string());
381 } else {
382 buckets[category(fnm)].push(format!("[{title} | {fnm}]"));
383 }
384 }
385 for lines in buckets.values_mut() {
386 lines.sort();
387 }
388 untitled.sort();
389
390 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
391 let titled_total: usize = buckets.values().map(Vec::len).sum();
392 let mut out: Vec<String> = vec![
393 "# SFX Pipe-Hint Cheatsheet".into(),
394 format!(
395 "# Generated {today} from {} assets ({titled_total} titled)",
396 records.len()
397 ),
398 "# Copy lines verbatim into script directions — do NOT modify the filename.".into(),
399 "# If a sound you need is not listed here, omit the pipe-hint entirely.".into(),
400 String::new(),
401 ];
402 for cat in ["AMBIENCE", "MUSIC", "SFX", "BEAT"] {
403 let entries = &buckets[cat];
404 out.push(format!("## {cat} ({} titled)", entries.len()));
405 out.push(String::new());
406 out.extend(entries.iter().cloned());
407 out.push(String::new());
408 }
409 if !untitled.is_empty() {
410 out.push(format!(
411 "## Untitled Assets — filename only ({} assets)",
412 untitled.len()
413 ));
414 out.push("# These have no direction-text title tag.".into());
415 out.push("# Match by filename keyword; use as bare source reference.".into());
416 out.push(String::new());
417 out.extend(untitled);
418 out.push(String::new());
419 }
420 let hints_path = output_dir.join("sfx_pipe_hints.md");
421 fs::write(&hints_path, out.join("\n"))?;
422 Ok(hints_path)
423}
424
425fn normpath(p: &Path) -> PathBuf {
427 let s = p.to_string_lossy();
428 let absolute = s.starts_with('/');
429 let mut parts: Vec<&str> = Vec::new();
430 for seg in s.split('/') {
431 match seg {
432 "" | "." => {}
433 ".." => {
434 if parts.last().is_some_and(|l| *l != "..") {
435 parts.pop();
436 } else if !absolute {
437 parts.push("..");
438 }
439 }
440 other => parts.push(other),
441 }
442 }
443 let joined = parts.join("/");
444 if absolute {
445 PathBuf::from(format!("/{joined}"))
446 } else if joined.is_empty() {
447 PathBuf::from(".")
448 } else {
449 PathBuf::from(joined)
450 }
451}
452
453fn same_file(a: &Path, b: &Path) -> bool {
454 match (fs::canonicalize(a), fs::canonicalize(b)) {
455 (Ok(x), Ok(y)) => x == y,
456 _ => false,
457 }
458}
459
460fn copy2(src: &Path, dst: &Path) -> std::io::Result<()> {
462 fs::copy(src, dst)?;
463 if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
464 let f = fs::File::options().write(true).open(dst)?;
465 f.set_modified(m)?;
466 }
467 Ok(())
468}
469
470fn package_reference_path() -> PathBuf {
475 match code_root() {
476 Some(root) => root.join("docs").join(REFERENCE_DOC),
477 None => {
478 let exe = std::env::current_exe().unwrap_or_default();
479 normpath(
480 &exe.join("..")
481 .join("..")
482 .join("..")
483 .join("docs")
484 .join(REFERENCE_DOC),
485 )
486 }
487 }
488}
489
490fn export_kit(
493 records: &[Map<String, Value>],
494 output_dir: &Path,
495) -> anyhow::Result<(PathBuf, PathBuf, PathBuf)> {
496 fs::create_dir_all(output_dir)?;
497 let json_path = output_dir.join("sfx_inventory.json");
498 let arr = Value::Array(records.iter().cloned().map(Value::Object).collect());
499 fs::write(&json_path, dumps(&arr, Style::INDENT2) + "\n")?;
500 let hints_path = write_pipe_hints_md(records, output_dir)?;
501
502 let ref_src = normpath(&package_reference_path());
503 let mut md_path = normpath(&output_dir.join(REFERENCE_DOC));
504 if ref_src.exists() {
505 if !same_file(&ref_src, &md_path) {
506 copy2(&ref_src, &md_path)?;
507 }
508 } else {
509 let cwd_ref = normpath(&Path::new("docs").join(REFERENCE_DOC));
510 if cwd_ref.exists() {
511 if !same_file(&cwd_ref, &md_path) {
512 copy2(&cwd_ref, &md_path)?;
513 } else {
514 md_path = cwd_ref;
515 }
516 } else {
517 log::warning(&format!(
518 "Reference doc not found at {} or {}",
519 ref_src.display(),
520 cwd_ref.display()
521 ));
522 md_path = PathBuf::new();
523 }
524 }
525 Ok((json_path, hints_path, md_path))
526}
527
528fn execute(a: &Args) -> anyhow::Result<i32> {
529 let sfx_dir = a
530 .sfx_dir
531 .clone()
532 .unwrap_or_else(|| workspace_root().join("SFX").to_string_lossy().into_owned());
533 let (mut records, data_source) = if a.api {
534 let api_key = std::env::var("ELEVENLABS_API_KEY").unwrap_or_default();
535 if api_key.is_empty() {
536 log::warning("ELEVENLABS_API_KEY not set.");
537 return Ok(1);
538 }
539 match fetch_api_records(&api_key, if a.all { None } else { Some(100) })? {
540 Some(r) => (r, "API".to_string()),
541 None => return Ok(1),
542 }
543 } else {
544 (
545 fetch_local_records(Path::new(&sfx_dir)),
546 format!("local ({sfx_dir}/)"),
547 )
548 };
549
550 if let Some(q) = &a.search {
551 let q = q.to_lowercase();
552 records.retain(|r| {
553 get_str(r, "prompt").to_lowercase().contains(&q)
554 || get_str(r, "filename").to_lowercase().contains(&q)
555 });
556 }
557 let date_unix =
558 |r: &Map<String, Value>| r.get("date_unix").and_then(Value::as_f64).unwrap_or(0.0);
559 if records
560 .first()
561 .is_some_and(|r| r.get("date_unix").is_some_and(crate::cmd::truthy))
562 {
563 records.sort_by(|x, y| date_unix(y).total_cmp(&date_unix(x)));
564 } else {
565 records.sort_by_key(|r| get_str(r, "filename").to_lowercase());
566 }
567
568 if let Some(dir) = &a.export_kit {
569 let (json_path, hints_path, md_path) = export_kit(&records, Path::new(dir))?;
570 log::info(&format!(
571 "\n--- Export kit ({} assets) ---\n",
572 records.len()
573 ));
574 log::info(&format!(" JSON inventory : {}", json_path.display()));
575 log::info(&format!(
576 " Pipe-hint cheatsheet : {}",
577 hints_path.display()
578 ));
579 if !md_path.as_os_str().is_empty() {
580 log::info(&format!(" Reference doc : {}", md_path.display()));
581 }
582 log::info("");
583 log::info(" Attach all three files to your Claude project as knowledge files.");
584 return Ok(0);
585 }
586
587 if a.json {
588 let arr = Value::Array(records.into_iter().map(Value::Object).collect());
589 println!("{}", dumps(&arr, Style::INDENT2));
590 return Ok(0);
591 }
592
593 log::info(&format!(
594 "\n--- ElevenLabs Sound Effects [{data_source}] ({} items) ---\n",
595 records.len()
596 ));
597 if records.is_empty() {
598 log::info(" No sound-effect records found.");
599 if let Some(q) = &a.search {
600 log::info(&format!(
601 " (search filter: {})",
602 xil_core::script::hints::py_repr(q)
603 ));
604 }
605 return Ok(0);
606 }
607
608 if a.verbose {
609 for rec in &records {
610 if get_str(rec, "source") == "local" {
611 print_verbose_local(rec);
612 } else {
613 print_verbose_api(rec);
614 }
615 }
616 } else {
617 for rec in &records {
618 if get_str(rec, "source") == "local" {
619 print_compact_local(rec);
620 } else {
621 print_compact_api(rec);
622 }
623 }
624 log::info("");
625 if data_source.starts_with("local") {
626 let total: u64 = records
627 .iter()
628 .map(|r| r.get("size_bytes").and_then(Value::as_u64).unwrap_or(0))
629 .sum();
630 log::info(&format!(
631 " Total size: {} MB ({} files)",
632 fixed(total as f64 / (1024.0 * 1024.0), 1),
633 records.len()
634 ));
635 } else {
636 let total: i64 = records
637 .iter()
638 .map(|r| r.get("credits_used").and_then(Value::as_i64).unwrap_or(0))
639 .sum();
640 log::info(&format!(
641 " Total credits used: {}",
642 xil_core::pyfmt::commas(total)
643 ));
644 }
645 log::info("");
646 log::info(" Use --verbose for full details, --json for machine-readable output,");
647 log::info(" --search <text> to filter, --local / --api to select data source.");
648 }
649 Ok(0)
650}
651
652pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
653 log::init("sfx-lib");
654 let _banner = banner::begin(super::prog(), &super::argv_line(args));
655 let a: Args = match super::parse_or_exit("xil-sfx-lib", args) {
656 Ok(a) => a,
657 Err(code) => return Ok(code),
658 };
659 execute(&a)
660}
661
662pub fn command() -> clap::Command {
664 <Args as clap::CommandFactory>::command()
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn categories_follow_filename_prefixes() {
673 assert_eq!(category("AMBGras-field.mp3"), "AMBIENCE");
674 assert_eq!(category("ambience_cafe.mp3"), "AMBIENCE");
675 assert_eq!(
676 category("amb1.mp3"),
677 "SFX",
678 "digit after amb is not a short code"
679 );
680 assert_eq!(category("MUSIC_theme.mp3"), "MUSIC");
681 assert_eq!(category("MUSFolk-x.mp3"), "MUSIC");
682 assert_eq!(category("beat.mp3"), "BEAT");
683 assert_eq!(category("sfx_door.mp3"), "SFX");
684 }
685
686 #[test]
687 fn formatting_helpers() {
688 assert_eq!(fmt_duration(Some(2.35)), "2.4s");
689 assert_eq!(fmt_duration(None), "");
690 assert_eq!(fmt_size(1536), "2 KB");
691 assert_eq!(fmt_size(1024 * 2 + 512), "2 KB", "half rounds to even");
692 assert_eq!(
693 normpath(Path::new("./kit/../kit/x.md")),
694 PathBuf::from("kit/x.md")
695 );
696 assert_eq!(normpath(Path::new("/a/b/../../c")), PathBuf::from("/c"));
697 assert_eq!(normpath(Path::new("./x")), PathBuf::from("x"));
698 }
699
700 #[test]
701 fn records_carry_show_and_blank_tags() {
702 let tmp = tempfile::tempdir().unwrap();
703 let root = tmp.path().join("SFX");
704 fs::create_dir_all(root.join("myshow")).unwrap();
705 fs::write(root.join("myshow").join("x.mp3"), b"ID3\x03\x00x").unwrap();
706 fs::write(root.join("top.mp3"), b"").unwrap();
707 fs::write(root.join("skip.MP3"), b"").unwrap();
708 let recs = fetch_local_records(&root);
709 let names: Vec<&str> = recs.iter().map(|r| get_str(r, "filename")).collect();
710 assert_eq!(
711 names,
712 vec!["x.mp3", "top.mp3"],
713 "path order, .MP3 not matched"
714 );
715 assert_eq!(get_str(&recs[0], "show"), "myshow");
716 assert_eq!(get_str(&recs[1], "show"), "");
717 assert!(recs[0]["duration_seconds"].is_null());
718 assert_eq!(recs[0]["size_bytes"], 6);
719 let keys: Vec<&str> = recs[0].keys().map(String::as_str).collect();
720 assert_eq!(keys[0], "source");
721 assert_eq!(keys[10], "date");
722 }
723}