use crate::consts::CDN_BASE_URL;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Clip {
pub id: String,
pub title: String,
pub audio_url: String,
pub media_urls: Vec<MediaUrl>,
pub image_url: String,
pub image_large_url: String,
pub video_url: String,
pub video_cover_url: String,
pub tags: String,
pub duration: f64,
pub play_count: u64,
pub status: String,
pub created_at: String,
pub display_name: String,
pub handle: String,
pub user_id: String,
pub batch_index: Option<i64>,
pub avatar_image_url: String,
pub is_liked: bool,
pub is_trashed: bool,
pub has_vocal: bool,
pub has_stem: bool,
pub stem_from_id: String,
pub stem_task: String,
pub stem_type_id: Option<i64>,
pub stem_type_group_name: String,
pub clip_type: String,
pub prompt: String,
pub gpt_description_prompt: String,
pub lyrics: String,
pub model_name: String,
pub major_model_version: String,
pub edited_clip_id: String,
pub task: String,
pub is_remix: bool,
pub cover_clip_id: String,
pub upsample_clip_id: String,
pub remaster_clip_id: String,
pub speed_clip_id: String,
pub override_history_clip_id: String,
pub override_future_clip_id: String,
pub history: Vec<HistoryEntry>,
pub concat_history: Vec<HistoryEntry>,
pub clip_roots: Vec<ClipRoot>,
pub clip_attribution_type: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ClipRoot {
pub id: String,
pub title: String,
pub image_url: String,
pub is_public: bool,
pub display_name: String,
pub handle: String,
pub avatar_image_url: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MediaUrl {
pub url: String,
pub content_type: String,
pub delivery: String,
pub encoding: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct HistoryEntry {
pub id: String,
pub infill: bool,
pub continue_at: Option<f64>,
pub infill_start_s: Option<f64>,
pub infill_end_s: Option<f64>,
pub infill_lyrics: String,
}
impl Clip {
pub fn mp3_url(&self) -> String {
if let Some(mp3) = self
.media_urls
.iter()
.find(|media| media.content_type == "mp3" && !media.url.is_empty())
{
return cdn_audio_url(&mp3.url, &self.id);
}
if self.audio_url.is_empty() {
format!("{CDN_BASE_URL}/{}.mp3", self.id)
} else {
self.audio_url.clone()
}
}
pub fn cover_candidates(&self) -> Vec<&str> {
[self.image_large_url.as_str(), self.image_url.as_str()]
.into_iter()
.filter(|url| !url.is_empty())
.collect()
}
pub fn selected_image_url(&self) -> Option<&str> {
if !self.image_large_url.is_empty() {
Some(self.image_large_url.as_str())
} else if !self.image_url.is_empty() {
Some(self.image_url.as_str())
} else {
None
}
}
}
pub(crate) fn cdn_audio_url(url: &str, id: &str) -> String {
if url.contains("audiopipe") && !id.is_empty() {
format!("{CDN_BASE_URL}/{id}.mp3")
} else {
url.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
pub id: String,
pub name: String,
pub num_clips: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BillingInfo {
pub total_credits_left: Option<i64>,
pub monthly_limit: Option<i64>,
pub monthly_usage: Option<i64>,
pub credits: Option<i64>,
pub period: Option<String>,
pub period_end: Option<String>,
pub renews_on: Option<String>,
pub is_active: Option<bool>,
pub is_paused: Option<bool>,
pub is_past_due: Option<bool>,
pub is_gifted: Option<bool>,
pub subscription_platform: Option<String>,
pub plan_key: Option<String>,
pub plan_name: Option<String>,
pub plan_level: Option<i64>,
pub features: BTreeSet<String>,
}
impl BillingInfo {
pub fn has_feature(&self, name: &str) -> bool {
self.features.contains(name)
}
pub fn can_get_stems(&self) -> bool {
self.has_feature("get_stems")
}
pub fn can_convert_audio(&self) -> bool {
self.has_feature("convert_audio")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stem {
pub id: String,
pub label: String,
pub url: String,
}
pub(crate) fn stem_label(clip: &Clip) -> String {
let group = clip.stem_type_group_name.replace('_', " ");
let group = group.trim();
if !group.is_empty() {
return group.to_string();
}
stem_label_from_title(&clip.title)
}
fn stem_label_from_title(title: &str) -> String {
let trimmed = title.trim_end();
let Some(before_close) = trimmed.strip_suffix(')') else {
return String::new();
};
match before_close.rfind('(') {
Some(open) => before_close[open + 1..].trim().to_string(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
Clip {
image_large_url: image_large.to_owned(),
image_url: image.to_owned(),
video_cover_url: video_cover.to_owned(),
..Default::default()
}
}
#[test]
fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
let mut clip = Clip {
id: "z".to_owned(),
audio_url: "https://x/real.mp3".to_owned(),
..Default::default()
};
assert_eq!(clip.mp3_url(), "https://x/real.mp3");
clip.audio_url = String::new();
assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
}
#[test]
fn mp3_url_prefers_the_media_urls_mp3_then_audio_url_then_synthesis() {
let clip = Clip {
id: "z".to_owned(),
audio_url: "https://x/real.mp3".to_owned(),
media_urls: vec![
MediaUrl {
url: "https://media/z.m4a".to_owned(),
content_type: "m4a-opus".to_owned(),
delivery: "progressive".to_owned(),
encoding: "1.0.0".to_owned(),
},
MediaUrl {
url: "https://cdn1.suno.ai/z.mp3".to_owned(),
content_type: "mp3".to_owned(),
delivery: "progressive".to_owned(),
encoding: String::new(),
},
],
..Default::default()
};
assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
let no_media = Clip {
id: "z".to_owned(),
audio_url: "https://x/real.mp3".to_owned(),
..Default::default()
};
assert_eq!(no_media.mp3_url(), "https://x/real.mp3");
let only_m4a = Clip {
id: "z".to_owned(),
audio_url: String::new(),
media_urls: vec![MediaUrl {
url: "https://media/z.m4a".to_owned(),
content_type: "m4a-opus".to_owned(),
..Default::default()
}],
..Default::default()
};
assert_eq!(only_m4a.mp3_url(), "https://cdn1.suno.ai/z.mp3");
}
#[test]
fn mp3_url_rewrites_an_expiring_audiopipe_media_url() {
let expiring = Clip {
id: "z".to_owned(),
media_urls: vec![MediaUrl {
url: "https://audiopipe.suno.ai/item?id=z".to_owned(),
content_type: "mp3".to_owned(),
..Default::default()
}],
..Default::default()
};
assert_eq!(expiring.mp3_url(), "https://cdn1.suno.ai/z.mp3");
let permanent = Clip {
id: "z".to_owned(),
media_urls: vec![MediaUrl {
url: "https://cdn1.suno.ai/z.mp3".to_owned(),
content_type: "mp3".to_owned(),
..Default::default()
}],
..Default::default()
};
assert_eq!(permanent.mp3_url(), "https://cdn1.suno.ai/z.mp3");
}
#[test]
fn cover_candidates_are_static_images_ordered_and_filtered() {
assert_eq!(art_clip("L", "I", "V").cover_candidates(), vec!["L", "I"]);
assert_eq!(art_clip("L", "", "V").cover_candidates(), vec!["L"]);
assert!(art_clip("", "", "V").cover_candidates().is_empty());
}
#[test]
fn selected_image_url_prefers_large_then_image_and_excludes_video() {
assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
assert_eq!(art_clip("", "", "V").selected_image_url(), None);
assert_eq!(art_clip("", "", "").selected_image_url(), None);
}
#[test]
fn stem_label_prefers_the_normalised_group_over_the_title() {
let grouped = Clip {
title: "Track 30".to_owned(),
stem_type_group_name: "Backing_Vocals".to_owned(),
..Default::default()
};
assert_eq!(stem_label(&grouped), "Backing Vocals");
let both = Clip {
title: "My Song (Guitar)".to_owned(),
stem_type_group_name: "Vocals".to_owned(),
..Default::default()
};
assert_eq!(stem_label(&both), "Vocals");
let titled = Clip {
title: "My Song (Drums)".to_owned(),
..Default::default()
};
assert_eq!(stem_label(&titled), "Drums");
let bare = Clip {
title: "Track 31".to_owned(),
..Default::default()
};
assert_eq!(stem_label(&bare), "");
}
#[test]
fn stem_label_from_title_extracts_trailing_parenthetical() {
assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
assert_eq!(
stem_label_from_title("A (b) Song (Backing Vocals)"),
"Backing Vocals"
);
assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
assert_eq!(stem_label_from_title("My Song"), "");
assert_eq!(stem_label_from_title(""), "");
}
}