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