use std::collections::BTreeMap;
use std::collections::HashMap;
use std::time::Duration;
use crate::backoff::{backoff_delay, retry_after};
use crate::client::SunoClient;
use crate::clock::Clock;
use crate::config::AudioFormat;
use crate::error::Error;
use crate::ffmpeg::{Ffmpeg, WebpEncodeSettings};
use crate::fs::Filesystem;
use crate::graph::{AlbumArt, PlaylistState};
use crate::http::{Http, HttpRequest};
use crate::lineage::LineageContext;
use crate::manifest::{ArtifactState, Manifest, ManifestEntry};
use crate::model::Clip;
use crate::reconcile::{Action, ArtifactKind, Desired, Plan, SourceMode, set_manifest_artifact};
use crate::tag::{TrackMetadata, tag_flac, tag_mp3};
#[derive(Debug, Clone)]
pub struct ExecOptions {
pub max_retries: u32,
pub wav_poll_attempts: u32,
pub wav_poll_interval: Duration,
}
impl Default for ExecOptions {
fn default() -> Self {
Self {
max_retries: 3,
wav_poll_attempts: 24,
wav_poll_interval: Duration::from_secs(5),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RunStatus {
#[default]
Completed,
AuthAborted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
pub clip_id: String,
pub reason: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExecOutcome {
pub downloaded: usize,
pub reformatted: usize,
pub retagged: usize,
pub renamed: usize,
pub deleted: usize,
pub skipped: usize,
pub artifacts_written: usize,
pub artifacts_deleted: usize,
pub failures: Vec<Failure>,
pub status: RunStatus,
}
impl ExecOutcome {
pub fn failed(&self) -> usize {
self.failures.len()
}
fn record(&mut self, effect: Effect) {
match effect {
Effect::Downloaded => self.downloaded += 1,
Effect::Reformatted => self.reformatted += 1,
Effect::Retagged => self.retagged += 1,
Effect::Renamed => self.renamed += 1,
Effect::Deleted => self.deleted += 1,
Effect::Skipped => self.skipped += 1,
Effect::ArtifactWritten => self.artifacts_written += 1,
Effect::ArtifactDeleted => self.artifacts_deleted += 1,
}
}
}
pub struct Ports<'a, H, F, G, C> {
pub client: &'a mut SunoClient<C>,
pub http: &'a H,
pub fs: &'a F,
pub ffmpeg: &'a G,
pub clock: &'a C,
}
pub async fn execute<H, F, G, C>(
plan: &Plan,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
playlists: &mut BTreeMap<String, PlaylistState>,
desired: &[Desired],
ports: Ports<'_, H, F, G, C>,
opts: &ExecOptions,
) -> ExecOutcome
where
H: Http,
F: Filesystem,
G: Ffmpeg,
C: Clock,
{
let Ports {
client,
http,
fs,
ffmpeg,
clock,
} = ports;
let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
let by_path: HashMap<&str, &Desired> = desired.iter().map(|d| (d.path.as_str(), d)).collect();
let ctx = Ctx {
http,
fs,
ffmpeg,
clock,
opts,
by_id: &by_id,
by_path: &by_path,
};
let mut outcome = ExecOutcome::default();
for action in &plan.actions {
match ctx.apply(action, client, manifest, albums, playlists).await {
Ok(effect) => outcome.record(effect),
Err(fail) => {
let aborts = matches!(fail.class, Class::Auth);
outcome.failures.push(Failure {
clip_id: fail.clip_id,
reason: fail.reason,
});
if aborts {
outcome.status = RunStatus::AuthAborted;
break;
}
}
}
}
let _ = fs.prune_empty_dirs("");
outcome
}
enum Effect {
Downloaded,
Reformatted,
Retagged,
Renamed,
Deleted,
Skipped,
ArtifactWritten,
ArtifactDeleted,
}
#[derive(Debug, Clone, Copy)]
enum Class {
Auth,
Transient,
Permanent,
}
struct Fail {
class: Class,
clip_id: String,
reason: String,
}
fn auth_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
Fail {
class: Class::Auth,
clip_id: clip_id.into(),
reason: reason.into(),
}
}
fn transient_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
Fail {
class: Class::Transient,
clip_id: clip_id.into(),
reason: reason.into(),
}
}
fn permanent_fail(clip_id: impl Into<String>, reason: impl Into<String>) -> Fail {
Fail {
class: Class::Permanent,
clip_id: clip_id.into(),
reason: reason.into(),
}
}
fn is_album_kind(kind: ArtifactKind) -> bool {
matches!(kind, ArtifactKind::FolderJpg | ArtifactKind::FolderWebp)
}
fn is_playlist_kind(kind: ArtifactKind) -> bool {
matches!(kind, ArtifactKind::Playlist)
}
fn is_per_clip_kind(kind: ArtifactKind) -> bool {
matches!(kind, ArtifactKind::CoverJpg | ArtifactKind::CoverWebp)
}
fn playlist_name_from_path(path: &str) -> String {
std::path::Path::new(path)
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default()
}
struct FetchError {
class: Class,
reason: String,
retry_after: Option<Duration>,
}
impl FetchError {
fn transient(reason: impl Into<String>, retry_after: Option<Duration>) -> Self {
Self {
class: Class::Transient,
reason: reason.into(),
retry_after,
}
}
fn permanent(reason: impl Into<String>) -> Self {
Self {
class: Class::Permanent,
reason: reason.into(),
retry_after: None,
}
}
fn attribute(self, clip_id: &str) -> Fail {
Fail {
class: self.class,
clip_id: clip_id.to_owned(),
reason: self.reason,
}
}
}
struct Ctx<'a, H, F, G, C> {
http: &'a H,
fs: &'a F,
ffmpeg: &'a G,
clock: &'a C,
opts: &'a ExecOptions,
by_id: &'a HashMap<&'a str, &'a Desired>,
by_path: &'a HashMap<&'a str, &'a Desired>,
}
impl<H, F, G, C> Ctx<'_, H, F, G, C>
where
H: Http,
F: Filesystem,
G: Ffmpeg,
C: Clock,
{
async fn apply(
&self,
action: &Action,
client: &mut SunoClient<C>,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
playlists: &mut BTreeMap<String, PlaylistState>,
) -> Result<Effect, Fail> {
match action {
Action::Download {
clip,
lineage,
path,
format,
} => {
self.download(client, manifest, clip, lineage, path, *format)
.await
}
Action::Reformat {
clip,
path,
from_path,
from: _,
to,
} => {
self.reformat(client, manifest, clip, path, from_path, *to)
.await
}
Action::Retag {
clip,
lineage,
path,
} => self.retag(manifest, clip, lineage, path).await,
Action::Rename { from, to } => self.rename(manifest, from, to),
Action::Delete { path, clip_id } => self.delete(manifest, path, clip_id),
Action::Skip { clip_id } => {
self.refresh_preserve(manifest, clip_id);
Ok(Effect::Skipped)
}
Action::WriteArtifact {
kind,
path,
source_url,
hash,
owner_id,
content,
} => {
self.write_artifact(
manifest,
albums,
playlists,
*kind,
path,
source_url,
hash,
owner_id,
content.as_deref(),
)
.await
}
Action::DeleteArtifact {
kind,
path,
owner_id,
} => self.delete_artifact(manifest, albums, playlists, *kind, path, owner_id),
}
}
async fn download(
&self,
client: &mut SunoClient<C>,
manifest: &mut Manifest,
clip: &Clip,
lineage: &LineageContext,
path: &str,
format: AudioFormat,
) -> Result<Effect, Fail> {
let tagged = self.produce_audio(client, clip, lineage, format).await?;
let size = self.write_verify(&clip.id, path, &tagged)?;
manifest.insert(clip.id.clone(), self.entry(&clip.id, path, format, size));
Ok(Effect::Downloaded)
}
async fn reformat(
&self,
client: &mut SunoClient<C>,
manifest: &mut Manifest,
clip: &Clip,
path: &str,
from_path: &str,
to: AudioFormat,
) -> Result<Effect, Fail> {
let lineage = self
.by_id
.get(clip.id.as_str())
.map(|d| d.lineage.clone())
.unwrap_or_else(|| LineageContext::own_root(clip));
let tagged = self.produce_audio(client, clip, &lineage, to).await?;
let size = self.write_verify(&clip.id, path, &tagged)?;
self.fs
.remove(from_path)
.map_err(|err| permanent_fail(&clip.id, format!("could not remove old file: {err}")))?;
manifest.insert(clip.id.clone(), self.entry(&clip.id, path, to, size));
Ok(Effect::Reformatted)
}
async fn retag(
&self,
manifest: &mut Manifest,
clip: &Clip,
lineage: &LineageContext,
path: &str,
) -> Result<Effect, Fail> {
let Some(format) = manifest.get(&clip.id).map(|entry| entry.format) else {
return Err(permanent_fail(
&clip.id,
"retag target missing from manifest",
));
};
if format == AudioFormat::Wav {
self.refresh_hashes(manifest, &clip.id, None);
return Ok(Effect::Retagged);
}
let meta = TrackMetadata::from_clip(clip, lineage);
let cover = self.fetch_cover(clip).await;
let existing = self
.fs
.read(path)
.map_err(|err| permanent_fail(&clip.id, format!("could not read for retag: {err}")))?;
let tagged = match format {
AudioFormat::Mp3 => tag_mp3(&existing, &meta, cover.as_deref()),
AudioFormat::Flac => tag_flac(&existing, &meta, cover.as_deref()),
AudioFormat::Wav => unreachable!("WAV handled above"),
}
.map_err(|err| permanent_fail(&clip.id, err.to_string()))?;
let size = self.write_verify(&clip.id, path, &tagged)?;
self.refresh_hashes(manifest, &clip.id, Some(size));
Ok(Effect::Retagged)
}
fn rename(&self, manifest: &mut Manifest, from: &str, to: &str) -> Result<Effect, Fail> {
let label = self
.by_path
.get(to)
.map(|d| d.clip.id.clone())
.unwrap_or_else(|| to.to_owned());
self.fs
.rename(from, to)
.map_err(|err| permanent_fail(label, format!("rename failed: {err}")))?;
let clip_id = self.by_path.get(to).map(|d| d.clip.id.clone()).or_else(|| {
manifest
.entries
.iter()
.find(|(_, entry)| entry.path == from)
.map(|(id, _)| id.clone())
});
if let Some(id) = clip_id
&& let Some(entry) = manifest.entries.get_mut(&id)
{
entry.path = to.to_owned();
if let Some(d) = self.by_path.get(to) {
entry.preserve = preserve_for(d);
}
}
Ok(Effect::Renamed)
}
fn delete(&self, manifest: &mut Manifest, path: &str, clip_id: &str) -> Result<Effect, Fail> {
self.fs
.remove(path)
.map_err(|err| permanent_fail(clip_id, format!("delete failed: {err}")))?;
manifest.remove(clip_id);
Ok(Effect::Deleted)
}
#[allow(clippy::too_many_arguments)]
async fn write_artifact(
&self,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
playlists: &mut BTreeMap<String, PlaylistState>,
kind: ArtifactKind,
path: &str,
source_url: &str,
hash: &str,
owner_id: &str,
content: Option<&str>,
) -> Result<Effect, Fail> {
if is_per_clip_kind(kind) && manifest.get(owner_id).is_none() {
return Ok(Effect::Skipped);
}
let old_path = match kind {
ArtifactKind::CoverJpg => manifest
.get(owner_id)
.and_then(|e| e.cover_jpg.as_ref())
.map(|s| s.path.clone()),
ArtifactKind::CoverWebp => manifest
.get(owner_id)
.and_then(|e| e.cover_webp.as_ref())
.map(|s| s.path.clone()),
ArtifactKind::FolderJpg | ArtifactKind::FolderWebp => albums
.get(owner_id)
.and_then(|a| a.artifact(kind))
.map(|s| s.path.clone()),
ArtifactKind::Playlist => None,
};
let bytes = match content {
Some(text) => text.as_bytes().to_vec(),
None => self.artifact_bytes(kind, source_url, owner_id).await?,
};
self.write_verify(owner_id, path, &bytes)?;
if let Some(old) = old_path.as_deref()
&& !old.is_empty()
&& old != path
{
self.fs.remove(old).map_err(|err| {
permanent_fail(
owner_id,
format!("could not remove old sidecar {old}: {err}"),
)
})?;
}
if is_album_kind(kind) {
albums.entry(owner_id.to_owned()).or_default().set(
kind,
Some(ArtifactState {
path: path.to_owned(),
hash: hash.to_owned(),
}),
);
} else if is_playlist_kind(kind) {
playlists.insert(
owner_id.to_owned(),
PlaylistState {
name: playlist_name_from_path(path),
path: path.to_owned(),
hash: hash.to_owned(),
},
);
} else if let Some(entry) = manifest.entries.get_mut(owner_id) {
set_manifest_artifact(
entry,
kind,
Some(ArtifactState {
path: path.to_owned(),
hash: hash.to_owned(),
}),
);
}
Ok(Effect::ArtifactWritten)
}
async fn artifact_bytes(
&self,
kind: ArtifactKind,
source_url: &str,
owner_id: &str,
) -> Result<Vec<u8>, Fail> {
let source = self
.fetch_bytes(source_url)
.await
.map_err(|err| err.attribute(owner_id))?;
match kind {
ArtifactKind::CoverWebp | ArtifactKind::FolderWebp => self
.ffmpeg
.mp4_to_webp(&source, WebpEncodeSettings::default())
.await
.map_err(|err| permanent_fail(owner_id, format!("cover transcode failed: {err}"))),
_ => Ok(source),
}
}
fn delete_artifact(
&self,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
playlists: &mut BTreeMap<String, PlaylistState>,
kind: ArtifactKind,
path: &str,
owner_id: &str,
) -> Result<Effect, Fail> {
self.fs
.remove(path)
.map_err(|err| permanent_fail(owner_id, format!("artifact delete failed: {err}")))?;
if is_album_kind(kind) {
if let Some(art) = albums.get_mut(owner_id) {
art.set(kind, None);
if art.is_empty() {
albums.remove(owner_id);
}
}
} else if is_playlist_kind(kind) {
playlists.remove(owner_id);
} else if let Some(entry) = manifest.entries.get_mut(owner_id) {
set_manifest_artifact(entry, kind, None);
}
Ok(Effect::ArtifactDeleted)
}
async fn produce_audio(
&self,
client: &mut SunoClient<C>,
clip: &Clip,
lineage: &LineageContext,
format: AudioFormat,
) -> Result<Vec<u8>, Fail> {
let meta = TrackMetadata::from_clip(clip, lineage);
match format {
AudioFormat::Mp3 => {
let url = clip.mp3_url();
let audio = self
.fetch_bytes(&url)
.await
.map_err(|err| err.attribute(&clip.id))?;
let cover = self.fetch_cover(clip).await;
tag_mp3(&audio, &meta, cover.as_deref())
.map_err(|err| permanent_fail(&clip.id, err.to_string()))
}
AudioFormat::Flac => {
let wav = self.fetch_wav(client, clip).await?;
let flac =
self.ffmpeg.wav_to_flac(&wav).await.map_err(|err| {
permanent_fail(&clip.id, format!("transcode failed: {err}"))
})?;
let cover = self.fetch_cover(clip).await;
tag_flac(&flac, &meta, cover.as_deref())
.map_err(|err| permanent_fail(&clip.id, err.to_string()))
}
AudioFormat::Wav => self.fetch_wav(client, clip).await,
}
}
async fn fetch_wav(&self, client: &mut SunoClient<C>, clip: &Clip) -> Result<Vec<u8>, Fail> {
let url = match self.resolve_wav_url(client, &clip.id).await? {
Some(url) => url,
None => return Err(transient_fail(&clip.id, "WAV render was not ready")),
};
self.fetch_bytes(&url)
.await
.map_err(|err| err.attribute(&clip.id))
}
async fn resolve_wav_url(
&self,
client: &mut SunoClient<C>,
id: &str,
) -> Result<Option<String>, Fail> {
if let Some(url) = self.wav_url_retrying(client, id).await? {
return Ok(Some(url));
}
self.request_wav_retrying(client, id).await?;
for _ in 0..self.opts.wav_poll_attempts {
self.clock.sleep(self.opts.wav_poll_interval).await;
if let Some(url) = self.wav_url_retrying(client, id).await? {
return Ok(Some(url));
}
}
Ok(None)
}
async fn wav_url_retrying(
&self,
client: &mut SunoClient<C>,
id: &str,
) -> Result<Option<String>, Fail> {
let mut attempt: u32 = 0;
loop {
match client.wav_url(self.http, id).await {
Ok(url) => return Ok(url),
Err(err) => match self.retry_core(id, err, &mut attempt).await {
Some(fail) => return Err(fail),
None => continue,
},
}
}
}
async fn request_wav_retrying(&self, client: &mut SunoClient<C>, id: &str) -> Result<(), Fail> {
let mut attempt: u32 = 0;
loop {
match client.request_wav(self.http, id).await {
Ok(()) => return Ok(()),
Err(err) => match self.retry_core(id, err, &mut attempt).await {
Some(fail) => return Err(fail),
None => continue,
},
}
}
}
async fn retry_core(&self, id: &str, err: Error, attempt: &mut u32) -> Option<Fail> {
let fail = classify_core(id, err);
if matches!(fail.class, Class::Transient) && *attempt < self.opts.max_retries {
self.clock.sleep(backoff_delay(*attempt, None)).await;
*attempt += 1;
None
} else {
Some(fail)
}
}
async fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, FetchError> {
let mut attempt: u32 = 0;
loop {
let result = self.http.send(HttpRequest::get(url)).await;
match classify_response(result) {
Ok(body) => return Ok(body),
Err(err) => {
if matches!(err.class, Class::Transient) && attempt < self.opts.max_retries {
let delay = backoff_delay(attempt, err.retry_after);
self.clock.sleep(delay).await;
attempt += 1;
continue;
}
return Err(err);
}
}
}
}
async fn fetch_cover(&self, clip: &Clip) -> Option<Vec<u8>> {
for url in clip.cover_candidates() {
if let Ok(response) = self.http.send(HttpRequest::get(url)).await
&& (200..=299).contains(&response.status)
&& !response.body.is_empty()
{
return Some(response.body);
}
}
None
}
fn write_verify(&self, clip_id: &str, path: &str, bytes: &[u8]) -> Result<u64, Fail> {
self.fs
.write_atomic(path, bytes)
.map_err(|err| permanent_fail(clip_id, format!("write failed: {err}")))?;
match self.fs.metadata(path) {
Some(stat) if stat.size == bytes.len() as u64 => Ok(stat.size),
Some(stat) => Err(permanent_fail(
clip_id,
format!("wrote {} bytes, expected {}", stat.size, bytes.len()),
)),
None => Ok(bytes.len() as u64),
}
}
fn entry(&self, clip_id: &str, path: &str, format: AudioFormat, size: u64) -> ManifestEntry {
match self.by_id.get(clip_id) {
Some(d) => manifest_entry(d, size),
None => ManifestEntry {
path: path.to_owned(),
format,
size,
..ManifestEntry::default()
},
}
}
fn refresh_hashes(&self, manifest: &mut Manifest, clip_id: &str, size: Option<u64>) {
let desired = self.by_id.get(clip_id).copied();
if let Some(entry) = manifest.entries.get_mut(clip_id) {
if let Some(d) = desired {
entry.meta_hash = d.meta_hash.clone();
entry.art_hash = d.art_hash.clone();
entry.preserve = preserve_for(d);
}
if let Some(size) = size {
entry.size = size;
}
}
}
fn refresh_preserve(&self, manifest: &mut Manifest, clip_id: &str) {
if let Some(d) = self.by_id.get(clip_id).copied()
&& let Some(entry) = manifest.entries.get_mut(clip_id)
{
entry.preserve = preserve_for(d);
}
}
}
fn manifest_entry(d: &Desired, size: u64) -> ManifestEntry {
ManifestEntry {
path: d.path.clone(),
format: d.format,
meta_hash: d.meta_hash.clone(),
art_hash: d.art_hash.clone(),
size,
preserve: preserve_for(d),
..Default::default()
}
}
fn preserve_for(d: &Desired) -> bool {
d.private || d.modes.contains(&SourceMode::Copy)
}
fn classify_response(
result: Result<crate::http::HttpResponse, crate::http::TransportError>,
) -> Result<Vec<u8>, FetchError> {
let response = match result {
Ok(response) => response,
Err(err) => {
return Err(FetchError::transient(
format!("transport error: {err}"),
None,
));
}
};
match response.status {
200..=299 => {
if let Some(expected) = content_length(&response) {
let actual = response.body.len() as u64;
if actual != expected {
return Err(FetchError::transient(
format!("truncated download: {actual} of {expected} bytes"),
None,
));
}
}
Ok(response.body)
}
401 | 403 => Err(FetchError::transient(
format!("download rejected: status {}", response.status),
None,
)),
408 => Err(FetchError::transient("request timed out", None)),
429 => Err(FetchError::transient(
"rate limited",
retry_after(&response),
)),
500..=599 => Err(FetchError::transient(
format!("server error {}", response.status),
None,
)),
status => Err(FetchError::permanent(format!(
"download failed: status {status}"
))),
}
}
fn classify_core(id: &str, err: Error) -> Fail {
let reason = err.to_string();
match err {
Error::Auth(_) => auth_fail(id, reason),
Error::RateLimited { .. } | Error::Connection(_) => transient_fail(id, reason),
Error::Api(_) | Error::NotFound(_) | Error::Tag(_) | Error::Config(_) => {
permanent_fail(id, reason)
}
}
}
fn content_length(response: &crate::http::HttpResponse) -> Option<u64> {
response.header("content-length")?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ClerkAuth;
use crate::http::HttpResponse;
use crate::testutil::{MemFs, RecordingClock, Reply, ScriptedHttp, StubFfmpeg};
fn clip(id: &str) -> Clip {
Clip {
id: id.to_owned(),
title: "Song".to_owned(),
audio_url: format!("https://cdn1.suno.ai/{id}.mp3"),
..Default::default()
}
}
fn art_clip(id: &str) -> Clip {
Clip {
image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
image_url: format!("https://art.suno.ai/{id}/small.jpg"),
..clip(id)
}
}
fn ext(format: AudioFormat) -> &'static str {
match format {
AudioFormat::Mp3 => "mp3",
AudioFormat::Flac => "flac",
AudioFormat::Wav => "wav",
}
}
fn desired(clip: Clip, format: AudioFormat) -> Desired {
Desired {
path: format!("{}.{}", clip.id, ext(format)),
lineage: LineageContext::own_root(&clip),
clip,
format,
meta_hash: "m".to_owned(),
art_hash: "art".to_owned(),
modes: vec![SourceMode::Mirror],
trashed: false,
private: false,
artifacts: Vec::new(),
}
}
fn entry(path: &str, format: AudioFormat) -> ManifestEntry {
ManifestEntry {
path: path.to_owned(),
format,
meta_hash: "old".to_owned(),
art_hash: "old-art".to_owned(),
size: 8,
preserve: false,
..Default::default()
}
}
#[allow(clippy::too_many_arguments)]
fn run(
plan: &Plan,
manifest: &mut Manifest,
desired: &[Desired],
http: &ScriptedHttp,
fs: &MemFs,
ffmpeg: &StubFfmpeg,
clock: &RecordingClock,
opts: &ExecOptions,
) -> ExecOutcome {
let mut albums = BTreeMap::new();
run_with_albums(
plan,
manifest,
&mut albums,
desired,
http,
fs,
ffmpeg,
clock,
opts,
)
}
#[allow(clippy::too_many_arguments)]
fn run_with_albums(
plan: &Plan,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
desired: &[Desired],
http: &ScriptedHttp,
fs: &MemFs,
ffmpeg: &StubFfmpeg,
clock: &RecordingClock,
opts: &ExecOptions,
) -> ExecOutcome {
let mut playlists = BTreeMap::new();
run_full(
plan,
manifest,
albums,
&mut playlists,
desired,
http,
fs,
ffmpeg,
clock,
opts,
)
}
#[allow(clippy::too_many_arguments)]
fn run_full(
plan: &Plan,
manifest: &mut Manifest,
albums: &mut BTreeMap<String, AlbumArt>,
playlists: &mut BTreeMap<String, PlaylistState>,
desired: &[Desired],
http: &ScriptedHttp,
fs: &MemFs,
ffmpeg: &StubFfmpeg,
clock: &RecordingClock,
opts: &ExecOptions,
) -> ExecOutcome {
let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
pollster::block_on(execute(
plan,
manifest,
albums,
playlists,
desired,
Ports {
client: &mut client,
http,
fs,
ffmpeg,
clock,
},
opts,
))
}
fn small_poll() -> ExecOptions {
ExecOptions {
max_retries: 3,
wav_poll_attempts: 2,
wav_poll_interval: Duration::from_secs(5),
}
}
#[test]
fn download_mp3_writes_tagged_file_and_records_manifest() {
let c = art_clip("a");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new()
.route("a.mp3", Reply::ok(b"mp3-body".to_vec()))
.route("a/large.jpg", Reply::ok(b"art-bytes".to_vec()));
let fs = MemFs::new();
let ffmpeg = StubFfmpeg::flac();
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&ffmpeg,
&clock,
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(outcome.status, RunStatus::Completed);
let written = fs.read_file("a.mp3").unwrap();
assert_eq!(&written[..3], b"ID3");
assert!(written.ends_with(b"mp3-body"));
let entry = manifest.get("a").unwrap();
assert_eq!(entry.path, "a.mp3");
assert_eq!(entry.format, AudioFormat::Mp3);
assert_eq!(entry.meta_hash, "m");
assert_eq!(entry.art_hash, "art");
assert_eq!(entry.size, written.len() as u64);
assert!(!entry.preserve);
}
#[test]
fn download_mp3_uses_cdn_fallback_when_audio_url_empty() {
let mut c = clip("a");
c.audio_url = String::new();
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route("cdn1.suno.ai/a.mp3", Reply::ok(b"body".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(http.count("cdn1.suno.ai/a.mp3"), 1);
}
#[test]
fn download_flac_renders_transcodes_and_records() {
let c = clip("b");
let d = desired(c.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Flac,
}],
};
let http = ScriptedHttp::new()
.with_auth()
.route(
"/wav_file/",
Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/b.wav"}"#),
)
.route("b.wav", Reply::ok(b"wav-bytes".to_vec()));
let fs = MemFs::new();
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&clock,
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(outcome.failed(), 0);
let written = fs.read_file("b.flac").unwrap();
assert_eq!(&written[..4], b"fLaC");
assert_eq!(manifest.get("b").unwrap().format, AudioFormat::Flac);
assert_eq!(http.count("/convert_wav/"), 0);
assert!(clock.sleeps().is_empty());
}
#[test]
fn download_flac_requests_render_then_polls_until_ready() {
let c = clip("c");
let d = desired(c.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Flac,
}],
};
let http = ScriptedHttp::new()
.with_auth()
.route_seq(
"/wav_file/",
vec![
Reply::json("{}"),
Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/c.wav"}"#),
],
)
.route("/convert_wav/", Reply::status(200))
.route("c.wav", Reply::ok(b"wav".to_vec()));
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs_new(),
&StubFfmpeg::flac(),
&clock,
&small_poll(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(http.count("/convert_wav/"), 1);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
}
#[test]
fn download_flac_unavailable_render_is_a_nonfatal_failure() {
let c = clip("d");
let d = desired(c.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Flac,
}],
};
let http = ScriptedHttp::new()
.with_auth()
.route("/wav_file/", Reply::json("{}"))
.route("/convert_wav/", Reply::status(200));
let fs = MemFs::new();
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&clock,
&small_poll(),
);
assert_eq!(outcome.downloaded, 0);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "d");
assert_eq!(outcome.status, RunStatus::Completed);
assert!(!fs.exists("d.flac"));
assert_eq!(clock.sleeps().len(), 2);
}
#[test]
fn flac_transcode_failure_is_recorded_and_skipped() {
let c = clip("t");
let d = desired(c.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Flac,
}],
};
let http = ScriptedHttp::new()
.with_auth()
.route(
"/wav_file/",
Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/t.wav"}"#),
)
.route("t.wav", Reply::ok(b"wav".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::failing(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 0);
assert_eq!(outcome.failed(), 1);
assert!(!fs.exists("t.flac"));
assert!(manifest.get("t").is_none());
}
#[test]
fn cover_falls_back_when_large_image_is_missing() {
let c = art_clip("e");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new()
.route("e.mp3", Reply::ok(b"body".to_vec()))
.route("e/large.jpg", Reply::status(404))
.route("e/small.jpg", Reply::ok(b"the-art".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
let calls = http.calls();
let large = calls
.iter()
.position(|u| u.contains("e/large.jpg"))
.unwrap();
let small = calls
.iter()
.position(|u| u.contains("e/small.jpg"))
.unwrap();
assert!(large < small, "large art tried before small");
}
#[test]
fn failed_write_leaves_the_prior_file_intact() {
let c = clip("f");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route("f.mp3", Reply::ok(b"new-body".to_vec()));
let fs = MemFs::new()
.with_file("f.mp3", b"OLD-CONTENT".to_vec())
.fail_write("f.mp3");
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 0);
assert_eq!(outcome.failed(), 1);
assert_eq!(fs.read_file("f.mp3").unwrap(), b"OLD-CONTENT");
assert!(manifest.get("f").is_none());
}
#[test]
fn size_mismatch_after_write_is_a_failure() {
let c = clip("g");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route("g.mp3", Reply::ok(b"body".to_vec()));
let fs = MemFs::new().corrupt_write("g.mp3");
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 0);
assert_eq!(outcome.failed(), 1);
assert!(outcome.failures[0].reason.contains("expected"));
assert!(manifest.get("g").is_none());
}
#[test]
fn transient_failure_is_retried_then_skipped() {
let c = clip("h");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route("h.mp3", Reply::status(500));
let fs = MemFs::new();
let clock = RecordingClock::new();
let opts = ExecOptions {
max_retries: 2,
..ExecOptions::default()
};
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&clock,
&opts,
);
assert_eq!(outcome.downloaded, 0);
assert_eq!(outcome.failed(), 1);
assert_eq!(http.count("h.mp3"), 3);
assert_eq!(clock.sleeps().len(), 2);
}
#[test]
fn truncated_download_is_retried_then_succeeds() {
let c = clip("i");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route_seq(
"i.mp3",
vec![
Reply::ok(b"short".to_vec()).with_content_length(999),
Reply::ok(b"good-body".to_vec()),
],
);
let fs = MemFs::new();
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&clock,
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(http.count("i.mp3"), 2);
assert_eq!(clock.sleeps().len(), 1);
}
#[test]
fn rate_limit_backs_off_using_retry_after() {
let c = clip("j");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route_seq(
"j.mp3",
vec![
Reply::status(429).with_retry_after(7),
Reply::ok(b"body".to_vec()),
],
);
let fs = MemFs::new();
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&clock,
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
}
#[test]
fn auth_failure_aborts_the_run() {
let c1 = clip("k1");
let c2 = clip("k2");
let d1 = desired(c1.clone(), AudioFormat::Flac);
let d2 = desired(c2.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![
Action::Download {
clip: c1.clone(),
lineage: LineageContext::own_root(&c1),
path: d1.path.clone(),
format: AudioFormat::Flac,
},
Action::Download {
clip: c2.clone(),
lineage: LineageContext::own_root(&c2),
path: d2.path.clone(),
format: AudioFormat::Flac,
},
],
};
let http = ScriptedHttp::new()
.with_auth()
.route("/wav_file/", Reply::status(401));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d1, d2],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&small_poll(),
);
assert_eq!(outcome.status, RunStatus::AuthAborted);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "k1");
assert_eq!(outcome.downloaded, 0);
}
#[test]
fn cdn_download_rejection_skips_the_clip_without_aborting() {
let c1 = clip("k1");
let c2 = clip("k2");
let d1 = desired(c1.clone(), AudioFormat::Mp3);
let d2 = desired(c2.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![
Action::Download {
clip: c1.clone(),
lineage: LineageContext::own_root(&c1),
path: d1.path.clone(),
format: AudioFormat::Mp3,
},
Action::Download {
clip: c2.clone(),
lineage: LineageContext::own_root(&c2),
path: d2.path.clone(),
format: AudioFormat::Mp3,
},
],
};
let http = ScriptedHttp::new()
.route("k1.mp3", Reply::status(403))
.route("k2.mp3", Reply::ok(b"body".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d1, d2],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_ne!(outcome.status, RunStatus::AuthAborted);
assert_eq!(outcome.downloaded, 1);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "k1");
}
#[test]
fn one_clip_failure_does_not_abort_the_run() {
let c1 = clip("l1");
let c2 = clip("l2");
let d1 = desired(c1.clone(), AudioFormat::Mp3);
let d2 = desired(c2.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![
Action::Download {
clip: c1.clone(),
lineage: LineageContext::own_root(&c1),
path: d1.path.clone(),
format: AudioFormat::Mp3,
},
Action::Download {
clip: c2.clone(),
lineage: LineageContext::own_root(&c2),
path: d2.path.clone(),
format: AudioFormat::Mp3,
},
],
};
let http = ScriptedHttp::new()
.route("l1.mp3", Reply::status(404))
.route("l2.mp3", Reply::ok(b"body".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d1, d2],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(outcome.downloaded, 1);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "l1");
assert!(fs.exists("l2.mp3"));
assert!(manifest.get("l2").is_some());
assert!(manifest.get("l1").is_none());
}
#[test]
fn preserve_is_set_for_copy_held_and_private_clips() {
let mut mirror = desired(clip("m1"), AudioFormat::Mp3);
mirror.modes = vec![SourceMode::Mirror];
let mut copy_held = desired(clip("m2"), AudioFormat::Mp3);
copy_held.modes = vec![SourceMode::Mirror, SourceMode::Copy];
let mut private = desired(clip("m3"), AudioFormat::Mp3);
private.private = true;
let plan = Plan {
actions: vec![
Action::Download {
clip: mirror.clip.clone(),
lineage: LineageContext::own_root(&mirror.clip),
path: mirror.path.clone(),
format: AudioFormat::Mp3,
},
Action::Download {
clip: copy_held.clip.clone(),
lineage: LineageContext::own_root(©_held.clip),
path: copy_held.path.clone(),
format: AudioFormat::Mp3,
},
Action::Download {
clip: private.clip.clone(),
lineage: LineageContext::own_root(&private.clip),
path: private.path.clone(),
format: AudioFormat::Mp3,
},
],
};
let http = ScriptedHttp::new()
.route("m1.mp3", Reply::ok(b"a".to_vec()))
.route("m2.mp3", Reply::ok(b"b".to_vec()))
.route("m3.mp3", Reply::ok(b"c".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[mirror, copy_held, private],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.downloaded, 3);
assert!(!manifest.get("m1").unwrap().preserve);
assert!(manifest.get("m2").unwrap().preserve);
assert!(manifest.get("m3").unwrap().preserve);
}
#[test]
fn reformat_writes_new_format_and_removes_old_file() {
let c = clip("n");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Reformat {
clip: c.clone(),
path: "n.mp3".to_owned(),
from_path: "n.flac".to_owned(),
from: AudioFormat::Flac,
to: AudioFormat::Mp3,
}],
};
let http = ScriptedHttp::new().route("n.mp3", Reply::ok(b"body".to_vec()));
let fs = MemFs::new().with_file("n.flac", b"OLD-FLAC".to_vec());
let mut manifest = Manifest::new();
manifest.insert("n", entry("n.flac", AudioFormat::Flac));
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.reformatted, 1);
assert!(fs.exists("n.mp3"));
assert!(!fs.exists("n.flac"));
let updated = manifest.get("n").unwrap();
assert_eq!(updated.path, "n.mp3");
assert_eq!(updated.format, AudioFormat::Mp3);
assert_eq!(updated.meta_hash, "m");
}
#[test]
fn retag_rewrites_file_and_updates_hashes() {
let c = clip("o");
let mut d = desired(c.clone(), AudioFormat::Mp3);
d.meta_hash = "new".to_owned();
d.art_hash = "new-art".to_owned();
let existing = tag_mp3(
b"audio",
&TrackMetadata::from_clip(&c, &LineageContext::own_root(&c)),
None,
)
.unwrap();
let fs = MemFs::new().with_file("o.mp3", existing.clone());
let mut manifest = Manifest::new();
let mut start = entry("o.mp3", AudioFormat::Mp3);
start.size = existing.len() as u64;
manifest.insert("o", start);
let plan = Plan {
actions: vec![Action::Retag {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: "o.mp3".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[d],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.retagged, 1);
let updated = manifest.get("o").unwrap();
assert_eq!(updated.meta_hash, "new");
assert_eq!(updated.art_hash, "new-art");
assert_eq!(&fs.read_file("o.mp3").unwrap()[..3], b"ID3");
}
#[test]
fn rename_moves_file_and_updates_manifest_path() {
let c = clip("p");
let mut d = desired(c.clone(), AudioFormat::Mp3);
d.path = "new/p.mp3".to_owned();
let fs = MemFs::new().with_file("old/p.mp3", b"DATA".to_vec());
let mut manifest = Manifest::new();
manifest.insert("p", entry("old/p.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![Action::Rename {
from: "old/p.mp3".to_owned(),
to: "new/p.mp3".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[d],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.renamed, 1);
assert!(fs.exists("new/p.mp3"));
assert!(!fs.exists("old/p.mp3"));
assert_eq!(manifest.get("p").unwrap().path, "new/p.mp3");
}
#[test]
fn delete_removes_file_and_manifest_entry() {
let fs = MemFs::new().with_file("q.mp3", b"DATA".to_vec());
let mut manifest = Manifest::new();
manifest.insert("q", entry("q.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![Action::Delete {
path: "q.mp3".to_owned(),
clip_id: "q".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.deleted, 1);
assert!(!fs.exists("q.mp3"));
assert!(manifest.get("q").is_none());
}
#[test]
fn failed_delete_keeps_the_manifest_entry() {
let fs = MemFs::new()
.with_file("s.mp3", b"DATA".to_vec())
.fail_remove("s.mp3");
let mut manifest = Manifest::new();
manifest.insert("s", entry("s.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![Action::Delete {
path: "s.mp3".to_owned(),
clip_id: "s".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.deleted, 0);
assert_eq!(outcome.failed(), 1);
assert!(manifest.get("s").is_some());
assert!(fs.exists("s.mp3"));
}
#[test]
fn skip_is_a_noop() {
let mut manifest = Manifest::new();
let plan = Plan {
actions: vec![Action::Skip {
clip_id: "r".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&MemFs::new(),
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.skipped, 1);
assert_eq!(outcome.failed(), 0);
}
#[test]
fn header_helpers_parse_or_ignore() {
let resp = HttpResponse {
status: 200,
headers: vec![("Content-Length".to_owned(), "42".to_owned())],
body: Vec::new(),
};
assert_eq!(content_length(&resp), Some(42));
let bare = HttpResponse {
status: 200,
headers: Vec::new(),
body: Vec::new(),
};
assert_eq!(content_length(&bare), None);
}
#[test]
fn preserve_rule_covers_copy_and_private() {
let base = desired(clip("x"), AudioFormat::Mp3);
assert!(!preserve_for(&base));
let mut copy_held = base.clone();
copy_held.modes = vec![SourceMode::Copy];
assert!(preserve_for(©_held));
let mut private = base.clone();
private.private = true;
assert!(preserve_for(&private));
}
fn fs_new() -> MemFs {
MemFs::new()
}
#[test]
fn skip_sets_preserve_when_a_clip_becomes_copy_held() {
let c = clip("s1");
let mut d = desired(c.clone(), AudioFormat::Mp3);
d.modes = vec![SourceMode::Copy];
let plan = Plan {
actions: vec![Action::Skip {
clip_id: "s1".to_owned(),
}],
};
let mut manifest = Manifest::new();
manifest.insert("s1".to_owned(), entry("s1.mp3", AudioFormat::Mp3));
assert!(!manifest.get("s1").unwrap().preserve);
let outcome = run(
&plan,
&mut manifest,
&[d],
&ScriptedHttp::new(),
&fs_new(),
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.skipped, 1);
assert!(
manifest.get("s1").unwrap().preserve,
"a copy-held skip must mark the entry preserved"
);
}
#[test]
fn skip_clears_stale_preserve_when_a_clip_returns_to_mirror_only() {
let c = clip("s2");
let d = desired(c.clone(), AudioFormat::Mp3);
let plan = Plan {
actions: vec![Action::Skip {
clip_id: "s2".to_owned(),
}],
};
let mut manifest = Manifest::new();
let mut stale = entry("s2.mp3", AudioFormat::Mp3);
stale.preserve = true;
manifest.insert("s2".to_owned(), stale);
run(
&plan,
&mut manifest,
&[d],
&ScriptedHttp::new(),
&fs_new(),
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert!(
!manifest.get("s2").unwrap().preserve,
"a mirror-only skip must clear a stale preserve marker"
);
}
#[test]
fn flac_render_retries_a_rate_limited_wav_lookup() {
let c = clip("rl");
let d = desired(c.clone(), AudioFormat::Flac);
let plan = Plan {
actions: vec![Action::Download {
clip: c.clone(),
lineage: LineageContext::own_root(&c),
path: d.path.clone(),
format: AudioFormat::Flac,
}],
};
let http = ScriptedHttp::new()
.with_auth()
.route_seq(
"/wav_file/",
vec![
Reply::status(429),
Reply::json(r#"{"wav_file_url": "https://cdn1.suno.ai/rl.wav"}"#),
],
)
.route("rl.wav", Reply::ok(b"wav".to_vec()));
let clock = RecordingClock::new();
let mut manifest = Manifest::new();
let outcome = run(
&plan,
&mut manifest,
&[d],
&http,
&fs_new(),
&StubFfmpeg::flac(),
&clock,
&small_poll(),
);
assert_eq!(outcome.downloaded, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(http.count("/convert_wav/"), 0);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
}
#[test]
fn write_artifact_fetches_writes_and_updates_manifest() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "a".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"jpg-bytes".to_vec()));
let fs = MemFs::new();
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(fs.read_file("a/cover.jpg").unwrap(), b"jpg-bytes");
assert_eq!(
manifest.get("a").unwrap().cover_jpg,
Some(ArtifactState {
path: "a/cover.jpg".to_owned(),
hash: "h1".to_owned(),
})
);
}
#[test]
fn delete_artifact_removes_file_and_clears_slot() {
let fs = MemFs::new().with_file("a/cover.jpg", b"jpg".to_vec());
let mut manifest = Manifest::new();
let mut e = entry("a.mp3", AudioFormat::Mp3);
e.cover_jpg = Some(ArtifactState {
path: "a/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("a", e);
let plan = Plan {
actions: vec![Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
owner_id: "a".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_deleted, 1);
assert!(!fs.exists("a/cover.jpg"));
assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
}
#[test]
fn delete_artifact_tolerates_already_absent_file() {
let mut manifest = Manifest::new();
let mut e = entry("a.mp3", AudioFormat::Mp3);
e.cover_jpg = Some(ArtifactState {
path: "a/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("a", e);
let plan = Plan {
actions: vec![Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
owner_id: "a".to_owned(),
}],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&MemFs::new(),
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_deleted, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
}
#[test]
fn write_artifact_http_failure_is_a_per_clip_failure_not_a_run_abort() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "a".to_owned(),
content: None,
},
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "b/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
hash: "h2".to_owned(),
owner_id: "b".to_owned(),
content: None,
},
],
};
let http = ScriptedHttp::new()
.route("a/large.jpg", Reply::status(404))
.route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
let fs = MemFs::new();
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "a");
assert_eq!(outcome.artifacts_written, 1);
assert!(!fs.exists("a/cover.jpg"));
assert_eq!(manifest.get("a").unwrap().cover_jpg, None);
assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
assert!(manifest.get("b").unwrap().cover_jpg.is_some());
}
#[test]
fn co_delete_executes_audio_delete_then_artifact_delete() {
let fs = MemFs::new()
.with_file("gone.mp3", b"DATA".to_vec())
.with_file("gone/cover.jpg", b"jpg".to_vec());
let mut manifest = Manifest::new();
let mut e = entry("gone.mp3", AudioFormat::Mp3);
e.cover_jpg = Some(ArtifactState {
path: "gone/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("gone", e);
let plan = Plan {
actions: vec![
Action::Delete {
path: "gone.mp3".to_owned(),
clip_id: "gone".to_owned(),
},
Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "gone/cover.jpg".to_owned(),
owner_id: "gone".to_owned(),
},
],
};
let outcome = run(
&plan,
&mut manifest,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.deleted, 1);
assert_eq!(outcome.artifacts_deleted, 1);
assert_eq!(outcome.failed(), 0);
assert!(!fs.exists("gone.mp3"));
assert!(!fs.exists("gone/cover.jpg"));
assert!(manifest.get("gone").is_none());
}
#[test]
fn write_artifact_is_skipped_when_the_owner_audio_is_absent() {
let ca = clip("a");
let plan = Plan {
actions: vec![
Action::Download {
clip: ca.clone(),
lineage: LineageContext::own_root(&ca),
path: "a.mp3".to_owned(),
format: AudioFormat::Mp3,
},
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "a".to_owned(),
content: None,
},
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "b/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
hash: "h2".to_owned(),
owner_id: "b".to_owned(),
content: None,
},
],
};
let http = ScriptedHttp::new()
.route("a.mp3", Reply::status(404))
.route("a/large.jpg", Reply::ok(b"jpg-a".to_vec()))
.route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
let fs = MemFs::new();
let mut manifest = Manifest::new();
manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "a");
assert_eq!(outcome.skipped, 1);
assert_eq!(http.count("a/large.jpg"), 0);
assert!(!fs.exists("a/cover.jpg"));
assert!(manifest.get("a").is_none());
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
assert!(manifest.get("b").unwrap().cover_jpg.is_some());
}
#[test]
fn write_artifact_transcodes_animated_cover_to_webp() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::CoverWebp,
path: "a/cover.webp".to_owned(),
source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
hash: "v1".to_owned(),
owner_id: "a".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
let fs = MemFs::new();
let ffmpeg = StubFfmpeg::webp();
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&ffmpeg,
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(http.count("a/video.mp4"), 1);
let written = fs.read_file("a/cover.webp").unwrap();
assert_ne!(written, b"mp4-bytes");
assert!(written.starts_with(b"RIFF"));
assert_eq!(
manifest.get("a").unwrap().cover_webp,
Some(ArtifactState {
path: "a/cover.webp".to_owned(),
hash: "v1".to_owned(),
})
);
}
#[test]
fn write_artifact_webp_transcode_failure_is_per_clip() {
let mut manifest = Manifest::new();
manifest.insert("a", entry("a.mp3", AudioFormat::Mp3));
manifest.insert("b", entry("b.mp3", AudioFormat::Mp3));
let plan = Plan {
actions: vec![
Action::WriteArtifact {
kind: ArtifactKind::CoverWebp,
path: "a/cover.webp".to_owned(),
source_url: "https://cdn.suno.ai/a/video.mp4".to_owned(),
hash: "v1".to_owned(),
owner_id: "a".to_owned(),
content: None,
},
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "b/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/b/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "b".to_owned(),
content: None,
},
],
};
let http = ScriptedHttp::new()
.route("a/video.mp4", Reply::ok(b"mp4-bytes".to_vec()))
.route("b/large.jpg", Reply::ok(b"jpg-b".to_vec()));
let fs = MemFs::new();
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::failing(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(outcome.failed(), 1);
assert_eq!(outcome.failures[0].clip_id, "a");
assert!(!fs.exists("a/cover.webp"));
assert_eq!(manifest.get("a").unwrap().cover_webp, None);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(fs.read_file("b/cover.jpg").unwrap(), b"jpg-b");
assert!(manifest.get("b").unwrap().cover_jpg.is_some());
}
#[test]
fn folder_jpg_write_records_album_state_and_skips_manifest() {
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::FolderJpg,
path: "creator/album/folder.jpg".to_owned(),
source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
hash: "jh".to_owned(),
owner_id: "root".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"folder-jpg".to_vec()));
let fs = MemFs::new();
let outcome = run_with_albums(
&plan,
&mut manifest,
&mut albums,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(outcome.status, RunStatus::Completed);
assert_eq!(
fs.read_file("creator/album/folder.jpg").unwrap(),
b"folder-jpg"
);
assert_eq!(
albums.get("root").unwrap().folder_jpg,
Some(ArtifactState {
path: "creator/album/folder.jpg".to_owned(),
hash: "jh".to_owned(),
})
);
assert!(manifest.get("root").is_none());
}
#[test]
fn folder_webp_write_transcodes_and_records_album_state() {
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::FolderWebp,
path: "creator/album/cover.webp".to_owned(),
source_url: "https://cdn.suno.ai/root/video.mp4".to_owned(),
hash: "wh".to_owned(),
owner_id: "root".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("root/video.mp4", Reply::ok(b"mp4-bytes".to_vec()));
let fs = MemFs::new();
let outcome = run_with_albums(
&plan,
&mut manifest,
&mut albums,
&[],
&http,
&fs,
&StubFfmpeg::webp(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(outcome.failed(), 0);
let written = fs.read_file("creator/album/cover.webp").unwrap();
assert_ne!(written, b"mp4-bytes");
assert!(written.starts_with(b"RIFF"));
assert_eq!(
albums.get("root").unwrap().folder_webp,
Some(ArtifactState {
path: "creator/album/cover.webp".to_owned(),
hash: "wh".to_owned(),
})
);
}
#[test]
fn folder_art_delete_clears_album_state() {
let fs = MemFs::new().with_file("creator/album/folder.jpg", b"jpg".to_vec());
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
albums.insert(
"root".to_owned(),
AlbumArt {
folder_jpg: Some(ArtifactState {
path: "creator/album/folder.jpg".to_owned(),
hash: "jh".to_owned(),
}),
folder_webp: None,
},
);
let plan = Plan {
actions: vec![Action::DeleteArtifact {
kind: ArtifactKind::FolderJpg,
path: "creator/album/folder.jpg".to_owned(),
owner_id: "root".to_owned(),
}],
};
let outcome = run_with_albums(
&plan,
&mut manifest,
&mut albums,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_deleted, 1);
assert!(!fs.exists("creator/album/folder.jpg"));
assert!(!albums.contains_key("root"));
}
#[test]
fn playlist_write_uses_inline_content_and_records_state() {
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
let body = "#EXTM3U\n#PLAYLIST:Road Trip\n#EXTINF:60,One\nA/One.flac\n";
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: "Road Trip.m3u8".to_owned(),
source_url: String::new(),
hash: "ph1".to_owned(),
owner_id: "pl1".to_owned(),
content: Some(body.to_owned()),
}],
};
let fs = MemFs::new();
let outcome = run_full(
&plan,
&mut manifest,
&mut albums,
&mut playlists,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(outcome.failed(), 0);
assert_eq!(fs.read_file("Road Trip.m3u8").unwrap(), body.as_bytes());
assert_eq!(
playlists.get("pl1"),
Some(&PlaylistState {
name: "Road Trip".to_owned(),
path: "Road Trip.m3u8".to_owned(),
hash: "ph1".to_owned(),
})
);
}
#[test]
fn playlist_delete_removes_file_and_clears_state() {
let fs = MemFs::new().with_file("Old.m3u8", b"#EXTM3U\n".to_vec());
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
let mut playlists: BTreeMap<String, PlaylistState> = BTreeMap::new();
playlists.insert(
"pl1".to_owned(),
PlaylistState {
name: "Old".to_owned(),
path: "Old.m3u8".to_owned(),
hash: "ph1".to_owned(),
},
);
let plan = Plan {
actions: vec![Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Old.m3u8".to_owned(),
owner_id: "pl1".to_owned(),
}],
};
let outcome = run_full(
&plan,
&mut manifest,
&mut albums,
&mut playlists,
&[],
&ScriptedHttp::new(),
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.artifacts_deleted, 1);
assert!(!fs.exists("Old.m3u8"));
assert!(
!playlists.contains_key("pl1"),
"the playlist row is cleared on delete"
);
}
#[test]
fn rename_move_relocates_cover_and_prunes_old_album() {
let mut manifest = Manifest::new();
let mut e = entry("Creator/AlbumA/song.flac", AudioFormat::Flac);
e.cover_jpg = Some(ArtifactState {
path: "Creator/AlbumA/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("a", e);
let fs = MemFs::new()
.with_file("Creator/AlbumA/song.flac", b"AUDIO".to_vec())
.with_file("Creator/AlbumA/cover.jpg", b"old-jpg".to_vec());
let plan = Plan {
actions: vec![
Action::Rename {
from: "Creator/AlbumA/song.flac".to_owned(),
to: "Creator/AlbumB/song.flac".to_owned(),
},
Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "Creator/AlbumB/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "a".to_owned(),
content: None,
},
],
};
let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new-jpg".to_vec()));
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.failed(), 0);
assert!(fs.exists("Creator/AlbumB/song.flac"));
assert_eq!(
fs.read_file("Creator/AlbumB/cover.jpg").unwrap(),
b"new-jpg"
);
assert!(!fs.exists("Creator/AlbumA/cover.jpg"));
assert!(!fs.exists("Creator/AlbumA/song.flac"));
assert_eq!(
manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
"Creator/AlbumB/cover.jpg"
);
assert!(!fs.has_dir("Creator/AlbumA"));
assert!(fs.has_dir("Creator/AlbumB"));
}
#[test]
fn rename_move_relocates_folder_art_and_prunes_old_album() {
let mut manifest = Manifest::new();
let mut albums: BTreeMap<String, AlbumArt> = BTreeMap::new();
albums.insert(
"root".to_owned(),
AlbumArt {
folder_jpg: Some(ArtifactState {
path: "Creator/AlbumA/folder.jpg".to_owned(),
hash: "jh".to_owned(),
}),
folder_webp: None,
},
);
let fs = MemFs::new().with_file("Creator/AlbumA/folder.jpg", b"old-folder".to_vec());
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::FolderJpg,
path: "Creator/AlbumB/folder.jpg".to_owned(),
source_url: "https://art.suno.ai/root/large.jpg".to_owned(),
hash: "jh".to_owned(),
owner_id: "root".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("root/large.jpg", Reply::ok(b"new-folder".to_vec()));
let outcome = run_with_albums(
&plan,
&mut manifest,
&mut albums,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(outcome.failed(), 0);
assert_eq!(
fs.read_file("Creator/AlbumB/folder.jpg").unwrap(),
b"new-folder"
);
assert!(!fs.exists("Creator/AlbumA/folder.jpg"));
assert_eq!(
albums
.get("root")
.unwrap()
.folder_jpg
.as_ref()
.unwrap()
.path,
"Creator/AlbumB/folder.jpg"
);
assert!(!fs.has_dir("Creator/AlbumA"));
assert!(fs.has_dir("Creator/AlbumB"));
}
#[test]
fn prune_empty_dirs_removes_only_empty_dirs() {
let fs = MemFs::new()
.with_file("keep/full/song.flac", b"x".to_vec())
.with_file("hidden/.suno-manifest.json", b"{}".to_vec())
.with_dir("empty/leaf")
.with_dir("nested/a/b/c");
fs.prune_empty_dirs("").unwrap();
for gone in [
"empty",
"empty/leaf",
"nested",
"nested/a",
"nested/a/b",
"nested/a/b/c",
] {
assert!(!fs.has_dir(gone), "empty dir {gone} should be pruned");
}
assert!(fs.has_dir("keep"));
assert!(fs.has_dir("keep/full"));
assert!(fs.has_dir("hidden"));
assert!(fs.exists("keep/full/song.flac"));
assert!(fs.exists("hidden/.suno-manifest.json"));
}
#[test]
fn prune_empty_dirs_never_removes_the_named_root() {
let fs = MemFs::new().with_dir("empty/leaf");
fs.prune_empty_dirs("empty").unwrap();
assert!(fs.has_dir("empty"), "the named root is never removed");
assert!(!fs.has_dir("empty/leaf"));
}
#[test]
fn old_sidecar_remove_failure_is_per_clip_and_converges_next_run() {
let mut manifest = Manifest::new();
let mut e = entry("a.flac", AudioFormat::Flac);
e.cover_jpg = Some(ArtifactState {
path: "AlbumA/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("a", e);
let fs = MemFs::new()
.with_file("a.flac", b"AUDIO".to_vec())
.with_file("AlbumA/cover.jpg", b"old".to_vec());
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "AlbumB/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h1".to_owned(),
owner_id: "a".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
fs.arm_fail_remove("AlbumA/cover.jpg");
let first = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(
first.status,
RunStatus::Completed,
"a remove failure never aborts the run"
);
assert_eq!(first.failed(), 1);
assert!(fs.exists("AlbumB/cover.jpg"));
assert!(fs.exists("AlbumA/cover.jpg"));
assert_eq!(
manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
"AlbumA/cover.jpg"
);
assert!(fs.has_dir("AlbumA"), "the orphan keeps its directory alive");
fs.disarm_fail_remove("AlbumA/cover.jpg");
let second = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(second.failed(), 0);
assert!(fs.exists("AlbumB/cover.jpg"));
assert!(!fs.exists("AlbumA/cover.jpg"), "no orphan persists");
assert_eq!(
manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().path,
"AlbumB/cover.jpg"
);
assert!(!fs.has_dir("AlbumA"), "the emptied directory is pruned");
}
#[test]
fn same_path_artifact_rewrite_does_no_remove_and_prunes_nothing() {
let mut manifest = Manifest::new();
let mut e = entry("Album/a.mp3", AudioFormat::Mp3);
e.cover_jpg = Some(ArtifactState {
path: "Album/cover.jpg".to_owned(),
hash: "h1".to_owned(),
});
manifest.insert("a", e);
let fs = MemFs::new()
.with_file("Album/a.mp3", b"AUDIO".to_vec())
.with_file("Album/cover.jpg", b"old".to_vec());
fs.arm_fail_remove("Album/cover.jpg");
let plan = Plan {
actions: vec![Action::WriteArtifact {
kind: ArtifactKind::CoverJpg,
path: "Album/cover.jpg".to_owned(),
source_url: "https://art.suno.ai/a/large.jpg".to_owned(),
hash: "h2".to_owned(),
owner_id: "a".to_owned(),
content: None,
}],
};
let http = ScriptedHttp::new().route("a/large.jpg", Reply::ok(b"new".to_vec()));
let outcome = run(
&plan,
&mut manifest,
&[],
&http,
&fs,
&StubFfmpeg::flac(),
&RecordingClock::new(),
&ExecOptions::default(),
);
assert_eq!(
outcome.failed(),
0,
"no remove is attempted, so the armed failure never fires"
);
assert_eq!(outcome.artifacts_written, 1);
assert_eq!(fs.read_file("Album/cover.jpg").unwrap(), b"new");
assert_eq!(
manifest.get("a").unwrap().cover_jpg.as_ref().unwrap().hash,
"h2"
);
assert!(fs.has_dir("Album"));
}
}