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