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", &clip.prompt),
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
255pub fn render_clip_lrc(clip: &Clip, lineage: &LineageContext) -> Option<String> {
263 if clip.lyrics.trim().is_empty() {
264 return None;
265 }
266 let meta = TrackMetadata::from_clip(clip, lineage);
267 let length = format_duration(clip.duration);
268 let headers: [(&str, &str); 5] = [
269 ("ti", &meta.title),
270 ("ar", &meta.artist),
271 ("al", &meta.album),
272 ("length", &length),
273 ("re", "rs-suno"),
274 ];
275 let mut out = String::new();
276 for (tag, value) in headers {
277 if value.is_empty() {
278 continue;
279 }
280 let _ = writeln!(out, "[{tag}:{}]", to_single_line(value));
281 }
282 for line in clip.lyrics.trim_end().lines() {
283 let _ = writeln!(out, "{line}");
284 }
285 Some(out)
286}
287
288fn format_duration(secs: f64) -> String {
291 if !secs.is_finite() || secs <= 0.0 {
292 return String::new();
293 }
294 let total = secs.round() as i64;
295 format!("{}:{:02}", total / 60, total % 60)
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::lineage::{EdgeType, ResolveStatus};
302
303 fn full_clip() -> Clip {
304 Clip {
305 id: "clip-1234abcd".to_owned(),
306 title: "Electric Storm".to_owned(),
307 tags: "ambient, cinematic".to_owned(),
308 duration: 211.6,
309 created_at: "2024-03-10T14:22:01Z".to_owned(),
310 display_name: "alice".to_owned(),
311 handle: "alice".to_owned(),
312 prompt: "an orchestral storm".to_owned(),
313 gpt_description_prompt: "a moody cinematic build".to_owned(),
314 lyrics: "thunder rolls\nover the plains".to_owned(),
315 model_name: "chirp-v4".to_owned(),
316 major_model_version: "v4".to_owned(),
317 image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
318 audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
319 ..Clip::default()
320 }
321 }
322
323 fn full_lineage() -> LineageContext {
324 LineageContext {
325 root_id: "rootid567890".to_owned(),
326 root_title: "Weather Series".to_owned(),
327 parent_id: "parentid1234".to_owned(),
328 edge_type: Some(EdgeType::Extend),
329 status: ResolveStatus::Resolved,
330 }
331 }
332
333 #[test]
334 fn details_render_is_exact_and_fixed_order() {
335 let rendered = render_clip_details(&full_clip(), &full_lineage());
336 let expected = "Title: Electric Storm\n\
337 Artist: alice\n\
338 Album: Weather Series\n\
339 Album Artist: alice\n\
340 Date: 2024-03-10\n\
341 Duration: 3:32\n\
342 Model: chirp-v4 (v4)\n\
343 Handle: alice\n\
344 Style: ambient, cinematic\n\
345 Style Summary: a moody cinematic build\n\
346 Comment: a moody cinematic build\n\
347 Prompt: an orchestral storm\n\
348 Parent: parentid1234\n\
349 Root: rootid567890\n\
350 Lineage: Extended from parentid Root rootid56 (Weather Series)\n\
351 Id: clip-1234abcd\n\
352 Url: https://suno.com/song/clip-1234abcd\n";
353 assert_eq!(rendered, expected);
354 }
355
356 #[test]
357 fn details_omit_empty_fields() {
358 let clip = Clip {
359 id: "only-id".to_owned(),
360 title: "Bare".to_owned(),
361 ..Clip::default()
362 };
363 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
364 let expected = "Title: Bare\n\
368 Artist: Suno\n\
369 Album: Bare\n\
370 Album Artist: Suno\n\
371 Root: only-id\n\
372 Id: only-id\n\
373 Url: https://suno.com/song/only-id\n";
374 assert_eq!(rendered, expected);
375 assert!(!rendered.contains("Duration:"));
376 assert!(!rendered.contains("Prompt:"));
377 }
378
379 #[test]
380 fn details_exclude_signed_cdn_urls() {
381 let rendered = render_clip_details(&full_clip(), &full_lineage());
382 assert!(!rendered.contains("cdn1.suno.ai"));
383 assert!(!rendered.contains("token=secret"));
384 assert!(!rendered.contains(".mp3"));
385 }
386
387 #[test]
388 fn details_use_canonical_song_url() {
389 let rendered = render_clip_details(&full_clip(), &full_lineage());
390 assert!(rendered.contains("Url: https://suno.com/song/clip-1234abcd\n"));
391 }
392
393 #[test]
394 fn details_label_prompt_not_lyrics() {
395 let rendered = render_clip_details(&full_clip(), &full_lineage());
396 assert!(rendered.contains("Prompt: an orchestral storm\n"));
397 assert!(!rendered.contains("Lyrics:"));
400 assert!(!rendered.contains("thunder rolls"));
401 }
402
403 #[test]
404 fn details_use_resolved_lineage_not_feed_fields() {
405 let clip = Clip {
406 id: "child".to_owned(),
407 title: "Child".to_owned(),
408 album_title: "Ignored Feed Album".to_owned(),
409 ..Clip::default()
410 };
411 let lineage = LineageContext {
412 root_id: "root-01".to_owned(),
413 root_title: "Resolved Album".to_owned(),
414 parent_id: "root-01".to_owned(),
415 edge_type: Some(EdgeType::Cover),
416 status: ResolveStatus::Resolved,
417 };
418 let rendered = render_clip_details(&clip, &lineage);
419 assert!(rendered.contains("Album: Resolved Album\n"));
420 assert!(!rendered.contains("Ignored Feed Album"));
421 }
422
423 #[test]
424 fn details_for_a_pure_root_omit_lineage_and_parent() {
425 let clip = Clip {
426 id: "root".to_owned(),
427 title: "Root".to_owned(),
428 ..Clip::default()
429 };
430 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
431 assert!(!rendered.contains("Parent:"));
434 assert!(!rendered.contains("Lineage:"));
435 assert!(rendered.contains("Root: root\n"));
436 }
437
438 #[test]
439 fn lyrics_render_verbatim_with_one_trailing_newline() {
440 let clip = Clip {
441 lyrics: "line one\nline two".to_owned(),
442 ..Clip::default()
443 };
444 assert_eq!(
445 render_clip_lyrics(&clip),
446 Some("line one\nline two\n".to_owned())
447 );
448 }
449
450 #[test]
451 fn lyrics_normalise_trailing_whitespace_to_one_newline() {
452 let clip = Clip {
453 lyrics: "verse\n\n\n".to_owned(),
454 ..Clip::default()
455 };
456 assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
457 }
458
459 #[test]
460 fn lyrics_none_when_empty_or_whitespace_only() {
461 assert_eq!(render_clip_lyrics(&Clip::default()), None);
462 let clip = Clip {
463 lyrics: " \n\t \n".to_owned(),
464 ..Clip::default()
465 };
466 assert_eq!(render_clip_lyrics(&clip), None);
467 }
468
469 #[test]
470 fn lyrics_use_clip_lyrics_not_prompt() {
471 let clip = Clip {
472 prompt: "the generation prompt".to_owned(),
473 lyrics: "the actual sung words".to_owned(),
474 ..Clip::default()
475 };
476 let rendered = render_clip_lyrics(&clip).unwrap();
477 assert!(rendered.contains("the actual sung words"));
478 assert!(!rendered.contains("the generation prompt"));
479 }
480
481 #[test]
482 fn lrc_none_when_lyrics_blank() {
483 let empty = Clip::default();
484 assert_eq!(
485 render_clip_lrc(&empty, &LineageContext::own_root(&empty)),
486 None
487 );
488 let clip = Clip {
489 lyrics: " \n\t \n".to_owned(),
490 ..Clip::default()
491 };
492 assert_eq!(
493 render_clip_lrc(&clip, &LineageContext::own_root(&clip)),
494 None
495 );
496 }
497
498 #[test]
499 fn lrc_renders_untimed_body_with_headers() {
500 let rendered = render_clip_lrc(&full_clip(), &full_lineage()).unwrap();
501 let expected = "[ti:Electric Storm]\n\
502 [ar:alice]\n\
503 [al:Weather Series]\n\
504 [length:3:32]\n\
505 [re:rs-suno]\n\
506 thunder rolls\n\
507 over the plains\n";
508 assert_eq!(rendered, expected);
509 assert!(!rendered.contains("[00:"));
511 }
512
513 #[test]
514 fn lrc_omits_unknown_headers() {
515 let clip = Clip {
516 title: "Bare".to_owned(),
517 lyrics: "one line".to_owned(),
518 ..Clip::default()
519 };
520 let rendered = render_clip_lrc(&clip, &LineageContext::own_root(&clip)).unwrap();
521 assert!(!rendered.contains("[length:"));
524 assert!(rendered.contains("[ti:Bare]\n"));
525 assert!(rendered.contains("[re:rs-suno]\n"));
526 assert!(rendered.ends_with("one line\n"));
527 }
528
529 #[test]
530 fn m3u8_preserves_order_and_rounds_extinf() {
531 let entries = [
532 M3u8Entry {
533 title: "First",
534 duration_secs: 211.6,
535 relative_path: "Artist/Album/First.flac",
536 },
537 M3u8Entry {
538 title: "Second, Take",
539 duration_secs: 90.5,
540 relative_path: "Artist/Album/Second.flac",
541 },
542 M3u8Entry {
543 title: "Third\nLine",
544 duration_secs: 30.2,
545 relative_path: "Artist/Album/Third.flac",
546 },
547 ];
548
549 let rendered = render_m3u8("Road Trip", &entries);
550
551 let expected = "#EXTM3U\n\
552 #PLAYLIST:Road Trip\n\
553 #EXTINF:212,First\n\
554 Artist/Album/First.flac\n\
555 #EXTINF:91,Second, Take\n\
556 Artist/Album/Second.flac\n\
557 #EXTINF:30,Third Line\n\
558 Artist/Album/Third.flac\n";
559 assert_eq!(rendered, expected);
560 }
561
562 #[test]
563 fn m3u8_strips_newlines_but_keeps_commas() {
564 let entries = [M3u8Entry {
565 title: "Hello, World\r\nSecond, Line",
566 duration_secs: 12.0,
567 relative_path: "Artist/Track.flac",
568 }];
569
570 let rendered = render_m3u8("Mix", &entries);
571
572 assert_eq!(
573 rendered,
574 "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
575 );
576 assert!(!rendered.contains('\r'));
577 assert_eq!(rendered.lines().count(), 4);
579 }
580
581 #[test]
582 fn m3u8_folds_newlines_in_the_playlist_name() {
583 let rendered = render_m3u8("Road\r\nTrip", &[]);
584 assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
585 }
586
587 #[test]
588 fn m3u8_empty_list_is_header_and_name_only() {
589 assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
590 }
591
592 #[test]
593 fn m3u8_absent_member_renders_a_comment_not_a_path() {
594 let entries = [
597 M3u8Entry {
598 title: "In Library",
599 duration_secs: 60.0,
600 relative_path: "Artist/In.flac",
601 },
602 M3u8Entry {
603 title: "Missing, Song",
604 duration_secs: 42.0,
605 relative_path: "",
606 },
607 M3u8Entry {
608 title: "Also Present",
609 duration_secs: 30.0,
610 relative_path: "Artist/Also.flac",
611 },
612 ];
613
614 let rendered = render_m3u8("Liked Songs", &entries);
615
616 let expected = "#EXTM3U\n\
617 #PLAYLIST:Liked Songs\n\
618 #EXTINF:60,In Library\n\
619 Artist/In.flac\n\
620 # (not in library) Missing, Song\n\
621 #EXTINF:30,Also Present\n\
622 Artist/Also.flac\n";
623 assert_eq!(rendered, expected);
624 assert!(!rendered.contains("#EXTINF:42"));
626 }
627
628 #[test]
629 fn m3u8_non_finite_duration_is_zero() {
630 let entries = [M3u8Entry {
631 title: "Unknown",
632 duration_secs: f64::NAN,
633 relative_path: "Artist/Unknown.flac",
634 }];
635
636 assert_eq!(
637 render_m3u8("Odd", &entries),
638 "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
639 );
640 }
641
642 use crate::lineage::{Resolution, RootInfo};
643 use crate::manifest::ManifestEntry;
644 use serde_json::Value;
645 use std::collections::HashMap as Map;
646
647 fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
648 ManifestEntry {
649 path: path.to_owned(),
650 format,
651 size,
652 ..Default::default()
653 }
654 }
655
656 fn clip(id: &str, title: &str) -> Clip {
657 Clip {
658 id: id.to_owned(),
659 title: title.to_owned(),
660 display_name: "alice".to_owned(),
661 handle: "alice_handle".to_owned(),
662 tags: "ambient, cinematic".to_owned(),
663 duration: 211.0,
664 created_at: "2024-03-10T14:22:01Z".to_owned(),
665 ..Default::default()
666 }
667 }
668
669 fn lineage_store() -> LineageStore {
672 let child = Clip {
673 id: "child".to_owned(),
674 title: "Cover Take".to_owned(),
675 created_at: "2024-05-01T00:00:00Z".to_owned(),
676 clip_type: "gen".to_owned(),
677 task: "cover".to_owned(),
678 cover_clip_id: "root".to_owned(),
679 edited_clip_id: "root".to_owned(),
680 ..Default::default()
681 };
682 let root = Clip {
683 id: "root".to_owned(),
684 title: "Original".to_owned(),
685 created_at: "2024-04-01T00:00:00Z".to_owned(),
686 ..Default::default()
687 };
688 let mut roots = HashMap::new();
689 roots.insert(
690 "child".to_owned(),
691 RootInfo {
692 root_id: "root".to_owned(),
693 root_title: "Original".to_owned(),
694 status: ResolveStatus::Resolved,
695 },
696 );
697 roots.insert(
698 "root".to_owned(),
699 RootInfo {
700 root_id: "root".to_owned(),
701 root_title: "Original".to_owned(),
702 status: ResolveStatus::Resolved,
703 },
704 );
705 let resolution = Resolution {
706 roots,
707 gap_filled: Vec::new(),
708 };
709 let mut store = LineageStore::new();
710 store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
711 store
712 }
713
714 fn parse(rendered: &str) -> Value {
715 serde_json::from_str(rendered).expect("index is valid JSON")
716 }
717
718 #[test]
719 fn index_empty_manifest_is_exact() {
720 let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
721 assert_eq!(rendered, "{\n \"schema_version\": 1,\n \"clips\": []\n}");
722 }
723
724 #[test]
725 fn index_schema_version_matches_constant() {
726 let value = parse(&render_library_index(
727 &Manifest::new(),
728 &LineageStore::new(),
729 &Map::new(),
730 ));
731 assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
732 }
733
734 #[test]
735 fn index_live_clip_uses_live_fields_and_canonical_album() {
736 let mut manifest = Manifest::new();
737 manifest.insert(
738 "child",
739 manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
740 );
741 let store = lineage_store();
742 let clip = clip("child", "Cover Take");
743 let mut live: Map<&str, &Clip> = Map::new();
744 live.insert("child", &clip);
745
746 let value = parse(&render_library_index(&manifest, &store, &live));
747 let row = &value["clips"][0];
748 assert_eq!(row["id"], "child");
749 assert_eq!(row["path"], "Original/Cover Take.flac");
750 assert_eq!(row["format"], "flac");
751 assert_eq!(row["size"], 99);
752 assert_eq!(row["title"], "Cover Take");
753 assert_eq!(row["artist"], "alice");
754 assert_eq!(row["handle"], "alice_handle");
755 assert_eq!(row["album"], "Original");
757 assert_eq!(row["root_id"], "root");
758 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
759 assert_eq!(row["duration"], 211.0);
760 assert_eq!(row["tags"], "ambient, cinematic");
761 }
762
763 #[test]
764 fn index_on_disk_clip_nulls_live_only_fields() {
765 let mut manifest = Manifest::new();
766 manifest.insert(
767 "child",
768 manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
769 );
770 let store = lineage_store();
771
772 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
774 let row = &value["clips"][0];
775 assert_eq!(row["format"], "mp3");
777 assert_eq!(row["size"], 7);
778 assert_eq!(row["title"], "Cover Take");
779 assert_eq!(row["album"], "Original");
780 assert_eq!(row["root_id"], "root");
781 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
782 assert!(row["artist"].is_null());
784 assert!(row["handle"].is_null());
785 assert!(row["duration"].is_null());
786 assert!(row["tags"].is_null());
787 }
788
789 #[test]
790 fn index_album_resolves_for_both_live_and_on_disk_clips() {
791 let mut manifest = Manifest::new();
792 manifest.insert(
793 "child",
794 manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
795 );
796 let store = lineage_store();
797
798 let live_clip = clip("child", "Cover Take");
799 let mut live: Map<&str, &Clip> = Map::new();
800 live.insert("child", &live_clip);
801 let live_value = parse(&render_library_index(&manifest, &store, &live));
802 let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
803
804 assert_eq!(live_value["clips"][0]["album"], "Original");
806 assert_eq!(on_disk_value["clips"][0]["album"], "Original");
807 }
808
809 #[test]
810 fn index_album_differs_from_sanitised_path_segment() {
811 let mut manifest = Manifest::new();
815 manifest.insert(
816 "child",
817 manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
818 );
819 let raw_root = Clip {
820 id: "root".to_owned(),
821 title: "AC/DC: Live!".to_owned(),
822 created_at: "2024-04-01T00:00:00Z".to_owned(),
823 ..Default::default()
824 };
825 let child = Clip {
826 id: "child".to_owned(),
827 title: "song".to_owned(),
828 clip_type: "gen".to_owned(),
829 task: "cover".to_owned(),
830 cover_clip_id: "root".to_owned(),
831 edited_clip_id: "root".to_owned(),
832 ..Default::default()
833 };
834 let mut roots = HashMap::new();
835 roots.insert(
836 "child".to_owned(),
837 RootInfo {
838 root_id: "root".to_owned(),
839 root_title: "AC/DC: Live!".to_owned(),
840 status: ResolveStatus::Resolved,
841 },
842 );
843 let resolution = Resolution {
844 roots,
845 gap_filled: Vec::new(),
846 };
847 let mut store = LineageStore::new();
848 store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
849
850 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
851 let row = &value["clips"][0];
852 assert_eq!(row["album"], "AC/DC: Live!");
853 let album = row["album"].as_str().unwrap();
855 let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
856 assert_ne!(album, path_segment);
857 assert_eq!(path_segment, "AC-DC Live");
858 }
859
860 #[test]
861 fn index_iterates_in_clip_id_order() {
862 let mut manifest = Manifest::new();
863 manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
864 manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
865 manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
866
867 let value = parse(&render_library_index(
868 &manifest,
869 &LineageStore::new(),
870 &Map::new(),
871 ));
872 let ids: Vec<&str> = value["clips"]
873 .as_array()
874 .unwrap()
875 .iter()
876 .map(|row| row["id"].as_str().unwrap())
877 .collect();
878 assert_eq!(ids, ["a", "b", "c"]);
879 }
880
881 #[test]
882 fn index_unknown_clip_is_well_formed_with_defaults() {
883 let mut manifest = Manifest::new();
886 manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
887
888 let value = parse(&render_library_index(
889 &manifest,
890 &LineageStore::new(),
891 &Map::new(),
892 ));
893 let row = &value["clips"][0];
894 assert_eq!(row["id"], "orphan");
895 assert_eq!(row["title"], "Untitled");
896 assert_eq!(row["format"], "wav");
897 assert_eq!(row["album"], "");
898 assert_eq!(row["root_id"], "orphan");
899 assert!(row["created_at"].is_null());
900 assert!(row["artist"].is_null());
901 assert!(row["tags"].is_null());
902 }
903
904 #[test]
905 fn index_title_falls_back_to_store_node_then_untitled() {
906 let mut manifest = Manifest::new();
907 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
908 let store = lineage_store();
909 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
911 assert_eq!(value["clips"][0]["title"], "Cover Take");
912 }
913
914 #[test]
915 fn index_artist_falls_back_to_suno_when_display_name_empty() {
916 let mut manifest = Manifest::new();
917 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
918 let mut anon = clip("child", "Cover Take");
919 anon.display_name = String::new();
920 let mut live: Map<&str, &Clip> = Map::new();
921 live.insert("child", &anon);
922 let value = parse(&render_library_index(
923 &manifest,
924 &LineageStore::new(),
925 &live,
926 ));
927 assert_eq!(value["clips"][0]["artist"], "Suno");
929 }
930
931 #[test]
932 fn index_unicode_round_trips() {
933 let mut manifest = Manifest::new();
934 manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
935 let unicode = clip("🎵", "音楽 \"quoted\"");
936 let mut live: Map<&str, &Clip> = Map::new();
937 live.insert("🎵", &unicode);
938
939 let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
940 let value = parse(&rendered);
941 let row = &value["clips"][0];
942 assert_eq!(row["id"], "🎵");
943 assert_eq!(row["path"], "音楽/曲.flac");
944 assert_eq!(row["title"], "音楽 \"quoted\"");
945 }
946}