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