1use std::collections::HashMap;
9use std::fmt::Write as _;
10
11use serde::Serialize;
12
13use crate::config::AudioFormat;
14use crate::consts::SUNO_SONG_BASE_URL;
15use crate::graph::LineageStore;
16use crate::lineage::LineageContext;
17use crate::manifest::Manifest;
18use crate::model::Clip;
19use crate::tag::TrackMetadata;
20
21pub const INDEX_SCHEMA_VERSION: u32 = 1;
26
27#[derive(Debug, Clone, Copy)]
38pub struct M3u8Entry<'a> {
39 pub title: &'a str,
40 pub duration_secs: f64,
41 pub relative_path: &'a str,
42}
43
44pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
55 let mut out = String::from("#EXTM3U\n");
56 let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
57 for entry in entries {
58 let title = to_single_line(entry.title);
59 if entry.relative_path.is_empty() {
60 let _ = writeln!(out, "# (not in library) {title}");
63 continue;
64 }
65 let path = to_single_line(entry.relative_path);
66 let seconds = extinf_seconds(entry.duration_secs);
67 let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
68 }
69 out
70}
71
72#[derive(Debug, Serialize)]
78struct IndexEntry {
79 id: String,
80 path: String,
81 format: AudioFormat,
82 size: u64,
83 title: String,
84 artist: Option<String>,
85 handle: Option<String>,
86 album: String,
87 root_id: String,
88 created_at: Option<String>,
89 duration: Option<f64>,
90 tags: Option<String>,
91}
92
93#[derive(Debug, Serialize)]
95struct LibraryIndex {
96 schema_version: u32,
97 clips: Vec<IndexEntry>,
98}
99
100pub fn render_library_index(
111 manifest: &Manifest,
112 store: &LineageStore,
113 live: &HashMap<&str, &Clip>,
114) -> String {
115 let clips = manifest
116 .iter()
117 .map(|(id, entry)| {
118 let live_clip = live.get(id.as_str()).copied();
119 let title = live_clip
120 .map(|clip| clip.title.clone())
121 .filter(|title| !title.is_empty())
122 .or_else(|| {
123 store
124 .node(id)
125 .map(|node| node.title.clone())
126 .filter(|title| !title.is_empty())
127 })
128 .unwrap_or_else(|| "Untitled".to_owned());
129 let artist =
130 live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
131 let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
132 let album = match live_clip {
133 Some(clip) => store.context_for(clip).album(&clip.title),
134 None => store.album_for_id(id),
135 };
136 let root_id = store
137 .get_root(id)
138 .map(|cached| cached.root_id.clone())
139 .filter(|root| !root.is_empty())
140 .unwrap_or_else(|| id.clone());
141 let created_at = store
142 .node(id)
143 .map(|node| node.created_at.clone())
144 .filter(|created| !created.is_empty());
145 let duration = live_clip.map(|clip| clip.duration);
146 let tags = live_clip.map(|clip| clip.tags.clone());
147 IndexEntry {
148 id: id.clone(),
149 path: entry.path.clone(),
150 format: entry.format,
151 size: entry.size,
152 title,
153 artist,
154 handle,
155 album,
156 root_id,
157 created_at,
158 duration,
159 tags,
160 }
161 })
162 .collect();
163 let index = LibraryIndex {
164 schema_version: INDEX_SCHEMA_VERSION,
165 clips,
166 };
167 serde_json::to_string_pretty(&index).expect("library index serialises")
168}
169
170fn non_empty(s: &str) -> Option<&str> {
175 (!s.is_empty()).then_some(s)
176}
177
178fn extinf_seconds(duration_secs: f64) -> i64 {
182 if duration_secs.is_finite() {
183 duration_secs.round() as i64
184 } else {
185 0
186 }
187}
188fn to_single_line(text: &str) -> String {
191 text.replace('\r', "").replace('\n', " ")
192}
193
194pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
207 let meta = TrackMetadata::from_clip(clip, lineage);
208 let url = if clip.id.is_empty() {
209 String::new()
210 } else {
211 format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
212 };
213 let fields: [(&str, &str); 17] = [
214 ("Title", &meta.title),
215 ("Artist", &meta.artist),
216 ("Album", &meta.album),
217 ("Album Artist", &meta.album_artist),
218 ("Date", &meta.date),
219 ("Duration", &format_duration(clip.duration)),
220 ("Model", &meta.model),
221 ("Handle", &meta.handle),
222 ("Style", &meta.style),
223 ("Style Summary", &meta.style_summary),
224 ("Comment", &meta.comment),
225 ("Prompt", &meta.lyrics),
226 ("Parent", &meta.parent),
227 ("Root", &meta.root),
228 ("Lineage", &meta.lineage),
229 ("Id", &clip.id),
230 ("Url", &url),
231 ];
232 let mut out = String::new();
233 for (label, value) in fields {
234 if value.is_empty() {
235 continue;
236 }
237 let _ = writeln!(out, "{label}: {}", to_single_line(value));
238 }
239 out
240}
241
242pub fn render_clip_lyrics(clip: &Clip) -> Option<String> {
249 if clip.lyrics.trim().is_empty() {
250 return None;
251 }
252 Some(format!("{}\n", clip.lyrics.trim_end()))
253}
254
255fn format_duration(secs: f64) -> String {
258 if !secs.is_finite() || secs <= 0.0 {
259 return String::new();
260 }
261 let total = secs.round() as i64;
262 format!("{}:{:02}", total / 60, total % 60)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::lineage::{EdgeType, ResolveStatus};
269
270 fn full_clip() -> Clip {
271 Clip {
272 id: "clip-1234abcd".to_owned(),
273 title: "Electric Storm".to_owned(),
274 tags: "ambient, cinematic".to_owned(),
275 duration: 211.6,
276 created_at: "2024-03-10T14:22:01Z".to_owned(),
277 display_name: "alice".to_owned(),
278 handle: "alice".to_owned(),
279 prompt: "an orchestral storm".to_owned(),
280 gpt_description_prompt: "a moody cinematic build".to_owned(),
281 lyrics: "thunder rolls\nover the plains".to_owned(),
282 model_name: "chirp-v4".to_owned(),
283 major_model_version: "v4".to_owned(),
284 image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
285 audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
286 ..Clip::default()
287 }
288 }
289
290 fn full_lineage() -> LineageContext {
291 LineageContext {
292 root_id: "rootid567890".to_owned(),
293 root_title: "Weather Series".to_owned(),
294 parent_id: "parentid1234".to_owned(),
295 edge_type: Some(EdgeType::Extend),
296 status: ResolveStatus::Resolved,
297 }
298 }
299
300 #[test]
301 fn details_render_is_exact_and_fixed_order() {
302 let rendered = render_clip_details(&full_clip(), &full_lineage());
303 let expected = "Title: Electric Storm\n\
304 Artist: alice\n\
305 Album: Weather Series\n\
306 Album Artist: alice\n\
307 Date: 2024-03-10\n\
308 Duration: 3:32\n\
309 Model: chirp-v4 (v4)\n\
310 Handle: alice\n\
311 Style: ambient, cinematic\n\
312 Style Summary: a moody cinematic build\n\
313 Comment: a moody cinematic build\n\
314 Prompt: an orchestral storm\n\
315 Parent: parentid1234\n\
316 Root: rootid567890\n\
317 Lineage: Extended from parentid Root rootid56 (Weather Series)\n\
318 Id: clip-1234abcd\n\
319 Url: https://suno.com/song/clip-1234abcd\n";
320 assert_eq!(rendered, expected);
321 }
322
323 #[test]
324 fn details_omit_empty_fields() {
325 let clip = Clip {
326 id: "only-id".to_owned(),
327 title: "Bare".to_owned(),
328 ..Clip::default()
329 };
330 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
331 let expected = "Title: Bare\n\
335 Artist: Suno\n\
336 Album: Bare\n\
337 Album Artist: Suno\n\
338 Root: only-id\n\
339 Id: only-id\n\
340 Url: https://suno.com/song/only-id\n";
341 assert_eq!(rendered, expected);
342 assert!(!rendered.contains("Duration:"));
343 assert!(!rendered.contains("Prompt:"));
344 }
345
346 #[test]
347 fn details_exclude_signed_cdn_urls() {
348 let rendered = render_clip_details(&full_clip(), &full_lineage());
349 assert!(!rendered.contains("cdn1.suno.ai"));
350 assert!(!rendered.contains("token=secret"));
351 assert!(!rendered.contains(".mp3"));
352 }
353
354 #[test]
355 fn details_use_canonical_song_url() {
356 let rendered = render_clip_details(&full_clip(), &full_lineage());
357 assert!(rendered.contains("Url: https://suno.com/song/clip-1234abcd\n"));
358 }
359
360 #[test]
361 fn details_label_prompt_not_lyrics() {
362 let rendered = render_clip_details(&full_clip(), &full_lineage());
363 assert!(rendered.contains("Prompt: an orchestral storm\n"));
364 assert!(!rendered.contains("Lyrics:"));
367 assert!(!rendered.contains("thunder rolls"));
368 }
369
370 #[test]
371 fn details_use_resolved_lineage_not_feed_fields() {
372 let clip = Clip {
373 id: "child".to_owned(),
374 title: "Child".to_owned(),
375 album_title: "Ignored Feed Album".to_owned(),
376 ..Clip::default()
377 };
378 let lineage = LineageContext {
379 root_id: "root-01".to_owned(),
380 root_title: "Resolved Album".to_owned(),
381 parent_id: "root-01".to_owned(),
382 edge_type: Some(EdgeType::Cover),
383 status: ResolveStatus::Resolved,
384 };
385 let rendered = render_clip_details(&clip, &lineage);
386 assert!(rendered.contains("Album: Resolved Album\n"));
387 assert!(!rendered.contains("Ignored Feed Album"));
388 }
389
390 #[test]
391 fn details_for_a_pure_root_omit_lineage_and_parent() {
392 let clip = Clip {
393 id: "root".to_owned(),
394 title: "Root".to_owned(),
395 ..Clip::default()
396 };
397 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
398 assert!(!rendered.contains("Parent:"));
401 assert!(!rendered.contains("Lineage:"));
402 assert!(rendered.contains("Root: root\n"));
403 }
404
405 #[test]
406 fn lyrics_render_verbatim_with_one_trailing_newline() {
407 let clip = Clip {
408 lyrics: "line one\nline two".to_owned(),
409 ..Clip::default()
410 };
411 assert_eq!(
412 render_clip_lyrics(&clip),
413 Some("line one\nline two\n".to_owned())
414 );
415 }
416
417 #[test]
418 fn lyrics_normalise_trailing_whitespace_to_one_newline() {
419 let clip = Clip {
420 lyrics: "verse\n\n\n".to_owned(),
421 ..Clip::default()
422 };
423 assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
424 }
425
426 #[test]
427 fn lyrics_none_when_empty_or_whitespace_only() {
428 assert_eq!(render_clip_lyrics(&Clip::default()), None);
429 let clip = Clip {
430 lyrics: " \n\t \n".to_owned(),
431 ..Clip::default()
432 };
433 assert_eq!(render_clip_lyrics(&clip), None);
434 }
435
436 #[test]
437 fn lyrics_use_clip_lyrics_not_prompt() {
438 let clip = Clip {
439 prompt: "the generation prompt".to_owned(),
440 lyrics: "the actual sung words".to_owned(),
441 ..Clip::default()
442 };
443 let rendered = render_clip_lyrics(&clip).unwrap();
444 assert!(rendered.contains("the actual sung words"));
445 assert!(!rendered.contains("the generation prompt"));
446 }
447
448 #[test]
449 fn m3u8_preserves_order_and_rounds_extinf() {
450 let entries = [
451 M3u8Entry {
452 title: "First",
453 duration_secs: 211.6,
454 relative_path: "Artist/Album/First.flac",
455 },
456 M3u8Entry {
457 title: "Second, Take",
458 duration_secs: 90.5,
459 relative_path: "Artist/Album/Second.flac",
460 },
461 M3u8Entry {
462 title: "Third\nLine",
463 duration_secs: 30.2,
464 relative_path: "Artist/Album/Third.flac",
465 },
466 ];
467
468 let rendered = render_m3u8("Road Trip", &entries);
469
470 let expected = "#EXTM3U\n\
471 #PLAYLIST:Road Trip\n\
472 #EXTINF:212,First\n\
473 Artist/Album/First.flac\n\
474 #EXTINF:91,Second, Take\n\
475 Artist/Album/Second.flac\n\
476 #EXTINF:30,Third Line\n\
477 Artist/Album/Third.flac\n";
478 assert_eq!(rendered, expected);
479 }
480
481 #[test]
482 fn m3u8_strips_newlines_but_keeps_commas() {
483 let entries = [M3u8Entry {
484 title: "Hello, World\r\nSecond, Line",
485 duration_secs: 12.0,
486 relative_path: "Artist/Track.flac",
487 }];
488
489 let rendered = render_m3u8("Mix", &entries);
490
491 assert_eq!(
492 rendered,
493 "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
494 );
495 assert!(!rendered.contains('\r'));
496 assert_eq!(rendered.lines().count(), 4);
498 }
499
500 #[test]
501 fn m3u8_folds_newlines_in_the_playlist_name() {
502 let rendered = render_m3u8("Road\r\nTrip", &[]);
503 assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
504 }
505
506 #[test]
507 fn m3u8_empty_list_is_header_and_name_only() {
508 assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
509 }
510
511 #[test]
512 fn m3u8_absent_member_renders_a_comment_not_a_path() {
513 let entries = [
516 M3u8Entry {
517 title: "In Library",
518 duration_secs: 60.0,
519 relative_path: "Artist/In.flac",
520 },
521 M3u8Entry {
522 title: "Missing, Song",
523 duration_secs: 42.0,
524 relative_path: "",
525 },
526 M3u8Entry {
527 title: "Also Present",
528 duration_secs: 30.0,
529 relative_path: "Artist/Also.flac",
530 },
531 ];
532
533 let rendered = render_m3u8("Liked Songs", &entries);
534
535 let expected = "#EXTM3U\n\
536 #PLAYLIST:Liked Songs\n\
537 #EXTINF:60,In Library\n\
538 Artist/In.flac\n\
539 # (not in library) Missing, Song\n\
540 #EXTINF:30,Also Present\n\
541 Artist/Also.flac\n";
542 assert_eq!(rendered, expected);
543 assert!(!rendered.contains("#EXTINF:42"));
545 }
546
547 #[test]
548 fn m3u8_non_finite_duration_is_zero() {
549 let entries = [M3u8Entry {
550 title: "Unknown",
551 duration_secs: f64::NAN,
552 relative_path: "Artist/Unknown.flac",
553 }];
554
555 assert_eq!(
556 render_m3u8("Odd", &entries),
557 "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
558 );
559 }
560
561 use crate::lineage::{Resolution, RootInfo};
562 use crate::manifest::ManifestEntry;
563 use serde_json::Value;
564 use std::collections::HashMap as Map;
565
566 fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
567 ManifestEntry {
568 path: path.to_owned(),
569 format,
570 size,
571 ..Default::default()
572 }
573 }
574
575 fn clip(id: &str, title: &str) -> Clip {
576 Clip {
577 id: id.to_owned(),
578 title: title.to_owned(),
579 display_name: "alice".to_owned(),
580 handle: "alice_handle".to_owned(),
581 tags: "ambient, cinematic".to_owned(),
582 duration: 211.0,
583 created_at: "2024-03-10T14:22:01Z".to_owned(),
584 ..Default::default()
585 }
586 }
587
588 fn lineage_store() -> LineageStore {
591 let child = Clip {
592 id: "child".to_owned(),
593 title: "Cover Take".to_owned(),
594 created_at: "2024-05-01T00:00:00Z".to_owned(),
595 clip_type: "gen".to_owned(),
596 task: "cover".to_owned(),
597 cover_clip_id: "root".to_owned(),
598 edited_clip_id: "root".to_owned(),
599 ..Default::default()
600 };
601 let root = Clip {
602 id: "root".to_owned(),
603 title: "Original".to_owned(),
604 created_at: "2024-04-01T00:00:00Z".to_owned(),
605 ..Default::default()
606 };
607 let mut roots = HashMap::new();
608 roots.insert(
609 "child".to_owned(),
610 RootInfo {
611 root_id: "root".to_owned(),
612 root_title: "Original".to_owned(),
613 status: ResolveStatus::Resolved,
614 },
615 );
616 roots.insert(
617 "root".to_owned(),
618 RootInfo {
619 root_id: "root".to_owned(),
620 root_title: "Original".to_owned(),
621 status: ResolveStatus::Resolved,
622 },
623 );
624 let resolution = Resolution {
625 roots,
626 gap_filled: Vec::new(),
627 };
628 let mut store = LineageStore::new();
629 store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
630 store
631 }
632
633 fn parse(rendered: &str) -> Value {
634 serde_json::from_str(rendered).expect("index is valid JSON")
635 }
636
637 #[test]
638 fn index_empty_manifest_is_exact() {
639 let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
640 assert_eq!(rendered, "{\n \"schema_version\": 1,\n \"clips\": []\n}");
641 }
642
643 #[test]
644 fn index_schema_version_matches_constant() {
645 let value = parse(&render_library_index(
646 &Manifest::new(),
647 &LineageStore::new(),
648 &Map::new(),
649 ));
650 assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
651 }
652
653 #[test]
654 fn index_live_clip_uses_live_fields_and_canonical_album() {
655 let mut manifest = Manifest::new();
656 manifest.insert(
657 "child",
658 manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
659 );
660 let store = lineage_store();
661 let clip = clip("child", "Cover Take");
662 let mut live: Map<&str, &Clip> = Map::new();
663 live.insert("child", &clip);
664
665 let value = parse(&render_library_index(&manifest, &store, &live));
666 let row = &value["clips"][0];
667 assert_eq!(row["id"], "child");
668 assert_eq!(row["path"], "Original/Cover Take.flac");
669 assert_eq!(row["format"], "flac");
670 assert_eq!(row["size"], 99);
671 assert_eq!(row["title"], "Cover Take");
672 assert_eq!(row["artist"], "alice");
673 assert_eq!(row["handle"], "alice_handle");
674 assert_eq!(row["album"], "Original");
676 assert_eq!(row["root_id"], "root");
677 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
678 assert_eq!(row["duration"], 211.0);
679 assert_eq!(row["tags"], "ambient, cinematic");
680 }
681
682 #[test]
683 fn index_on_disk_clip_nulls_live_only_fields() {
684 let mut manifest = Manifest::new();
685 manifest.insert(
686 "child",
687 manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
688 );
689 let store = lineage_store();
690
691 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
693 let row = &value["clips"][0];
694 assert_eq!(row["format"], "mp3");
696 assert_eq!(row["size"], 7);
697 assert_eq!(row["title"], "Cover Take");
698 assert_eq!(row["album"], "Original");
699 assert_eq!(row["root_id"], "root");
700 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
701 assert!(row["artist"].is_null());
703 assert!(row["handle"].is_null());
704 assert!(row["duration"].is_null());
705 assert!(row["tags"].is_null());
706 }
707
708 #[test]
709 fn index_album_resolves_for_both_live_and_on_disk_clips() {
710 let mut manifest = Manifest::new();
711 manifest.insert(
712 "child",
713 manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
714 );
715 let store = lineage_store();
716
717 let live_clip = clip("child", "Cover Take");
718 let mut live: Map<&str, &Clip> = Map::new();
719 live.insert("child", &live_clip);
720 let live_value = parse(&render_library_index(&manifest, &store, &live));
721 let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
722
723 assert_eq!(live_value["clips"][0]["album"], "Original");
725 assert_eq!(on_disk_value["clips"][0]["album"], "Original");
726 }
727
728 #[test]
729 fn index_album_differs_from_sanitised_path_segment() {
730 let mut manifest = Manifest::new();
734 manifest.insert(
735 "child",
736 manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
737 );
738 let raw_root = Clip {
739 id: "root".to_owned(),
740 title: "AC/DC: Live!".to_owned(),
741 created_at: "2024-04-01T00:00:00Z".to_owned(),
742 ..Default::default()
743 };
744 let child = Clip {
745 id: "child".to_owned(),
746 title: "song".to_owned(),
747 clip_type: "gen".to_owned(),
748 task: "cover".to_owned(),
749 cover_clip_id: "root".to_owned(),
750 edited_clip_id: "root".to_owned(),
751 ..Default::default()
752 };
753 let mut roots = HashMap::new();
754 roots.insert(
755 "child".to_owned(),
756 RootInfo {
757 root_id: "root".to_owned(),
758 root_title: "AC/DC: Live!".to_owned(),
759 status: ResolveStatus::Resolved,
760 },
761 );
762 let resolution = Resolution {
763 roots,
764 gap_filled: Vec::new(),
765 };
766 let mut store = LineageStore::new();
767 store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
768
769 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
770 let row = &value["clips"][0];
771 assert_eq!(row["album"], "AC/DC: Live!");
772 let album = row["album"].as_str().unwrap();
774 let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
775 assert_ne!(album, path_segment);
776 assert_eq!(path_segment, "AC-DC Live");
777 }
778
779 #[test]
780 fn index_iterates_in_clip_id_order() {
781 let mut manifest = Manifest::new();
782 manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
783 manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
784 manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
785
786 let value = parse(&render_library_index(
787 &manifest,
788 &LineageStore::new(),
789 &Map::new(),
790 ));
791 let ids: Vec<&str> = value["clips"]
792 .as_array()
793 .unwrap()
794 .iter()
795 .map(|row| row["id"].as_str().unwrap())
796 .collect();
797 assert_eq!(ids, ["a", "b", "c"]);
798 }
799
800 #[test]
801 fn index_unknown_clip_is_well_formed_with_defaults() {
802 let mut manifest = Manifest::new();
805 manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
806
807 let value = parse(&render_library_index(
808 &manifest,
809 &LineageStore::new(),
810 &Map::new(),
811 ));
812 let row = &value["clips"][0];
813 assert_eq!(row["id"], "orphan");
814 assert_eq!(row["title"], "Untitled");
815 assert_eq!(row["format"], "wav");
816 assert_eq!(row["album"], "");
817 assert_eq!(row["root_id"], "orphan");
818 assert!(row["created_at"].is_null());
819 assert!(row["artist"].is_null());
820 assert!(row["tags"].is_null());
821 }
822
823 #[test]
824 fn index_title_falls_back_to_store_node_then_untitled() {
825 let mut manifest = Manifest::new();
826 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
827 let store = lineage_store();
828 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
830 assert_eq!(value["clips"][0]["title"], "Cover Take");
831 }
832
833 #[test]
834 fn index_artist_falls_back_to_suno_when_display_name_empty() {
835 let mut manifest = Manifest::new();
836 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
837 let mut anon = clip("child", "Cover Take");
838 anon.display_name = String::new();
839 let mut live: Map<&str, &Clip> = Map::new();
840 live.insert("child", &anon);
841 let value = parse(&render_library_index(
842 &manifest,
843 &LineageStore::new(),
844 &live,
845 ));
846 assert_eq!(value["clips"][0]["artist"], "Suno");
848 }
849
850 #[test]
851 fn index_unicode_round_trips() {
852 let mut manifest = Manifest::new();
853 manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
854 let unicode = clip("🎵", "音楽 \"quoted\"");
855 let mut live: Map<&str, &Clip> = Map::new();
856 live.insert("🎵", &unicode);
857
858 let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
859 let value = parse(&rendered);
860 let row = &value["clips"][0];
861 assert_eq!(row["id"], "🎵");
862 assert_eq!(row["path"], "音楽/曲.flac");
863 assert_eq!(row["title"], "音楽 \"quoted\"");
864 }
865}