1use std::collections::{HashMap, HashSet};
17
18use crate::client::SunoClient;
19use crate::error::Result;
20use crate::http::Http;
21use crate::model::Clip;
22
23const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum EdgeType {
29 Cover,
31 Remaster,
33 SpeedEdit,
35 Edit,
37 Extend,
39 SectionReplace,
41 Stitch,
43 Derived,
45 Uploaded,
47}
48
49impl EdgeType {
50 pub fn label(self) -> &'static str {
52 match self {
53 EdgeType::Cover => "Cover of",
54 EdgeType::Remaster => "Remaster of",
55 EdgeType::SpeedEdit => "Speed-edited from",
56 EdgeType::Edit => "Edited from",
57 EdgeType::Extend => "Extended from",
58 EdgeType::SectionReplace => "Section replaced from",
59 EdgeType::Stitch => "Stitched from",
60 EdgeType::Derived => "Derived from",
61 EdgeType::Uploaded => "Uploaded",
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum EdgeRole {
69 Primary,
71 Secondary,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Edge {
78 pub parent_id: String,
80 pub edge_type: EdgeType,
82 pub role: EdgeRole,
84 pub ordinal: u32,
86 pub source_field: &'static str,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct ResolveOpts {
93 pub max_gap_fills: u32,
95 pub hop_cap: u32,
97}
98
99impl Default for ResolveOpts {
100 fn default() -> Self {
101 Self {
102 max_gap_fills: 200,
103 hop_cap: 64,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum ResolveStatus {
111 Resolved,
113 External,
116 Unresolved,
118 Cycle,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct RootInfo {
125 pub root_id: String,
127 pub root_title: String,
129 pub status: ResolveStatus,
131}
132
133#[derive(Debug, Clone, PartialEq)]
141pub struct Resolution {
142 pub roots: HashMap<String, RootInfo>,
145 pub gap_filled: Vec<Clip>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct LineageContext {
160 pub root_id: String,
162 pub root_title: String,
164 pub parent_id: String,
166 pub edge_type: Option<EdgeType>,
168 pub status: ResolveStatus,
170}
171
172impl LineageContext {
173 pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
180 let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
181 Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
182 None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
183 };
184 let (parent_id, edge_type) = match immediate_parent(clip) {
185 Some((id, edge)) => (id, Some(edge)),
186 None => (String::new(), None),
187 };
188 LineageContext {
189 root_id,
190 root_title,
191 parent_id,
192 edge_type,
193 status,
194 }
195 }
196
197 pub fn own_root(clip: &Clip) -> LineageContext {
201 LineageContext {
202 root_id: clip.id.clone(),
203 root_title: clip.title.clone(),
204 parent_id: String::new(),
205 edge_type: None,
206 status: ResolveStatus::Resolved,
207 }
208 }
209
210 pub fn album(&self, own_title: &str) -> String {
217 let root_title = self.root_title.trim();
218 if !root_title.is_empty() && self.root_title != own_title {
219 self.root_title.clone()
220 } else {
221 own_title.to_owned()
222 }
223 }
224}
225
226pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
239 let task = clip.task.as_str();
240 let clip_type = clip.clip_type.as_str();
241
242 if task == "infill" || task == "fixed_infill" {
243 Some(EdgeType::SectionReplace)
244 } else if task == "extend" {
245 Some(EdgeType::Extend)
246 } else if clip_type == "concat" {
247 Some(EdgeType::Stitch)
248 } else if clip_type == "edit_speed" {
249 Some(EdgeType::SpeedEdit)
250 } else if task == "cover" {
251 Some(EdgeType::Cover)
252 } else if clip_type == "upsample" || task == "upsample" {
253 Some(EdgeType::Remaster)
254 } else if clip_type == "edit_v3_export" {
255 Some(EdgeType::Edit)
256 } else if normalise_id(&clip.edited_clip_id).is_some() {
257 Some(EdgeType::Derived)
258 } else {
259 None
260 }
261}
262
263pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
271 primary_parent(clip).map(|(id, edge, _field)| (id, edge))
272}
273
274pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
285 let Some(edge_type) = edge_type(clip) else {
286 return Vec::new();
287 };
288
289 let mut edges = Vec::new();
290 if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
291 edges.push(Edge {
292 parent_id,
293 edge_type,
294 role: EdgeRole::Primary,
295 ordinal: 0,
296 source_field,
297 });
298 }
299
300 match edge_type {
301 EdgeType::Stitch => {
302 for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
303 if let Some(id) = normalise_id(&entry.id) {
304 edges.push(Edge {
305 parent_id: id,
306 edge_type,
307 role: EdgeRole::Secondary,
308 ordinal: ordinal as u32,
309 source_field: "concat_history",
310 });
311 }
312 }
313 }
314 EdgeType::SectionReplace => {
315 if let Some(future) = normalise_id(&clip.override_future_clip_id)
316 && edges
317 .first()
318 .is_none_or(|primary| primary.parent_id != future)
319 {
320 edges.push(Edge {
321 parent_id: future,
322 edge_type,
323 role: EdgeRole::Secondary,
324 ordinal: 1,
325 source_field: "override_future_clip_id",
326 });
327 }
328 }
329 _ => {}
330 }
331
332 edges
333}
334
335pub async fn resolve_roots(
354 clips: &[Clip],
355 client: &mut SunoClient,
356 http: &impl Http,
357 opts: ResolveOpts,
358) -> Result<Resolution> {
359 let mut resolver = Resolver::new(clips, opts);
360 resolver.run(client, http).await?;
361 Ok(resolver.into_resolution(clips))
362}
363
364fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
368 let edge = edge_type(clip)?;
369 let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
370 let concat_head = clip
371 .concat_history
372 .first()
373 .map_or("", |entry| entry.id.as_str());
374
375 let candidates: Vec<(&str, &'static str)> = match edge {
376 EdgeType::SectionReplace => vec![
377 (
378 clip.override_history_clip_id.as_str(),
379 "override_history_clip_id",
380 ),
381 (
382 clip.override_future_clip_id.as_str(),
383 "override_future_clip_id",
384 ),
385 (history_head, "history"),
386 (clip.edited_clip_id.as_str(), "edited_clip_id"),
387 ],
388 EdgeType::Extend => vec![
389 (history_head, "history"),
390 (clip.edited_clip_id.as_str(), "edited_clip_id"),
391 ],
392 EdgeType::Stitch => vec![
393 (concat_head, "concat_history"),
394 (clip.edited_clip_id.as_str(), "edited_clip_id"),
395 ],
396 EdgeType::SpeedEdit => vec![
397 (clip.speed_clip_id.as_str(), "speed_clip_id"),
398 (clip.edited_clip_id.as_str(), "edited_clip_id"),
399 ],
400 EdgeType::Cover => vec![
401 (clip.cover_clip_id.as_str(), "cover_clip_id"),
402 (clip.edited_clip_id.as_str(), "edited_clip_id"),
403 ],
404 EdgeType::Remaster => vec![
405 (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
406 (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
407 (clip.edited_clip_id.as_str(), "edited_clip_id"),
408 ],
409 EdgeType::Edit | EdgeType::Derived => {
410 vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
411 }
412 EdgeType::Uploaded => vec![],
413 };
414
415 candidates
416 .into_iter()
417 .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
418}
419
420fn normalise_id(id: &str) -> Option<String> {
423 let id = id.strip_prefix("m_").unwrap_or(id);
424 if id.is_empty() || id == ZERO_UUID {
425 None
426 } else {
427 Some(id.to_string())
428 }
429}
430
431enum Walk {
433 Resolved,
435 Blocked(String),
437}
438
439struct Resolver {
447 index: HashMap<String, Clip>,
448 gap_filled: HashSet<String>,
449 bridges: HashMap<String, String>,
450 external: HashSet<String>,
451 memo: HashMap<String, RootInfo>,
452 targets: Vec<String>,
453 budget: u32,
454 hop_cap: u32,
455}
456
457impl Resolver {
458 fn new(clips: &[Clip], opts: ResolveOpts) -> Self {
459 let index = clips
460 .iter()
461 .map(|clip| (clip.id.clone(), clip.clone()))
462 .collect();
463 let targets = clips.iter().map(|clip| clip.id.clone()).collect();
464 Self {
465 index,
466 gap_filled: HashSet::new(),
467 bridges: HashMap::new(),
468 external: HashSet::new(),
469 memo: HashMap::new(),
470 targets,
471 budget: opts.max_gap_fills,
472 hop_cap: opts.hop_cap,
473 }
474 }
475
476 async fn run(&mut self, client: &mut SunoClient, http: &impl Http) -> Result<()> {
479 let targets = self.targets.clone();
480 loop {
481 let mut frontier: Vec<String> = Vec::new();
482 let mut seen: HashSet<String> = HashSet::new();
483 let mut blocked: Vec<(String, String)> = Vec::new();
484
485 for target in &targets {
486 if self.memo.contains_key(target) {
487 continue;
488 }
489 if let Walk::Blocked(missing) = self.walk(target) {
490 if seen.insert(missing.clone()) {
491 frontier.push(missing.clone());
492 }
493 blocked.push((target.clone(), missing));
494 }
495 }
496
497 if blocked.is_empty() {
498 break;
499 }
500 if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
501 self.finalise_external(&blocked);
502 break;
503 }
504 }
505 Ok(())
506 }
507
508 fn walk(&mut self, start: &str) -> Walk {
512 if self.memo.contains_key(start) {
513 return Walk::Resolved;
514 }
515 let mut chain: Vec<String> = Vec::new();
516 let mut visited: HashSet<String> = HashSet::new();
517 let mut current = start.to_string();
518 let mut hops = 0u32;
519
520 loop {
521 if let Some(info) = self.memo.get(¤t).cloned() {
522 self.assign(&chain, &info);
523 return Walk::Resolved;
524 }
525 if visited.contains(¤t) {
526 let info = self.terminal(¤t, ResolveStatus::Cycle);
527 self.assign(&chain, &info);
528 self.memo.insert(current, info);
529 return Walk::Resolved;
530 }
531 if hops >= self.hop_cap {
532 let info = self.terminal(¤t, ResolveStatus::Unresolved);
533 self.assign(&chain, &info);
534 self.memo.insert(current, info);
535 return Walk::Resolved;
536 }
537
538 let (parent, title) = match self.index.get(¤t) {
539 Some(clip) => (immediate_parent(clip), clip.title.clone()),
540 None => return Walk::Blocked(current),
541 };
542
543 let Some((parent_id, _edge)) = parent else {
544 let info = RootInfo {
545 root_id: current.clone(),
546 root_title: title,
547 status: ResolveStatus::Resolved,
548 };
549 self.assign(&chain, &info);
550 self.memo.insert(current, info);
551 return Walk::Resolved;
552 };
553
554 visited.insert(current.clone());
555 chain.push(current);
556
557 if self.index.contains_key(&parent_id) {
558 current = parent_id;
559 } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
560 visited.insert(parent_id);
561 current = bridged;
562 } else if self.external.contains(&parent_id) {
563 let info = self.terminal(&parent_id, ResolveStatus::External);
564 self.assign(&chain, &info);
565 self.memo.insert(parent_id, info);
566 return Walk::Resolved;
567 } else {
568 return Walk::Blocked(parent_id);
569 }
570 hops += 1;
571 }
572 }
573
574 async fn gap_fill(
578 &mut self,
579 client: &mut SunoClient,
580 http: &impl Http,
581 frontier: &[String],
582 ) -> Result<bool> {
583 let mut want: Vec<String> = frontier
584 .iter()
585 .filter(|id| !self.known(id))
586 .cloned()
587 .collect();
588 if want.is_empty() {
589 return Ok(false);
590 }
591 want.sort();
592 let take = (self.budget as usize).min(want.len());
593 let batch: Vec<String> = want.into_iter().take(take).collect();
594 self.budget -= batch.len() as u32;
595
596 let refs: Vec<&str> = batch.iter().map(String::as_str).collect();
597 let fetched = client.get_clips_by_ids(http, &refs).await?;
598
599 let mut returned: HashSet<String> = HashSet::new();
600 let mut progressed = false;
601 for clip in fetched {
602 returned.insert(clip.id.clone());
603 if self.insert_ancestor(clip) {
604 progressed = true;
605 }
606 }
607
608 for id in &batch {
609 if returned.contains(id) {
610 continue;
611 }
612 match client.get_clip_parent(http, id).await? {
613 Some(parent) => {
614 let parent_id = parent.id.clone();
615 self.insert_ancestor(parent);
616 self.bridges.insert(id.clone(), parent_id);
617 progressed = true;
618 }
619 None => {
620 self.external.insert(id.clone());
621 progressed = true;
622 }
623 }
624 }
625
626 Ok(progressed)
627 }
628
629 fn insert_ancestor(&mut self, clip: Clip) -> bool {
632 if clip.id.is_empty() || self.index.contains_key(&clip.id) {
633 return false;
634 }
635 self.gap_filled.insert(clip.id.clone());
636 self.index.insert(clip.id.clone(), clip);
637 true
638 }
639
640 fn known(&self, id: &str) -> bool {
642 self.index.contains_key(id) || self.bridges.contains_key(id) || self.external.contains(id)
643 }
644
645 fn finalise_external(&mut self, blocked: &[(String, String)]) {
648 for (target, missing) in blocked {
649 if self.memo.contains_key(target) {
650 continue;
651 }
652 let info = self.terminal(missing, ResolveStatus::External);
653 self.memo.insert(target.clone(), info);
654 }
655 }
656
657 fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
659 RootInfo {
660 root_id: id.to_string(),
661 root_title: self.title_of(id),
662 status,
663 }
664 }
665
666 fn title_of(&self, id: &str) -> String {
668 self.index
669 .get(id)
670 .map_or_else(String::new, |clip| clip.title.clone())
671 }
672
673 fn assign(&mut self, chain: &[String], info: &RootInfo) {
675 for id in chain {
676 self.memo.insert(id.clone(), info.clone());
677 }
678 }
679
680 fn into_resolution(self, clips: &[Clip]) -> Resolution {
683 let mut roots = HashMap::with_capacity(clips.len());
684 for clip in clips {
685 let info = self
686 .memo
687 .get(&clip.id)
688 .cloned()
689 .unwrap_or_else(|| RootInfo {
690 root_id: clip.id.clone(),
691 root_title: clip.title.clone(),
692 status: ResolveStatus::Unresolved,
693 });
694 roots.insert(clip.id.clone(), info);
695 }
696
697 let mut gap_filled: Vec<Clip> = self
698 .gap_filled
699 .iter()
700 .filter_map(|id| self.index.get(id).cloned())
701 .collect();
702 gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
703
704 Resolution { roots, gap_filled }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::auth::ClerkAuth;
712 use crate::model::HistoryEntry;
713 use crate::testutil::{Reply, ScriptedHttp};
714
715 fn history(id: &str) -> HistoryEntry {
716 HistoryEntry {
717 id: id.to_owned(),
718 ..Default::default()
719 }
720 }
721
722 fn chain1_clips() -> Vec<Clip> {
726 vec![
727 Clip {
728 id: "40068b49".into(),
729 title: "Zac and the Sea Eagles (Lullaby Version)".into(),
730 clip_type: "upsample".into(),
731 task: "upsample".into(),
732 is_remix: true,
733 upsample_clip_id: "52962dae".into(),
734 edited_clip_id: "52962dae".into(),
735 ..Default::default()
736 },
737 Clip {
738 id: "52962dae".into(),
739 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
740 clip_type: "gen".into(),
741 task: "cover".into(),
742 is_remix: true,
743 cover_clip_id: "536e1b92".into(),
744 edited_clip_id: "536e1b92".into(),
745 ..Default::default()
746 },
747 Clip {
748 id: "536e1b92".into(),
749 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
750 clip_type: "upsample".into(),
751 task: "upsample".into(),
752 is_remix: true,
753 upsample_clip_id: "b9f27ee1".into(),
754 edited_clip_id: "b9f27ee1".into(),
755 ..Default::default()
756 },
757 Clip {
758 id: "b9f27ee1".into(),
759 title: "Zac and the Sea Eagles (Edit)".into(),
760 clip_type: "gen".into(),
761 task: "cover".into(),
762 is_remix: true,
763 cover_clip_id: "c1997d52".into(),
764 edited_clip_id: "c1997d52".into(),
765 ..Default::default()
766 },
767 Clip {
768 id: "c1997d52".into(),
769 title: "Zac and the Sea Eagles (Rework)".into(),
770 clip_type: "edit_v3_export".into(),
771 edited_clip_id: "dfb59a04".into(),
772 ..Default::default()
773 },
774 Clip {
775 id: "dfb59a04".into(),
776 title: "Zac and the Sea Eagles".into(),
777 clip_type: "gen".into(),
778 ..Default::default()
779 },
780 ]
781 }
782
783 fn authed_client(http: &ScriptedHttp) -> SunoClient {
784 let mut auth = ClerkAuth::new("eyJtoken");
785 pollster::block_on(auth.authenticate(http)).unwrap();
786 SunoClient::new(auth)
787 }
788
789 #[test]
790 fn edge_type_labels_read_naturally() {
791 assert_eq!(EdgeType::Cover.label(), "Cover of");
792 assert_eq!(EdgeType::Remaster.label(), "Remaster of");
793 assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
794 assert_eq!(EdgeType::Edit.label(), "Edited from");
795 assert_eq!(EdgeType::Extend.label(), "Extended from");
796 assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
797 assert_eq!(EdgeType::Stitch.label(), "Stitched from");
798 assert_eq!(EdgeType::Derived.label(), "Derived from");
799 assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
800 }
801
802 #[test]
803 fn classifies_remaster_cover_edit_and_root_across_chain1() {
804 let clips = chain1_clips();
805
806 assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
807 assert_eq!(
808 immediate_parent(&clips[0]),
809 Some(("52962dae".into(), EdgeType::Remaster))
810 );
811
812 assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
813 assert_eq!(
814 immediate_parent(&clips[1]),
815 Some(("536e1b92".into(), EdgeType::Cover))
816 );
817
818 assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
819 assert_eq!(
820 immediate_parent(&clips[4]),
821 Some(("dfb59a04".into(), EdgeType::Edit))
822 );
823
824 assert_eq!(edge_type(&clips[5]), None);
825 assert_eq!(immediate_parent(&clips[5]), None);
826 }
827
828 #[test]
829 fn classifies_speed_edit_from_speed_pointer_without_edited() {
830 let clip = Clip {
832 id: "6e5193b1".into(),
833 title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
834 clip_type: "edit_speed".into(),
835 is_remix: true,
836 speed_clip_id: "2b69882c".into(),
837 ..Default::default()
838 };
839 assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
840 assert_eq!(
841 immediate_parent(&clip),
842 Some(("2b69882c".into(), EdgeType::SpeedEdit))
843 );
844 }
845
846 #[test]
847 fn empty_task_gen_is_a_root() {
848 let clip = Clip {
850 id: "b4f16694".into(),
851 title: "Go Xavi Go, Fast.".into(),
852 clip_type: "gen".into(),
853 task: String::new(),
854 ..Default::default()
855 };
856 assert_eq!(edge_type(&clip), None);
857 assert_eq!(immediate_parent(&clip), None);
858 }
859
860 #[test]
861 fn classifies_extend_from_history_head() {
862 let clip = Clip {
863 id: "9a3dcb67".into(),
864 title: "Extended".into(),
865 clip_type: "gen".into(),
866 task: "extend".into(),
867 edited_clip_id: "0a3c311a".into(),
868 history: vec![HistoryEntry {
869 id: "0a3c311a".into(),
870 continue_at: Some(115.35),
871 ..Default::default()
872 }],
873 ..Default::default()
874 };
875 assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
876 assert_eq!(
877 immediate_parent(&clip),
878 Some(("0a3c311a".into(), EdgeType::Extend))
879 );
880 }
881
882 #[test]
883 fn classifies_infill_with_override_history_precedence() {
884 let clip = Clip {
886 id: "c0ce5c48".into(),
887 title: "Section replaced".into(),
888 clip_type: "gen".into(),
889 task: "infill".into(),
890 edited_clip_id: "cf37e05f".into(),
891 override_history_clip_id: "d3d28e59".into(),
892 override_future_clip_id: "ea88571e".into(),
893 history: vec![HistoryEntry {
894 id: "cf37e05f".into(),
895 infill: true,
896 infill_start_s: Some(20.4),
897 infill_end_s: Some(24.92),
898 ..Default::default()
899 }],
900 ..Default::default()
901 };
902 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
903 assert_eq!(
904 immediate_parent(&clip),
905 Some(("d3d28e59".into(), EdgeType::SectionReplace))
906 );
907 }
908
909 #[test]
910 fn fixed_infill_is_also_section_replace() {
911 let clip = Clip {
912 task: "fixed_infill".into(),
913 override_history_clip_id: "past".into(),
914 edited_clip_id: "edited".into(),
915 ..Default::default()
916 };
917 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
918 assert_eq!(
919 immediate_parent(&clip),
920 Some(("past".into(), EdgeType::SectionReplace))
921 );
922 }
923
924 #[test]
925 fn classifies_stitch_from_concat_base() {
926 let clip = Clip {
928 id: "43ba1ce3".into(),
929 title: "Stitched".into(),
930 clip_type: "concat".into(),
931 concat_history: vec![
932 HistoryEntry {
933 id: "ead64fbe".into(),
934 continue_at: Some(149.19),
935 ..Default::default()
936 },
937 history("da47b824"),
938 ],
939 ..Default::default()
940 };
941 assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
942 assert_eq!(
943 immediate_parent(&clip),
944 Some(("ead64fbe".into(), EdgeType::Stitch))
945 );
946 }
947
948 #[test]
949 fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
950 let clip = Clip {
955 clip_type: "gen".into(),
956 concat_history: vec![history("base"), history("second")],
957 ..Default::default()
958 };
959 assert_eq!(edge_type(&clip), None);
960 assert_eq!(immediate_parent(&clip), None);
961 }
962
963 #[test]
964 fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
965 let clip = Clip {
969 id: "cov".into(),
970 title: "Cover of a stitch".into(),
971 clip_type: "gen".into(),
972 task: "cover".into(),
973 cover_clip_id: "stitch-parent".into(),
974 edited_clip_id: "stitch-parent".into(),
975 concat_history: vec![history("inherited-base"), history("inherited-seg")],
976 ..Default::default()
977 };
978 assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
979 assert_eq!(
980 immediate_parent(&clip),
981 Some(("stitch-parent".into(), EdgeType::Cover))
982 );
983 }
984
985 #[test]
986 fn upload_is_a_root() {
987 let clip = Clip {
988 id: "4770ef56".into(),
989 title: "Uploaded audio".into(),
990 clip_type: "upload".into(),
991 ..Default::default()
992 };
993 assert_eq!(edge_type(&clip), None);
994 assert_eq!(immediate_parent(&clip), None);
995 }
996
997 #[test]
998 fn edited_only_clip_is_derived() {
999 let clip = Clip {
1001 clip_type: "gen".into(),
1002 task: "chop_sample_condition".into(),
1003 edited_clip_id: "parent-x".into(),
1004 ..Default::default()
1005 };
1006 assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1007 assert_eq!(
1008 immediate_parent(&clip),
1009 Some(("parent-x".into(), EdgeType::Derived))
1010 );
1011 }
1012
1013 #[test]
1014 fn unmarked_clip_without_pointer_is_a_root() {
1015 let clip = Clip {
1016 clip_type: "gen".into(),
1017 task: "chop_sample_condition".into(),
1018 ..Default::default()
1019 };
1020 assert_eq!(edge_type(&clip), None);
1021 assert_eq!(immediate_parent(&clip), None);
1022 }
1023
1024 #[test]
1025 fn is_remix_does_not_change_classification() {
1026 let base = Clip {
1027 clip_type: "gen".into(),
1028 task: "cover".into(),
1029 cover_clip_id: "root-1".into(),
1030 edited_clip_id: "root-1".into(),
1031 ..Default::default()
1032 };
1033 let mut with_flag = base.clone();
1034 with_flag.is_remix = true;
1035 let mut without_flag = base;
1036 without_flag.is_remix = false;
1037
1038 assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1039 assert_eq!(
1040 immediate_parent(&with_flag),
1041 immediate_parent(&without_flag)
1042 );
1043 assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1044 assert_eq!(
1045 immediate_parent(&with_flag),
1046 Some(("root-1".into(), EdgeType::Cover))
1047 );
1048 }
1049
1050 #[test]
1051 fn zero_uuid_cover_falls_back_to_edited() {
1052 let clip = Clip {
1053 clip_type: "gen".into(),
1054 task: "cover".into(),
1055 cover_clip_id: ZERO_UUID.into(),
1056 edited_clip_id: "real-parent".into(),
1057 ..Default::default()
1058 };
1059 assert_eq!(
1060 immediate_parent(&clip),
1061 Some(("real-parent".into(), EdgeType::Cover))
1062 );
1063 }
1064
1065 #[test]
1066 fn m_prefix_is_stripped_from_history_and_concat_ids() {
1067 let extend = Clip {
1068 clip_type: "gen".into(),
1069 task: "extend".into(),
1070 history: vec![history("m_abc123")],
1071 ..Default::default()
1072 };
1073 assert_eq!(
1074 immediate_parent(&extend),
1075 Some(("abc123".into(), EdgeType::Extend))
1076 );
1077
1078 let stitch = Clip {
1079 clip_type: "concat".into(),
1080 concat_history: vec![history("m_base"), history("m_second")],
1081 ..Default::default()
1082 };
1083 let edges = lineage_edges(&stitch);
1084 assert_eq!(edges[0].parent_id, "base");
1085 assert_eq!(edges[1].parent_id, "second");
1086 assert_eq!(edges[1].role, EdgeRole::Secondary);
1087 }
1088
1089 #[test]
1090 fn lineage_edges_of_a_root_is_empty() {
1091 let clip = Clip {
1092 clip_type: "gen".into(),
1093 ..Default::default()
1094 };
1095 assert!(lineage_edges(&clip).is_empty());
1096 }
1097
1098 #[test]
1099 fn lineage_edges_records_stitch_secondaries_in_order() {
1100 let clip = Clip {
1101 clip_type: "concat".into(),
1102 concat_history: vec![history("base"), history("seg1"), history("seg2")],
1103 ..Default::default()
1104 };
1105 let edges = lineage_edges(&clip);
1106 assert_eq!(
1107 edges,
1108 vec![
1109 Edge {
1110 parent_id: "base".into(),
1111 edge_type: EdgeType::Stitch,
1112 role: EdgeRole::Primary,
1113 ordinal: 0,
1114 source_field: "concat_history",
1115 },
1116 Edge {
1117 parent_id: "seg1".into(),
1118 edge_type: EdgeType::Stitch,
1119 role: EdgeRole::Secondary,
1120 ordinal: 1,
1121 source_field: "concat_history",
1122 },
1123 Edge {
1124 parent_id: "seg2".into(),
1125 edge_type: EdgeType::Stitch,
1126 role: EdgeRole::Secondary,
1127 ordinal: 2,
1128 source_field: "concat_history",
1129 },
1130 ]
1131 );
1132 }
1133
1134 #[test]
1135 fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1136 let clip = Clip {
1140 clip_type: "concat".into(),
1141 concat_history: vec![history(""), history("seg1"), history("seg2")],
1142 ..Default::default()
1143 };
1144 let edges = lineage_edges(&clip);
1145 assert_eq!(
1146 edges,
1147 vec![
1148 Edge {
1149 parent_id: "seg1".into(),
1150 edge_type: EdgeType::Stitch,
1151 role: EdgeRole::Secondary,
1152 ordinal: 1,
1153 source_field: "concat_history",
1154 },
1155 Edge {
1156 parent_id: "seg2".into(),
1157 edge_type: EdgeType::Stitch,
1158 role: EdgeRole::Secondary,
1159 ordinal: 2,
1160 source_field: "concat_history",
1161 },
1162 ],
1163 "secondaries survive an empty primary base segment"
1164 );
1165 }
1166
1167 #[test]
1168 fn lineage_edges_records_infill_future_as_secondary() {
1169 let clip = Clip {
1170 task: "infill".into(),
1171 override_history_clip_id: "past".into(),
1172 override_future_clip_id: "future".into(),
1173 ..Default::default()
1174 };
1175 let edges = lineage_edges(&clip);
1176 assert_eq!(edges[0].parent_id, "past");
1177 assert_eq!(edges[0].role, EdgeRole::Primary);
1178 assert_eq!(edges[0].source_field, "override_history_clip_id");
1179 assert_eq!(
1180 edges[1],
1181 Edge {
1182 parent_id: "future".into(),
1183 edge_type: EdgeType::SectionReplace,
1184 role: EdgeRole::Secondary,
1185 ordinal: 1,
1186 source_field: "override_future_clip_id",
1187 }
1188 );
1189 }
1190
1191 #[test]
1192 fn resolve_roots_walks_a_connected_chain_with_no_http() {
1193 let http = ScriptedHttp::new();
1194 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"));
1195 let clips = chain1_clips();
1196
1197 let roots = pollster::block_on(resolve_roots(
1198 &clips,
1199 &mut client,
1200 &http,
1201 ResolveOpts::default(),
1202 ))
1203 .unwrap()
1204 .roots;
1205
1206 assert!(
1207 http.calls().is_empty(),
1208 "a fully-connected chain must never touch the network"
1209 );
1210 assert_eq!(roots.len(), clips.len());
1211 for clip in &clips {
1212 let info = &roots[&clip.id];
1213 assert_eq!(info.status, ResolveStatus::Resolved);
1214 assert_eq!(info.root_id, "dfb59a04");
1215 assert_eq!(info.root_title, "Zac and the Sea Eagles");
1216 }
1217 }
1218
1219 #[test]
1220 fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1221 let cover = Clip {
1222 id: "child".into(),
1223 title: "Cover".into(),
1224 clip_type: "gen".into(),
1225 task: "cover".into(),
1226 cover_clip_id: "root".into(),
1227 edited_clip_id: "root".into(),
1228 ..Default::default()
1229 };
1230 let root_feed = serde_json::json!({
1231 "clips": [{
1232 "id": "root", "title": "Original", "status": "complete",
1233 "metadata": {"type": "gen"}
1234 }]
1235 })
1236 .to_string();
1237 let http = ScriptedHttp::new()
1238 .with_auth()
1239 .route("/api/feed/v2/?ids=", Reply::json(&root_feed));
1240 let mut client = authed_client(&http);
1241
1242 let roots = pollster::block_on(resolve_roots(
1243 &[cover],
1244 &mut client,
1245 &http,
1246 ResolveOpts::default(),
1247 ))
1248 .unwrap()
1249 .roots;
1250
1251 let info = &roots["child"];
1252 assert_eq!(info.status, ResolveStatus::Resolved);
1253 assert_eq!(info.root_id, "root");
1254 assert_eq!(info.root_title, "Original");
1255 assert_eq!(http.count("/api/feed/v2/?ids=root"), 1);
1256 assert_eq!(
1257 http.count("/api/clips/parent"),
1258 0,
1259 "the parent endpoint must not be used when ?ids= succeeds"
1260 );
1261 }
1262
1263 #[test]
1264 fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1265 let cover = Clip {
1269 id: "child".into(),
1270 title: "Cover".into(),
1271 clip_type: "gen".into(),
1272 task: "cover".into(),
1273 cover_clip_id: "root".into(),
1274 edited_clip_id: "root".into(),
1275 ..Default::default()
1276 };
1277 let root_feed = serde_json::json!({
1278 "clips": [{
1279 "id": "root", "title": "Trashed Original", "status": "complete",
1280 "metadata": {"type": "gen"}
1281 }]
1282 })
1283 .to_string();
1284 let http = ScriptedHttp::new()
1285 .with_auth()
1286 .route("/api/feed/v2/?ids=", Reply::json(&root_feed));
1287 let mut client = authed_client(&http);
1288
1289 let resolution = pollster::block_on(resolve_roots(
1290 &[cover],
1291 &mut client,
1292 &http,
1293 ResolveOpts::default(),
1294 ))
1295 .unwrap();
1296
1297 assert_eq!(resolution.gap_filled.len(), 1);
1298 assert_eq!(resolution.gap_filled[0].id, "root");
1299 assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1300 assert_eq!(resolution.roots["child"].root_id, "root");
1301 assert!(
1302 !resolution.roots.contains_key("root"),
1303 "gap-filled ancestors must never enter the roots set"
1304 );
1305 }
1306
1307 #[test]
1308 fn resolve_roots_falls_back_to_the_parent_endpoint() {
1309 let cover = Clip {
1310 id: "child".into(),
1311 title: "Cover".into(),
1312 clip_type: "gen".into(),
1313 task: "cover".into(),
1314 cover_clip_id: "missing".into(),
1315 edited_clip_id: "missing".into(),
1316 ..Default::default()
1317 };
1318 let empty_feed = serde_json::json!({"clips": []}).to_string();
1321 let parent_body = serde_json::json!({
1322 "id": "root", "title": "Original", "status": "complete",
1323 "metadata": {"type": "gen"}
1324 })
1325 .to_string();
1326 let http = ScriptedHttp::new()
1327 .with_auth()
1328 .route("/api/feed/v2/?ids=", Reply::json(&empty_feed))
1329 .route("/api/clips/parent", Reply::json(&parent_body));
1330 let mut client = authed_client(&http);
1331
1332 let roots = pollster::block_on(resolve_roots(
1333 &[cover],
1334 &mut client,
1335 &http,
1336 ResolveOpts::default(),
1337 ))
1338 .unwrap()
1339 .roots;
1340
1341 let info = &roots["child"];
1342 assert_eq!(info.status, ResolveStatus::Resolved);
1343 assert_eq!(info.root_id, "root");
1344 assert_eq!(info.root_title, "Original");
1345 assert!(
1346 http.count("/api/clips/parent?clip_id=missing") >= 1,
1347 "the missing ancestor must be resolved via the parent endpoint"
1348 );
1349 }
1350
1351 #[test]
1352 fn resolve_roots_detects_a_cycle_without_looping() {
1353 let a = Clip {
1354 id: "a".into(),
1355 title: "A".into(),
1356 clip_type: "gen".into(),
1357 task: "cover".into(),
1358 cover_clip_id: "b".into(),
1359 edited_clip_id: "b".into(),
1360 ..Default::default()
1361 };
1362 let b = Clip {
1363 id: "b".into(),
1364 title: "B".into(),
1365 clip_type: "gen".into(),
1366 task: "cover".into(),
1367 cover_clip_id: "a".into(),
1368 edited_clip_id: "a".into(),
1369 ..Default::default()
1370 };
1371 let http = ScriptedHttp::new();
1372 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"));
1373
1374 let roots = pollster::block_on(resolve_roots(
1375 &[a, b],
1376 &mut client,
1377 &http,
1378 ResolveOpts::default(),
1379 ))
1380 .unwrap()
1381 .roots;
1382
1383 assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1384 assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1385 assert!(http.calls().is_empty());
1386 }
1387
1388 #[test]
1389 fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1390 let child = Clip {
1392 id: "child".into(),
1393 title: "Child".into(),
1394 clip_type: "gen".into(),
1395 task: "cover".into(),
1396 cover_clip_id: "m1".into(),
1397 edited_clip_id: "m1".into(),
1398 ..Default::default()
1399 };
1400 let m1_feed = serde_json::json!({
1401 "clips": [{
1402 "id": "m1", "title": "Middle", "status": "complete",
1403 "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1404 }]
1405 })
1406 .to_string();
1407 let http = ScriptedHttp::new()
1408 .with_auth()
1409 .route("/api/feed/v2/?ids=", Reply::json(&m1_feed));
1410 let mut client = authed_client(&http);
1411 let opts = ResolveOpts {
1412 max_gap_fills: 1,
1413 hop_cap: 64,
1414 };
1415
1416 let roots = pollster::block_on(resolve_roots(&[child], &mut client, &http, opts))
1417 .unwrap()
1418 .roots;
1419
1420 let info = &roots["child"];
1421 assert_eq!(info.status, ResolveStatus::External);
1422 assert_eq!(
1423 info.root_id, "m2",
1424 "resolution stops at the first ancestor it could not fetch"
1425 );
1426 assert_eq!(http.count("/api/feed/v2/?ids=m1"), 1);
1427 assert_eq!(
1428 http.count("ids=m2"),
1429 0,
1430 "the gap-fill budget must not be exceeded"
1431 );
1432 }
1433
1434 #[test]
1435 fn resolve_roots_external_root_endpoint_stops_the_walk() {
1436 let cover = Clip {
1439 id: "child".into(),
1440 title: "Cover".into(),
1441 clip_type: "gen".into(),
1442 task: "cover".into(),
1443 cover_clip_id: "outside".into(),
1444 edited_clip_id: "outside".into(),
1445 ..Default::default()
1446 };
1447 let empty_feed = serde_json::json!({"clips": []}).to_string();
1448 let http = ScriptedHttp::new()
1449 .with_auth()
1450 .route("/api/feed/v2/?ids=", Reply::json(&empty_feed))
1451 .route("/api/clips/parent", Reply::status(404));
1452 let mut client = authed_client(&http);
1453
1454 let roots = pollster::block_on(resolve_roots(
1455 &[cover],
1456 &mut client,
1457 &http,
1458 ResolveOpts::default(),
1459 ))
1460 .unwrap()
1461 .roots;
1462
1463 let info = &roots["child"];
1464 assert_eq!(info.status, ResolveStatus::External);
1465 assert_eq!(info.root_id, "outside");
1466 }
1467
1468 fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
1469 Resolution {
1470 roots: roots
1471 .into_iter()
1472 .map(|(id, info)| (id.to_owned(), info))
1473 .collect(),
1474 gap_filled: Vec::new(),
1475 }
1476 }
1477
1478 #[test]
1479 fn context_for_a_root_uses_its_own_id_and_title() {
1480 let root = Clip {
1481 id: "root-1".into(),
1482 title: "Original".into(),
1483 ..Default::default()
1484 };
1485 let resolution = resolution_with(vec![(
1486 "root-1",
1487 RootInfo {
1488 root_id: "root-1".into(),
1489 root_title: "Original".into(),
1490 status: ResolveStatus::Resolved,
1491 },
1492 )]);
1493
1494 let ctx = LineageContext::for_clip(&root, &resolution);
1495 assert_eq!(ctx.root_id, "root-1");
1496 assert_eq!(ctx.root_title, "Original");
1497 assert_eq!(ctx.parent_id, "");
1498 assert_eq!(ctx.edge_type, None);
1499 assert_eq!(ctx.album("Original"), "Original");
1501 }
1502
1503 #[test]
1504 fn context_for_a_remix_carries_root_and_parent() {
1505 let child = Clip {
1506 id: "child-1".into(),
1507 title: "Remix".into(),
1508 clip_type: "gen".into(),
1509 task: "cover".into(),
1510 cover_clip_id: "root-1".into(),
1511 edited_clip_id: "root-1".into(),
1512 ..Default::default()
1513 };
1514 let resolution = resolution_with(vec![(
1515 "child-1",
1516 RootInfo {
1517 root_id: "root-1".into(),
1518 root_title: "Original".into(),
1519 status: ResolveStatus::Resolved,
1520 },
1521 )]);
1522
1523 let ctx = LineageContext::for_clip(&child, &resolution);
1524 assert_eq!(ctx.root_id, "root-1");
1525 assert_eq!(ctx.root_title, "Original");
1526 assert_eq!(ctx.parent_id, "root-1");
1527 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1528 assert_eq!(ctx.album("Remix"), "Original");
1530 }
1531
1532 #[test]
1533 fn context_absent_from_resolution_is_its_own_root() {
1534 let clip = Clip {
1535 id: "lonely".into(),
1536 title: "Solo".into(),
1537 ..Default::default()
1538 };
1539 let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
1540 assert_eq!(ctx.root_id, "lonely");
1541 assert_eq!(ctx.root_title, "Solo");
1542 assert_eq!(ctx.status, ResolveStatus::Resolved);
1543 assert_eq!(ctx.album("Solo"), "Solo");
1544 }
1545
1546 #[test]
1547 fn album_falls_back_to_own_title_when_root_title_is_empty() {
1548 let ctx = LineageContext {
1549 root_id: "outside".into(),
1550 root_title: String::new(),
1551 parent_id: "outside".into(),
1552 edge_type: Some(EdgeType::Cover),
1553 status: ResolveStatus::External,
1554 };
1555 assert_eq!(ctx.album("My Title"), "My Title");
1556 }
1557
1558 #[test]
1559 fn own_root_has_no_parent() {
1560 let clip = Clip {
1561 id: "solo".into(),
1562 title: "Solo".into(),
1563 ..Default::default()
1564 };
1565 let ctx = LineageContext::own_root(&clip);
1566 assert_eq!(ctx.root_id, "solo");
1567 assert_eq!(ctx.parent_id, "");
1568 assert_eq!(ctx.edge_type, None);
1569 }
1570}