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