use std::collections::HashMap;
use std::time::Duration;
use crate::auth::ClerkAuth;
use crate::client::SunoClient;
use crate::config::AudioFormat;
use crate::executor::{ExecOptions, ExecOutcome, Ports, execute};
use crate::fs::Filesystem;
use crate::hash::{art_hash, meta_hash};
use crate::lineage::LineageContext;
use crate::manifest::Manifest;
use crate::model::Clip;
use crate::reconcile::{Action, Desired, LocalFile, Plan, SourceMode, SourceStatus, reconcile};
use crate::testutil::{ChaosHttp, MemFs, Outcome, RecordingClock, StubFfmpeg};
#[derive(Clone, Debug)]
pub(super) struct ClipSpec {
pub id: String,
pub title: String,
pub creator: String,
pub tags: String,
pub art: String,
pub format: AudioFormat,
pub modes: Vec<SourceMode>,
pub trashed: bool,
pub private: bool,
}
impl ClipSpec {
pub(super) fn mirror(id: &str, title: &str) -> Self {
Self {
id: id.to_owned(),
title: title.to_owned(),
creator: "Artist".to_owned(),
tags: format!("tag-{id}"),
art: format!("https://cdn1.suno.ai/{id}-art.jpeg"),
format: AudioFormat::Mp3,
modes: vec![SourceMode::Mirror],
trashed: false,
private: false,
}
}
pub(super) fn with_format(mut self, format: AudioFormat) -> Self {
self.format = format;
self
}
pub(super) fn copy_held(mut self) -> Self {
if !self.modes.contains(&SourceMode::Copy) {
self.modes.push(SourceMode::Copy);
}
self
}
pub(super) fn private(mut self) -> Self {
self.private = true;
self
}
pub(super) fn trashed(mut self) -> Self {
self.trashed = true;
self
}
pub(super) fn with_tags(mut self, tags: &str) -> Self {
self.tags = tags.to_owned();
self
}
pub(super) fn with_title(mut self, title: &str) -> Self {
self.title = title.to_owned();
self
}
pub(super) fn with_creator(mut self, creator: &str) -> Self {
self.creator = creator.to_owned();
self
}
}
pub(super) fn ext(format: AudioFormat) -> &'static str {
match format {
AudioFormat::Mp3 => "mp3",
AudioFormat::Flac => "flac",
AudioFormat::Wav => "wav",
}
}
fn slug(title: &str) -> String {
let cleaned: String = title
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if cleaned.is_empty() {
"untitled".to_owned()
} else {
cleaned
}
}
pub(super) fn path_of(spec: &ClipSpec) -> String {
format!(
"{}/{}-{}.{}",
slug(&spec.creator),
slug(&spec.title),
spec.id,
ext(spec.format)
)
}
pub(super) fn clip_of(spec: &ClipSpec) -> Clip {
Clip {
id: spec.id.clone(),
title: spec.title.clone(),
tags: spec.tags.clone(),
display_name: spec.creator.clone(),
audio_url: format!("https://cdn1.suno.ai/{}.mp3", spec.id),
image_large_url: spec.art.clone(),
..Default::default()
}
}
pub(super) fn desired_of(spec: &ClipSpec) -> Desired {
let clip = clip_of(spec);
let lineage = LineageContext::own_root(&clip);
Desired {
path: path_of(spec),
format: spec.format,
meta_hash: meta_hash(&clip, &lineage),
art_hash: art_hash(&clip),
modes: spec.modes.clone(),
trashed: spec.trashed,
private: spec.private,
lineage,
clip,
artifacts: Vec::new(),
}
}
pub(super) fn desired_set(specs: &[ClipSpec]) -> Vec<Desired> {
specs.iter().map(desired_of).collect()
}
fn audio_bytes(id: &str) -> Vec<u8> {
format!("audio-source-for-{id}").into_bytes()
}
fn art_bytes(url: &str) -> Vec<u8> {
format!("art-bytes-for-{url}").into_bytes()
}
fn wav_file_json(id: &str) -> String {
format!(r#"{{"wav_file_url": "https://cdn1.suno.ai/{id}.wav"}}"#)
}
pub(super) fn world(specs: &[ClipSpec]) -> ChaosHttp {
let mut http = ChaosHttp::new()
.with_auth()
.program("/convert_wav/", vec![Outcome::status(200)]);
for spec in specs {
let id = &spec.id;
match spec.format {
AudioFormat::Mp3 => {
http = http.serve(&format!("/{id}.mp3"), audio_bytes(id));
}
AudioFormat::Flac | AudioFormat::Wav => {
http = http
.serve(
&format!("gen/{id}/wav_file"),
wav_file_json(id).into_bytes(),
)
.serve(&format!("/{id}.wav"), audio_bytes(id));
}
}
if !spec.art.is_empty() {
http = http.serve(&spec.art, art_bytes(&spec.art));
}
}
http
}
pub(super) fn clean_mirror() -> Vec<SourceStatus> {
vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: true,
}]
}
pub(super) fn sources_for(specs: &[ClipSpec]) -> Vec<SourceStatus> {
let mut sources = clean_mirror();
if specs.iter().any(|s| s.modes.contains(&SourceMode::Copy)) {
sources.push(SourceStatus {
mode: SourceMode::Copy,
fully_enumerated: true,
});
}
sources
}
pub(super) fn fast_opts() -> ExecOptions {
ExecOptions {
max_retries: 3,
wav_poll_attempts: 3,
wav_poll_interval: Duration::from_secs(5),
concurrency: 4,
}
}
pub(super) fn probe_local(manifest: &Manifest, fs: &MemFs) -> HashMap<String, LocalFile> {
manifest
.iter()
.map(|(id, entry)| {
let local = match fs.metadata(&entry.path) {
Some(stat) => LocalFile {
exists: stat.exists,
size: stat.size,
},
None => LocalFile::default(),
};
(id.clone(), local)
})
.collect()
}
pub(super) fn drive(
plan: &Plan,
manifest: &mut Manifest,
desired: &[Desired],
http: &ChaosHttp,
fs: &MemFs,
opts: &ExecOptions,
) -> ExecOutcome {
let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
let clock = RecordingClock::new();
let ffmpeg = StubFfmpeg::flac();
let mut albums = std::collections::BTreeMap::new();
let mut playlists = std::collections::BTreeMap::new();
pollster::block_on(execute(
plan,
manifest,
&mut albums,
&mut playlists,
desired,
Ports {
client: &mut client,
http,
fs,
ffmpeg: &ffmpeg,
clock: &clock,
},
opts,
))
}
pub(super) fn run_sync(
specs: &[ClipSpec],
sources: &[SourceStatus],
fs: &MemFs,
manifest: &mut Manifest,
http: &ChaosHttp,
opts: &ExecOptions,
) -> (Plan, ExecOutcome) {
let desired = desired_set(specs);
let local = probe_local(manifest, fs);
let plan = reconcile(manifest, &desired, &local, sources);
let outcome = drive(&plan, manifest, &desired, http, fs, opts);
(plan, outcome)
}
pub(super) fn run_clean(
specs: &[ClipSpec],
fs: &MemFs,
manifest: &mut Manifest,
) -> (Plan, ExecOutcome) {
let http = world(specs);
run_sync(
specs,
&sources_for(specs),
fs,
manifest,
&http,
&fast_opts(),
)
}
pub(super) fn mutating_actions(plan: &Plan) -> usize {
plan.actions
.iter()
.filter(|a| !matches!(a, Action::Skip { .. }))
.count()
}