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::lyrics::AlignedLyrics;
18use crate::manifest::Manifest;
19use crate::model::Clip;
20use crate::tag::{TrackMetadata, non_empty};
21
22pub const INDEX_SCHEMA_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, Copy)]
39pub struct M3u8Entry<'a> {
40 pub title: &'a str,
41 pub duration_secs: f64,
42 pub relative_path: &'a str,
43}
44
45pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
56 let mut out = String::from("#EXTM3U\n");
57 let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
58 for entry in entries {
59 let title = to_single_line(entry.title);
60 if entry.relative_path.is_empty() {
61 let _ = writeln!(out, "# (not in library) {title}");
64 continue;
65 }
66 let path = to_single_line(entry.relative_path);
67 let seconds = extinf_seconds(entry.duration_secs);
68 let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
69 }
70 out
71}
72
73#[derive(Debug, Serialize)]
79struct IndexEntry {
80 id: String,
81 path: String,
82 format: AudioFormat,
83 size: u64,
84 title: String,
85 artist: Option<String>,
86 handle: Option<String>,
87 album: String,
88 root_id: String,
89 created_at: Option<String>,
90 duration: Option<f64>,
91 tags: Option<String>,
92}
93
94#[derive(Debug, Serialize)]
96struct LibraryIndex {
97 schema_version: u32,
98 clips: Vec<IndexEntry>,
99}
100
101pub fn render_library_index(
112 manifest: &Manifest,
113 store: &LineageStore,
114 live: &HashMap<&str, &Clip>,
115) -> String {
116 let clips = manifest
117 .iter()
118 .map(|(id, entry)| {
119 let live_clip = live.get(id.as_str()).copied();
120 let title = live_clip
121 .map(|clip| clip.title.clone())
122 .filter(|title| !title.is_empty())
123 .or_else(|| {
124 store
125 .node(id)
126 .map(|node| node.title.clone())
127 .filter(|title| !title.is_empty())
128 })
129 .unwrap_or_else(|| "Untitled".to_owned());
130 let artist =
131 live_clip.map(|clip| non_empty(&clip.display_name).unwrap_or("Suno").to_owned());
132 let handle = live_clip.and_then(|clip| non_empty(&clip.handle).map(str::to_owned));
133 let album = match live_clip {
134 Some(clip) => store.context_for(clip).album(&clip.title),
135 None => store.album_for_id(id),
136 };
137 let root_id = store
138 .get_root(id)
139 .map(|cached| cached.root_id.clone())
140 .filter(|root| !root.is_empty())
141 .unwrap_or_else(|| id.clone());
142 let created_at = store
143 .node(id)
144 .map(|node| node.created_at.clone())
145 .filter(|created| !created.is_empty());
146 let duration = live_clip.map(|clip| clip.duration);
147 let tags = live_clip.map(|clip| clip.tags.clone());
148 IndexEntry {
149 id: id.clone(),
150 path: entry.path.clone(),
151 format: entry.format,
152 size: entry.size,
153 title,
154 artist,
155 handle,
156 album,
157 root_id,
158 created_at,
159 duration,
160 tags,
161 }
162 })
163 .collect();
164 let index = LibraryIndex {
165 schema_version: INDEX_SCHEMA_VERSION,
166 clips,
167 };
168 serde_json::to_string_pretty(&index).expect("library index serialises")
169}
170
171fn extinf_seconds(duration_secs: f64) -> i64 {
175 if duration_secs.is_finite() {
176 duration_secs.round() as i64
177 } else {
178 0
179 }
180}
181fn to_single_line(text: &str) -> String {
184 text.replace('\r', "").replace('\n', " ")
185}
186
187pub fn render_clip_details(clip: &Clip, lineage: &LineageContext) -> String {
200 let meta = TrackMetadata::from_clip(clip, lineage);
201 let url = if clip.id.is_empty() {
202 String::new()
203 } else {
204 format!("{SUNO_SONG_BASE_URL}/{}", clip.id)
205 };
206 let fields: [(&str, &str); 17] = [
207 ("Title", &meta.title),
208 ("Artist", &meta.artist),
209 ("Album", &meta.album),
210 ("Album Artist", &meta.album_artist),
211 ("Date", &meta.date),
212 ("Duration", &format_duration(clip.duration)),
213 ("Model", &meta.model),
214 ("Handle", &meta.handle),
215 ("Style", &meta.style),
216 ("Style Summary", &meta.style_summary),
217 ("Comment", &meta.comment),
218 ("Prompt", &clip.prompt),
219 ("Parent", &meta.parent),
220 ("Root", &meta.root),
221 ("Lineage", &meta.lineage),
222 ("Id", &clip.id),
223 ("Url", &url),
224 ];
225 let mut out = String::new();
226 for (label, value) in fields {
227 if value.is_empty() {
228 continue;
229 }
230 let _ = writeln!(out, "{label}: {}", to_single_line(value));
231 }
232 out
233}
234
235pub fn render_clip_lyrics(clip: &Clip) -> Option<String> {
242 if clip.lyrics.trim().is_empty() {
243 return None;
244 }
245 Some(format!("{}\n", clip.lyrics.trim_end()))
246}
247
248pub fn render_clip_lrc(clip: &Clip, lineage: &LineageContext) -> Option<String> {
259 if clip.lyrics.trim().is_empty() {
260 return None;
261 }
262 let mut out = lrc_headers(clip, lineage);
263 for line in clip.lyrics.trim_end().lines() {
264 let _ = writeln!(out, "{line}");
265 }
266 Some(out)
267}
268
269pub fn render_synced_lrc(
279 clip: &Clip,
280 lineage: &LineageContext,
281 aligned: &AlignedLyrics,
282) -> Option<String> {
283 let body = aligned.lrc_body();
284 if body.is_empty() {
285 return None;
286 }
287 let mut out = lrc_headers(clip, lineage);
288 out.push_str(&body);
289 Some(out)
290}
291
292fn lrc_headers(clip: &Clip, lineage: &LineageContext) -> String {
295 let meta = TrackMetadata::from_clip(clip, lineage);
296 let length = format_duration(clip.duration);
297 let headers: [(&str, &str); 5] = [
298 ("ti", &meta.title),
299 ("ar", &meta.artist),
300 ("al", &meta.album),
301 ("length", &length),
302 ("re", "rs-suno"),
303 ];
304 let mut out = String::new();
305 for (tag, value) in headers {
306 if value.is_empty() {
307 continue;
308 }
309 let _ = writeln!(out, "[{tag}:{}]", to_single_line(value));
310 }
311 out
312}
313
314fn format_duration(secs: f64) -> String {
317 if !secs.is_finite() || secs <= 0.0 {
318 return String::new();
319 }
320 let total = secs.round() as i64;
321 format!("{}:{:02}", total / 60, total % 60)
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use crate::lineage::{EdgeType, ResolveStatus};
328
329 fn full_clip() -> Clip {
330 Clip {
331 id: "clip-1234abcd".to_owned(),
332 title: "Electric Storm".to_owned(),
333 tags: "ambient, cinematic".to_owned(),
334 duration: 211.6,
335 created_at: "2024-03-10T14:22:01Z".to_owned(),
336 display_name: "alice".to_owned(),
337 handle: "alice".to_owned(),
338 prompt: "an orchestral storm".to_owned(),
339 gpt_description_prompt: "a moody cinematic build".to_owned(),
340 lyrics: "thunder rolls\nover the plains".to_owned(),
341 model_name: "chirp-v4".to_owned(),
342 major_model_version: "v4".to_owned(),
343 image_large_url: "https://cdn1.suno.ai/signed?token=secret".to_owned(),
344 audio_url: "https://cdn1.suno.ai/clip-1234abcd.mp3".to_owned(),
345 ..Clip::default()
346 }
347 }
348
349 fn full_lineage() -> LineageContext {
350 LineageContext {
351 root_id: "rootid567890".to_owned(),
352 root_title: "Weather Series".to_owned(),
353 root_date: String::new(),
354 parent_id: "parentid1234".to_owned(),
355 edge_type: Some(EdgeType::Extend),
356 status: ResolveStatus::Resolved,
357 }
358 }
359
360 #[test]
361 fn details_render_is_exact_and_fixed_order() {
362 let rendered = render_clip_details(&full_clip(), &full_lineage());
363 let expected = "Title: Electric Storm\n\
364 Artist: alice\n\
365 Album: Weather Series\n\
366 Album Artist: alice\n\
367 Date: 2024-03-10\n\
368 Duration: 3:32\n\
369 Model: chirp-v4 (v4)\n\
370 Handle: alice\n\
371 Style: ambient, cinematic\n\
372 Style Summary: a moody cinematic build\n\
373 Comment: a moody cinematic build\n\
374 Prompt: an orchestral storm\n\
375 Parent: parentid1234\n\
376 Root: rootid567890\n\
377 Lineage: Extended from parentid Root rootid56 (Weather Series)\n\
378 Id: clip-1234abcd\n\
379 Url: https://suno.com/song/clip-1234abcd\n";
380 assert_eq!(rendered, expected);
381 }
382
383 #[test]
384 fn details_omit_empty_fields() {
385 let clip = Clip {
386 id: "only-id".to_owned(),
387 title: "Bare".to_owned(),
388 ..Clip::default()
389 };
390 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
391 let expected = "Title: Bare\n\
395 Artist: Suno\n\
396 Album: Bare\n\
397 Album Artist: Suno\n\
398 Root: only-id\n\
399 Id: only-id\n\
400 Url: https://suno.com/song/only-id\n";
401 assert_eq!(rendered, expected);
402 assert!(!rendered.contains("Duration:"));
403 assert!(!rendered.contains("Prompt:"));
404 }
405
406 #[test]
407 fn details_exclude_signed_cdn_urls() {
408 let rendered = render_clip_details(&full_clip(), &full_lineage());
409 assert!(!rendered.contains("cdn1.suno.ai"));
410 assert!(!rendered.contains("token=secret"));
411 assert!(!rendered.contains(".mp3"));
412 }
413
414 #[test]
415 fn details_use_canonical_song_url() {
416 let rendered = render_clip_details(&full_clip(), &full_lineage());
417 assert!(rendered.contains("Url: https://suno.com/song/clip-1234abcd\n"));
418 }
419
420 #[test]
421 fn details_label_prompt_not_lyrics() {
422 let rendered = render_clip_details(&full_clip(), &full_lineage());
423 assert!(rendered.contains("Prompt: an orchestral storm\n"));
424 assert!(!rendered.contains("Lyrics:"));
427 assert!(!rendered.contains("thunder rolls"));
428 }
429
430 #[test]
431 fn details_use_resolved_lineage_not_feed_fields() {
432 let clip = Clip {
433 id: "child".to_owned(),
434 title: "Child".to_owned(),
435 album_title: "Ignored Feed Album".to_owned(),
436 ..Clip::default()
437 };
438 let lineage = LineageContext {
439 root_id: "root-01".to_owned(),
440 root_title: "Resolved Album".to_owned(),
441 root_date: String::new(),
442 parent_id: "root-01".to_owned(),
443 edge_type: Some(EdgeType::Cover),
444 status: ResolveStatus::Resolved,
445 };
446 let rendered = render_clip_details(&clip, &lineage);
447 assert!(rendered.contains("Album: Resolved Album\n"));
448 assert!(!rendered.contains("Ignored Feed Album"));
449 }
450
451 #[test]
452 fn details_for_a_pure_root_omit_lineage_and_parent() {
453 let clip = Clip {
454 id: "root".to_owned(),
455 title: "Root".to_owned(),
456 ..Clip::default()
457 };
458 let rendered = render_clip_details(&clip, &LineageContext::own_root(&clip));
459 assert!(!rendered.contains("Parent:"));
462 assert!(!rendered.contains("Lineage:"));
463 assert!(rendered.contains("Root: root\n"));
464 }
465
466 #[test]
467 fn lyrics_render_verbatim_with_one_trailing_newline() {
468 let clip = Clip {
469 lyrics: "line one\nline two".to_owned(),
470 ..Clip::default()
471 };
472 assert_eq!(
473 render_clip_lyrics(&clip),
474 Some("line one\nline two\n".to_owned())
475 );
476 }
477
478 #[test]
479 fn lyrics_normalise_trailing_whitespace_to_one_newline() {
480 let clip = Clip {
481 lyrics: "verse\n\n\n".to_owned(),
482 ..Clip::default()
483 };
484 assert_eq!(render_clip_lyrics(&clip), Some("verse\n".to_owned()));
485 }
486
487 #[test]
488 fn lyrics_none_when_empty_or_whitespace_only() {
489 assert_eq!(render_clip_lyrics(&Clip::default()), None);
490 let clip = Clip {
491 lyrics: " \n\t \n".to_owned(),
492 ..Clip::default()
493 };
494 assert_eq!(render_clip_lyrics(&clip), None);
495 }
496
497 #[test]
498 fn lyrics_use_clip_lyrics_not_prompt() {
499 let clip = Clip {
500 prompt: "the generation prompt".to_owned(),
501 lyrics: "the actual sung words".to_owned(),
502 ..Clip::default()
503 };
504 let rendered = render_clip_lyrics(&clip).unwrap();
505 assert!(rendered.contains("the actual sung words"));
506 assert!(!rendered.contains("the generation prompt"));
507 }
508
509 #[test]
510 fn lrc_none_when_lyrics_blank() {
511 let empty = Clip::default();
512 assert_eq!(
513 render_clip_lrc(&empty, &LineageContext::own_root(&empty)),
514 None
515 );
516 let clip = Clip {
517 lyrics: " \n\t \n".to_owned(),
518 ..Clip::default()
519 };
520 assert_eq!(
521 render_clip_lrc(&clip, &LineageContext::own_root(&clip)),
522 None
523 );
524 }
525
526 #[test]
527 fn lrc_renders_untimed_body_with_headers() {
528 let rendered = render_clip_lrc(&full_clip(), &full_lineage()).unwrap();
529 let expected = "[ti:Electric Storm]\n\
530 [ar:alice]\n\
531 [al:Weather Series]\n\
532 [length:3:32]\n\
533 [re:rs-suno]\n\
534 thunder rolls\n\
535 over the plains\n";
536 assert_eq!(rendered, expected);
537 assert!(!rendered.contains("[00:"));
539 }
540
541 #[test]
542 fn lrc_omits_unknown_headers() {
543 let clip = Clip {
544 title: "Bare".to_owned(),
545 lyrics: "one line".to_owned(),
546 ..Clip::default()
547 };
548 let rendered = render_clip_lrc(&clip, &LineageContext::own_root(&clip)).unwrap();
549 assert!(!rendered.contains("[length:"));
552 assert!(rendered.contains("[ti:Bare]\n"));
553 assert!(rendered.contains("[re:rs-suno]\n"));
554 assert!(rendered.ends_with("one line\n"));
555 }
556
557 fn sample_aligned() -> crate::lyrics::AlignedLyrics {
558 crate::lyrics::AlignedLyrics::from_json(&serde_json::json!({
559 "aligned_words": [],
560 "aligned_lyrics": [
561 {"text": "thunder rolls", "start_s": 1.5, "end_s": 2.4, "section": "Verse 1",
562 "words": [
563 {"text": "thunder", "start_s": 1.5, "end_s": 2.0},
564 {"text": "rolls", "start_s": 2.1, "end_s": 2.4}
565 ]}
566 ]
567 }))
568 }
569
570 #[test]
571 fn synced_lrc_has_headers_then_line_stamps() {
572 let rendered = render_synced_lrc(&full_clip(), &full_lineage(), &sample_aligned()).unwrap();
573 let expected = "[ti:Electric Storm]\n\
574 [ar:alice]\n\
575 [al:Weather Series]\n\
576 [length:3:32]\n\
577 [re:rs-suno]\n\
578 [00:01.50]thunder rolls\n";
579 assert_eq!(rendered, expected);
580 }
581
582 #[test]
583 fn synced_lrc_is_none_for_empty_alignment() {
584 let empty = crate::lyrics::AlignedLyrics::default();
587 assert_eq!(
588 render_synced_lrc(&full_clip(), &full_lineage(), &empty),
589 None
590 );
591 }
592
593 #[test]
594 fn m3u8_preserves_order_and_rounds_extinf() {
595 let entries = [
596 M3u8Entry {
597 title: "First",
598 duration_secs: 211.6,
599 relative_path: "Artist/Album/First.flac",
600 },
601 M3u8Entry {
602 title: "Second, Take",
603 duration_secs: 90.5,
604 relative_path: "Artist/Album/Second.flac",
605 },
606 M3u8Entry {
607 title: "Third\nLine",
608 duration_secs: 30.2,
609 relative_path: "Artist/Album/Third.flac",
610 },
611 ];
612
613 let rendered = render_m3u8("Road Trip", &entries);
614
615 let expected = "#EXTM3U\n\
616 #PLAYLIST:Road Trip\n\
617 #EXTINF:212,First\n\
618 Artist/Album/First.flac\n\
619 #EXTINF:91,Second, Take\n\
620 Artist/Album/Second.flac\n\
621 #EXTINF:30,Third Line\n\
622 Artist/Album/Third.flac\n";
623 assert_eq!(rendered, expected);
624 }
625
626 #[test]
627 fn m3u8_strips_newlines_but_keeps_commas() {
628 let entries = [M3u8Entry {
629 title: "Hello, World\r\nSecond, Line",
630 duration_secs: 12.0,
631 relative_path: "Artist/Track.flac",
632 }];
633
634 let rendered = render_m3u8("Mix", &entries);
635
636 assert_eq!(
637 rendered,
638 "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
639 );
640 assert!(!rendered.contains('\r'));
641 assert_eq!(rendered.lines().count(), 4);
643 }
644
645 #[test]
646 fn m3u8_folds_newlines_in_the_playlist_name() {
647 let rendered = render_m3u8("Road\r\nTrip", &[]);
648 assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
649 }
650
651 #[test]
652 fn m3u8_empty_list_is_header_and_name_only() {
653 assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
654 }
655
656 #[test]
657 fn m3u8_absent_member_renders_a_comment_not_a_path() {
658 let entries = [
661 M3u8Entry {
662 title: "In Library",
663 duration_secs: 60.0,
664 relative_path: "Artist/In.flac",
665 },
666 M3u8Entry {
667 title: "Missing, Song",
668 duration_secs: 42.0,
669 relative_path: "",
670 },
671 M3u8Entry {
672 title: "Also Present",
673 duration_secs: 30.0,
674 relative_path: "Artist/Also.flac",
675 },
676 ];
677
678 let rendered = render_m3u8("Liked Songs", &entries);
679
680 let expected = "#EXTM3U\n\
681 #PLAYLIST:Liked Songs\n\
682 #EXTINF:60,In Library\n\
683 Artist/In.flac\n\
684 # (not in library) Missing, Song\n\
685 #EXTINF:30,Also Present\n\
686 Artist/Also.flac\n";
687 assert_eq!(rendered, expected);
688 assert!(!rendered.contains("#EXTINF:42"));
690 }
691
692 #[test]
693 fn m3u8_non_finite_duration_is_zero() {
694 let entries = [M3u8Entry {
695 title: "Unknown",
696 duration_secs: f64::NAN,
697 relative_path: "Artist/Unknown.flac",
698 }];
699
700 assert_eq!(
701 render_m3u8("Odd", &entries),
702 "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
703 );
704 }
705
706 use crate::lineage::{Resolution, RootInfo};
707 use crate::manifest::ManifestEntry;
708 use serde_json::Value;
709 use std::collections::HashMap as Map;
710
711 fn manifest_entry(path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
712 ManifestEntry {
713 path: path.to_owned(),
714 format,
715 size,
716 ..Default::default()
717 }
718 }
719
720 fn clip(id: &str, title: &str) -> Clip {
721 Clip {
722 id: id.to_owned(),
723 title: title.to_owned(),
724 display_name: "alice".to_owned(),
725 handle: "alice_handle".to_owned(),
726 tags: "ambient, cinematic".to_owned(),
727 duration: 211.0,
728 created_at: "2024-03-10T14:22:01Z".to_owned(),
729 ..Default::default()
730 }
731 }
732
733 fn lineage_store() -> LineageStore {
736 let child = Clip {
737 id: "child".to_owned(),
738 title: "Cover Take".to_owned(),
739 created_at: "2024-05-01T00:00:00Z".to_owned(),
740 clip_type: "gen".to_owned(),
741 task: "cover".to_owned(),
742 cover_clip_id: "root".to_owned(),
743 edited_clip_id: "root".to_owned(),
744 ..Default::default()
745 };
746 let root = Clip {
747 id: "root".to_owned(),
748 title: "Original".to_owned(),
749 created_at: "2024-04-01T00:00:00Z".to_owned(),
750 ..Default::default()
751 };
752 let mut roots = HashMap::new();
753 roots.insert(
754 "child".to_owned(),
755 RootInfo {
756 root_id: "root".to_owned(),
757 root_title: "Original".to_owned(),
758 status: ResolveStatus::Resolved,
759 },
760 );
761 roots.insert(
762 "root".to_owned(),
763 RootInfo {
764 root_id: "root".to_owned(),
765 root_title: "Original".to_owned(),
766 status: ResolveStatus::Resolved,
767 },
768 );
769 let resolution = Resolution {
770 roots,
771 gap_filled: Vec::new(),
772 };
773 let mut store = LineageStore::new();
774 store.update(&[child, root], &resolution, "2024-06-01T00:00:00Z");
775 store
776 }
777
778 fn parse(rendered: &str) -> Value {
779 serde_json::from_str(rendered).expect("index is valid JSON")
780 }
781
782 #[test]
783 fn index_empty_manifest_is_exact() {
784 let rendered = render_library_index(&Manifest::new(), &LineageStore::new(), &Map::new());
785 assert_eq!(rendered, "{\n \"schema_version\": 1,\n \"clips\": []\n}");
786 }
787
788 #[test]
789 fn index_schema_version_matches_constant() {
790 let value = parse(&render_library_index(
791 &Manifest::new(),
792 &LineageStore::new(),
793 &Map::new(),
794 ));
795 assert_eq!(value["schema_version"], INDEX_SCHEMA_VERSION);
796 }
797
798 #[test]
799 fn index_live_clip_uses_live_fields_and_canonical_album() {
800 let mut manifest = Manifest::new();
801 manifest.insert(
802 "child",
803 manifest_entry("Original/Cover Take.flac", AudioFormat::Flac, 99),
804 );
805 let store = lineage_store();
806 let clip = clip("child", "Cover Take");
807 let mut live: Map<&str, &Clip> = Map::new();
808 live.insert("child", &clip);
809
810 let value = parse(&render_library_index(&manifest, &store, &live));
811 let row = &value["clips"][0];
812 assert_eq!(row["id"], "child");
813 assert_eq!(row["path"], "Original/Cover Take.flac");
814 assert_eq!(row["format"], "flac");
815 assert_eq!(row["size"], 99);
816 assert_eq!(row["title"], "Cover Take");
817 assert_eq!(row["artist"], "alice");
818 assert_eq!(row["handle"], "alice_handle");
819 assert_eq!(row["album"], "Original");
821 assert_eq!(row["root_id"], "root");
822 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
823 assert_eq!(row["duration"], 211.0);
824 assert_eq!(row["tags"], "ambient, cinematic");
825 }
826
827 #[test]
828 fn index_on_disk_clip_nulls_live_only_fields() {
829 let mut manifest = Manifest::new();
830 manifest.insert(
831 "child",
832 manifest_entry("Original/Cover Take.flac", AudioFormat::Mp3, 7),
833 );
834 let store = lineage_store();
835
836 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
838 let row = &value["clips"][0];
839 assert_eq!(row["format"], "mp3");
841 assert_eq!(row["size"], 7);
842 assert_eq!(row["title"], "Cover Take");
843 assert_eq!(row["album"], "Original");
844 assert_eq!(row["root_id"], "root");
845 assert_eq!(row["created_at"], "2024-05-01T00:00:00Z");
846 assert!(row["artist"].is_null());
848 assert!(row["handle"].is_null());
849 assert!(row["duration"].is_null());
850 assert!(row["tags"].is_null());
851 }
852
853 #[test]
854 fn index_album_resolves_for_both_live_and_on_disk_clips() {
855 let mut manifest = Manifest::new();
856 manifest.insert(
857 "child",
858 manifest_entry("Original/a.flac", AudioFormat::Flac, 1),
859 );
860 let store = lineage_store();
861
862 let live_clip = clip("child", "Cover Take");
863 let mut live: Map<&str, &Clip> = Map::new();
864 live.insert("child", &live_clip);
865 let live_value = parse(&render_library_index(&manifest, &store, &live));
866 let on_disk_value = parse(&render_library_index(&manifest, &store, &Map::new()));
867
868 assert_eq!(live_value["clips"][0]["album"], "Original");
870 assert_eq!(on_disk_value["clips"][0]["album"], "Original");
871 }
872
873 #[test]
874 fn index_album_differs_from_sanitised_path_segment() {
875 let mut manifest = Manifest::new();
879 manifest.insert(
880 "child",
881 manifest_entry("AC-DC Live/song.flac", AudioFormat::Flac, 1),
882 );
883 let raw_root = Clip {
884 id: "root".to_owned(),
885 title: "AC/DC: Live!".to_owned(),
886 created_at: "2024-04-01T00:00:00Z".to_owned(),
887 ..Default::default()
888 };
889 let child = Clip {
890 id: "child".to_owned(),
891 title: "song".to_owned(),
892 clip_type: "gen".to_owned(),
893 task: "cover".to_owned(),
894 cover_clip_id: "root".to_owned(),
895 edited_clip_id: "root".to_owned(),
896 ..Default::default()
897 };
898 let mut roots = HashMap::new();
899 roots.insert(
900 "child".to_owned(),
901 RootInfo {
902 root_id: "root".to_owned(),
903 root_title: "AC/DC: Live!".to_owned(),
904 status: ResolveStatus::Resolved,
905 },
906 );
907 let resolution = Resolution {
908 roots,
909 gap_filled: Vec::new(),
910 };
911 let mut store = LineageStore::new();
912 store.update(&[child, raw_root], &resolution, "2024-06-01T00:00:00Z");
913
914 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
915 let row = &value["clips"][0];
916 assert_eq!(row["album"], "AC/DC: Live!");
917 let album = row["album"].as_str().unwrap();
919 let path_segment = row["path"].as_str().unwrap().split('/').next().unwrap();
920 assert_ne!(album, path_segment);
921 assert_eq!(path_segment, "AC-DC Live");
922 }
923
924 #[test]
925 fn index_iterates_in_clip_id_order() {
926 let mut manifest = Manifest::new();
927 manifest.insert("c", manifest_entry("c.flac", AudioFormat::Flac, 1));
928 manifest.insert("a", manifest_entry("a.flac", AudioFormat::Flac, 1));
929 manifest.insert("b", manifest_entry("b.flac", AudioFormat::Flac, 1));
930
931 let value = parse(&render_library_index(
932 &manifest,
933 &LineageStore::new(),
934 &Map::new(),
935 ));
936 let ids: Vec<&str> = value["clips"]
937 .as_array()
938 .unwrap()
939 .iter()
940 .map(|row| row["id"].as_str().unwrap())
941 .collect();
942 assert_eq!(ids, ["a", "b", "c"]);
943 }
944
945 #[test]
946 fn index_unknown_clip_is_well_formed_with_defaults() {
947 let mut manifest = Manifest::new();
950 manifest.insert("orphan", manifest_entry("orphan.wav", AudioFormat::Wav, 3));
951
952 let value = parse(&render_library_index(
953 &manifest,
954 &LineageStore::new(),
955 &Map::new(),
956 ));
957 let row = &value["clips"][0];
958 assert_eq!(row["id"], "orphan");
959 assert_eq!(row["title"], "Untitled");
960 assert_eq!(row["format"], "wav");
961 assert_eq!(row["album"], "");
962 assert_eq!(row["root_id"], "orphan");
963 assert!(row["created_at"].is_null());
964 assert!(row["artist"].is_null());
965 assert!(row["tags"].is_null());
966 }
967
968 #[test]
969 fn index_title_falls_back_to_store_node_then_untitled() {
970 let mut manifest = Manifest::new();
971 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
972 let store = lineage_store();
973 let value = parse(&render_library_index(&manifest, &store, &Map::new()));
975 assert_eq!(value["clips"][0]["title"], "Cover Take");
976 }
977
978 #[test]
979 fn index_artist_falls_back_to_suno_when_display_name_empty() {
980 let mut manifest = Manifest::new();
981 manifest.insert("child", manifest_entry("x.flac", AudioFormat::Flac, 1));
982 let mut anon = clip("child", "Cover Take");
983 anon.display_name = String::new();
984 let mut live: Map<&str, &Clip> = Map::new();
985 live.insert("child", &anon);
986 let value = parse(&render_library_index(
987 &manifest,
988 &LineageStore::new(),
989 &live,
990 ));
991 assert_eq!(value["clips"][0]["artist"], "Suno");
993 }
994
995 #[test]
996 fn index_unicode_round_trips() {
997 let mut manifest = Manifest::new();
998 manifest.insert("🎵", manifest_entry("音楽/曲.flac", AudioFormat::Flac, 5));
999 let unicode = clip("🎵", "音楽 \"quoted\"");
1000 let mut live: Map<&str, &Clip> = Map::new();
1001 live.insert("🎵", &unicode);
1002
1003 let rendered = render_library_index(&manifest, &LineageStore::new(), &live);
1004 let value = parse(&rendered);
1005 let row = &value["clips"][0];
1006 assert_eq!(row["id"], "🎵");
1007 assert_eq!(row["path"], "音楽/曲.flac");
1008 assert_eq!(row["title"], "音楽 \"quoted\"");
1009 }
1010}