use std::collections::{BTreeSet, HashMap};
use std::path::{Component, Path};
use suno_core::{
ArtifactKind, AudioFormat, Clip, Desired, DesiredArtifact, ExecOutcome, LineageContext,
M3u8Entry, NamingConfig, NamingRequest, Playlist, PlaylistDesired, RunStatus, SourceMode,
art_hash, art_url_hash, content_hash, meta_hash, render_clip_details, render_clip_lrc,
render_clip_lyrics, render_clip_names, render_m3u8, sanitise_name,
};
const MASS_DELETE_FLOOR: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
Ok = 0,
General = 1,
Usage = 2,
Config = 3,
Auth = 4,
Partial = 5,
Transient = 6,
Safety = 7,
Interrupted = 8,
DiskFull = 9,
}
impl ExitCode {
pub fn code(self) -> i32 {
self as i32
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ArtifactToggles {
pub animated_covers: bool,
pub details: bool,
pub lyrics: bool,
pub lrc: bool,
}
pub fn build_desired(
clips: &[&Clip],
format: AudioFormat,
mode: SourceMode,
contexts: &HashMap<String, LineageContext>,
colliding_albums: &BTreeSet<String>,
toggles: ArtifactToggles,
) -> Vec<Desired> {
let config = NamingConfig::default();
let lineages: Vec<LineageContext> = clips
.iter()
.map(|clip| {
contexts
.get(&clip.id)
.cloned()
.unwrap_or_else(|| LineageContext::own_root(clip))
})
.collect();
let names = {
let requests: Vec<NamingRequest<'_>> = clips
.iter()
.zip(&lineages)
.map(|(clip, lineage)| NamingRequest { clip, lineage })
.collect();
render_clip_names(&requests, &config, colliding_albums)
};
clips
.iter()
.zip(names)
.zip(lineages)
.map(|((clip, name), lineage)| {
let base = rel_to_string(&name.relative_path);
let path = format!("{base}.{format}");
let meta_hash = meta_hash(clip, &lineage);
let artifacts = clip_artifacts(clip, &base, &lineage, toggles);
Desired {
clip: (*clip).clone(),
lineage,
path,
format,
meta_hash,
art_hash: art_hash(clip),
modes: vec![mode],
trashed: false,
private: false,
artifacts,
}
})
.collect()
}
fn clip_artifacts(
clip: &Clip,
base: &str,
lineage: &LineageContext,
toggles: ArtifactToggles,
) -> Vec<DesiredArtifact> {
let mut artifacts = Vec::new();
if let Some(url) = clip.selected_image_url().filter(|u| !u.is_empty()) {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::CoverJpg,
path: format!("{base}.jpg"),
source_url: url.to_owned(),
hash: art_hash(clip),
content: None,
});
}
if toggles.animated_covers && !clip.video_cover_url.is_empty() {
artifacts.push(DesiredArtifact {
kind: ArtifactKind::CoverWebp,
path: format!("{base}.webp"),
source_url: clip.video_cover_url.clone(),
hash: art_url_hash(&clip.video_cover_url),
content: None,
});
}
if toggles.details {
let text = render_clip_details(clip, lineage);
artifacts.push(DesiredArtifact {
kind: ArtifactKind::DetailsTxt,
path: format!("{base}.details.txt"),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
if toggles.lyrics
&& let Some(text) = render_clip_lyrics(clip)
{
artifacts.push(DesiredArtifact {
kind: ArtifactKind::LyricsTxt,
path: format!("{base}.lyrics.txt"),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
if toggles.lrc
&& let Some(text) = render_clip_lrc(clip, lineage)
{
artifacts.push(DesiredArtifact {
kind: ArtifactKind::Lrc,
path: format!("{base}.lrc"),
source_url: String::new(),
hash: content_hash(&text),
content: Some(text),
});
}
artifacts
}
pub const LIKED_PLAYLIST_ID: &str = "liked";
pub struct PlaylistInput<'a> {
pub id: &'a str,
pub name: &'a str,
pub members: &'a [Clip],
}
pub fn build_playlist_desired(
inputs: &[PlaylistInput<'_>],
desired: &[Desired],
) -> Vec<PlaylistDesired> {
let by_id: HashMap<&str, &Desired> = desired.iter().map(|d| (d.clip.id.as_str(), d)).collect();
inputs
.iter()
.map(|input| {
let entries: Vec<M3u8Entry<'_>> = input
.members
.iter()
.map(|member| match by_id.get(member.id.as_str()) {
Some(d) => M3u8Entry {
title: d.clip.title.as_str(),
duration_secs: d.clip.duration,
relative_path: d.path.as_str(),
},
None => M3u8Entry {
title: member.title.as_str(),
duration_secs: member.duration,
relative_path: "",
},
})
.collect();
let content = render_m3u8(input.name, &entries);
let hash = content_hash(&content);
let path = format!("{}.m3u8", sanitise_name(input.name));
PlaylistDesired {
id: input.id.to_owned(),
name: input.name.to_owned(),
path,
content,
hash,
}
})
.collect()
}
fn rel_to_string(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
pub fn fully_enumerated(complete: bool, narrowed: bool) -> bool {
complete && !narrowed
}
pub fn dedup_clips_by_id(clips: Vec<Clip>) -> Vec<Clip> {
let mut seen: BTreeSet<String> = BTreeSet::new();
clips
.into_iter()
.filter(|clip| seen.insert(clip.id.clone()))
.collect()
}
pub fn is_narrowed(limit: Option<usize>, since: Option<&str>) -> bool {
limit.is_some() || since.is_some()
}
pub fn is_scoped(liked: bool, playlists: &[String]) -> bool {
liked || !playlists.is_empty()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlaylistResolveError {
NotFound(String),
Ambiguous(String),
}
impl std::fmt::Display for PlaylistResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlaylistResolveError::NotFound(value) => {
write!(f, "no playlist matches '{value}'")
}
PlaylistResolveError::Ambiguous(value) => {
write!(
f,
"'{value}' matches more than one playlist; use the playlist id instead"
)
}
}
}
}
pub fn resolve_playlist<'a>(
value: &str,
playlists: &'a [Playlist],
) -> std::result::Result<&'a Playlist, PlaylistResolveError> {
if let Some(hit) = playlists.iter().find(|playlist| playlist.id == value) {
return Ok(hit);
}
let exact: Vec<&Playlist> = playlists
.iter()
.filter(|playlist| playlist.name == value)
.collect();
match exact.as_slice() {
[one] => return Ok(one),
[_, _, ..] => return Err(PlaylistResolveError::Ambiguous(value.to_owned())),
[] => {}
}
let ci: Vec<&Playlist> = playlists
.iter()
.filter(|playlist| playlist.name.eq_ignore_ascii_case(value))
.collect();
match ci.as_slice() {
[one] => Ok(one),
[_, _, ..] => Err(PlaylistResolveError::Ambiguous(value.to_owned())),
[] => Err(PlaylistResolveError::NotFound(value.to_owned())),
}
}
pub fn mass_delete_abort(
desired_count: usize,
manifest_len: usize,
delete_count: usize,
min_newest: u32,
explicit_min_newest_zero: bool,
yes: bool,
) -> bool {
if delete_count == 0 || manifest_len == 0 {
return false;
}
if desired_count == 0 {
return !(explicit_min_newest_zero && yes);
}
if min_newest == 0 && yes {
return false;
}
is_large_fraction(delete_count, manifest_len)
}
fn is_large_fraction(delete_count: usize, manifest_len: usize) -> bool {
manifest_len >= MASS_DELETE_FLOOR && delete_count.saturating_mul(2) >= manifest_len
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confirm {
Proceed,
Prompt,
RefuseNonInteractive,
}
pub fn confirm_decision(
is_sync: bool,
delete_count: usize,
yes: bool,
stdin_is_tty: bool,
) -> Confirm {
if !is_sync || delete_count == 0 || yes {
return Confirm::Proceed;
}
if stdin_is_tty {
Confirm::Prompt
} else {
Confirm::RefuseNonInteractive
}
}
pub fn confirmed(answer: &str) -> bool {
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
pub fn run_exit_code(outcome: &ExecOutcome) -> ExitCode {
if outcome.status == RunStatus::DiskFull {
return ExitCode::DiskFull;
}
if outcome.status == RunStatus::AuthAborted {
return ExitCode::Auth;
}
if outcome.failures.is_empty() {
return ExitCode::Ok;
}
let progressed = outcome.downloaded
+ outcome.reformatted
+ outcome.retagged
+ outcome.renamed
+ outcome.deleted
+ outcome.skipped
+ outcome.artifacts_written
+ outcome.artifacts_deleted;
if progressed == 0 {
ExitCode::Transient
} else {
ExitCode::Partial
}
}
#[cfg(test)]
mod tests {
use super::*;
use suno_core::Failure;
fn clip(id: &str, title: &str, handle: &str) -> Clip {
Clip {
id: id.to_owned(),
title: title.to_owned(),
handle: handle.to_owned(),
display_name: handle.to_owned(),
..Default::default()
}
}
fn no_contexts() -> HashMap<String, LineageContext> {
HashMap::new()
}
fn no_collisions() -> BTreeSet<String> {
BTreeSet::new()
}
#[test]
fn build_desired_appends_extension_and_mode() {
let a = clip("id-a", "Song A", "alice");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert_eq!(desired.len(), 1);
assert!(
desired[0].path.ends_with(".flac"),
"path: {}",
desired[0].path
);
assert_eq!(desired[0].format, AudioFormat::Flac);
assert_eq!(desired[0].modes, vec![SourceMode::Mirror]);
assert!(!desired[0].trashed);
assert!(!desired[0].private);
let lineage = LineageContext::own_root(&a);
assert_eq!(desired[0].meta_hash, meta_hash(&a, &lineage));
assert_eq!(desired[0].art_hash, art_hash(&a));
assert_eq!(desired[0].lineage, lineage);
}
#[test]
fn build_desired_uses_supplied_lineage_context() {
let a = clip("child-1", "Remix", "alice");
let clips = [&a];
let lineage = LineageContext {
root_id: "root-1".to_owned(),
root_title: "Original".to_owned(),
parent_id: "root-1".to_owned(),
edge_type: None,
status: suno_core::ResolveStatus::Resolved,
};
let contexts: HashMap<String, LineageContext> =
[(a.id.clone(), lineage.clone())].into_iter().collect();
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&contexts,
&no_collisions(),
ArtifactToggles::default(),
);
assert!(
desired[0].path.contains("/Original/"),
"path: {}",
desired[0].path
);
assert_eq!(desired[0].lineage, lineage);
assert_eq!(desired[0].meta_hash, meta_hash(&a, &lineage));
}
#[test]
fn lineage_is_stable_when_a_later_resolution_fails() {
use suno_core::{LineageStore, Resolution, ResolveStatus, RootInfo};
let root = Clip {
id: "root-break".into(),
title: "Break Through".into(),
clip_type: "gen".into(),
handle: "alice".into(),
display_name: "alice".into(),
..Default::default()
};
let child = Clip {
id: "child-remix".into(),
title: "Remix".into(),
clip_type: "gen".into(),
task: "cover".into(),
cover_clip_id: "root-break".into(),
edited_clip_id: "root-break".into(),
handle: "alice".into(),
display_name: "alice".into(),
..Default::default()
};
let clips = [&root, &child];
let contexts_of = |store: &LineageStore| -> HashMap<String, LineageContext> {
clips
.iter()
.map(|c| (c.id.clone(), store.context_for(c)))
.collect()
};
let mut roots = HashMap::new();
for id in ["root-break", "child-remix"] {
roots.insert(
id.to_owned(),
RootInfo {
root_id: "root-break".into(),
root_title: "Break Through".into(),
status: ResolveStatus::Resolved,
},
);
}
let resolution = Resolution {
roots,
gap_filled: Vec::new(),
};
let mut store = LineageStore::new();
store.update(&[root.clone(), child.clone()], &resolution, "t1");
let cycle1 = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&contexts_of(&store),
&store.colliding_root_titles(),
ArtifactToggles::default(),
);
let child1 = cycle1.iter().find(|d| d.clip.id == "child-remix").unwrap();
assert!(
child1.path.contains("/Break Through/"),
"the remix should folder under its root album, got {}",
child1.path
);
let cycle2 = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&contexts_of(&store),
&store.colliding_root_titles(),
ArtifactToggles::default(),
);
for (a, b) in cycle1.iter().zip(&cycle2) {
assert_eq!(a.path, b.path, "album path drifted for {}", a.clip.id);
assert_eq!(
a.meta_hash, b.meta_hash,
"meta_hash drifted for {}",
a.clip.id
);
}
let own = LineageContext::own_root(&child);
assert_ne!(
meta_hash(&child, &own),
child1.meta_hash,
"own-root fallback must differ from the store-driven hash"
);
}
#[test]
fn build_desired_disambiguates_collisions() {
let a = clip("id-a", "Same", "alice");
let b = clip("id-b", "Same", "alice");
let clips = [&a, &b];
let desired = build_desired(
&clips,
AudioFormat::Mp3,
SourceMode::Copy,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert_ne!(desired[0].path, desired[1].path);
assert!(desired.iter().all(|d| d.path.ends_with(".mp3")));
assert!(desired.iter().all(|d| d.modes == vec![SourceMode::Copy]));
}
#[test]
fn build_desired_uses_forward_slashes() {
let a = clip("id-a", "Song A", "alice");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert!(!desired[0].path.contains('\\'));
assert!(desired[0].path.contains('/'));
}
fn art_clip(id: &str) -> Clip {
Clip {
image_large_url: format!("https://art.suno.ai/{id}/large.jpg"),
..clip(id, "Song", "alice")
}
}
#[test]
fn build_desired_emits_cover_jpg_next_to_audio() {
let a = art_clip("id-a");
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
assert_eq!(desired[0].artifacts.len(), 1);
let jpg = &desired[0].artifacts[0];
assert_eq!(jpg.kind, ArtifactKind::CoverJpg);
assert_eq!(jpg.path, format!("{base}.jpg"));
assert_eq!(jpg.source_url, a.selected_image_url().unwrap());
assert_eq!(jpg.hash, art_hash(&a));
}
#[test]
fn build_desired_omits_cover_jpg_when_art_is_empty() {
let a = clip("id-a", "Song", "alice");
assert!(a.selected_image_url().is_none());
let clips = [&a];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
);
assert!(desired[0].artifacts.is_empty());
}
#[test]
fn build_desired_emits_cover_webp_only_when_animated_and_video_present() {
let with_video = Clip {
video_cover_url: "https://cdn.suno.ai/id-a/video.mp4".to_owned(),
..art_clip("id-a")
};
let clips = [&with_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert_eq!(desired[0].artifacts.len(), 1);
assert_eq!(desired[0].artifacts[0].kind, ArtifactKind::CoverJpg);
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
let webp = desired[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::CoverWebp)
.expect("animated cover expected");
assert_eq!(webp.path, format!("{base}.webp"));
assert_eq!(webp.source_url, with_video.video_cover_url);
assert_eq!(webp.hash, art_url_hash(&with_video.video_cover_url));
let no_video = art_clip("id-b");
let clips = [&no_video];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
animated_covers: true,
..Default::default()
},
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::CoverWebp)
);
}
#[test]
fn build_desired_emits_details_sidecar_only_when_enabled() {
let a = clip("id-a", "Song", "alice");
let clips = [&a];
let off = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::DetailsTxt)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
details: true,
..Default::default()
},
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let details = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::DetailsTxt)
.expect("details sidecar expected");
assert_eq!(details.path, format!("{base}.details.txt"));
assert_eq!(details.source_url, "");
let body = render_clip_details(&a, &LineageContext::own_root(&a));
assert_eq!(details.content.as_deref(), Some(body.as_str()));
assert_eq!(details.hash, content_hash(&body));
}
#[test]
fn build_desired_emits_lyrics_sidecar_only_when_enabled_and_present() {
let with_lyrics = Clip {
lyrics: "la la la".to_owned(),
..clip("id-a", "Song", "alice")
};
let clips = [&with_lyrics];
let off = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::LyricsTxt)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lyrics: true,
..Default::default()
},
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let lyrics = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::LyricsTxt)
.expect("lyrics sidecar expected");
assert_eq!(lyrics.path, format!("{base}.lyrics.txt"));
assert_eq!(lyrics.source_url, "");
assert_eq!(lyrics.content.as_deref(), Some("la la la\n"));
assert_eq!(lyrics.hash, content_hash("la la la\n"));
}
#[test]
fn build_desired_emits_lrc_sidecar_only_when_enabled_and_present() {
let with_lyrics = Clip {
lyrics: "la la la".to_owned(),
..clip("id-a", "Song", "alice")
};
let clips = [&with_lyrics];
let off = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
assert!(
off[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::Lrc)
);
let on = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lrc: true,
..Default::default()
},
);
let base = on[0].path.strip_suffix(".flac").unwrap();
let lrc = on[0]
.artifacts
.iter()
.find(|art| art.kind == ArtifactKind::Lrc)
.expect("lrc sidecar expected");
assert_eq!(lrc.path, format!("{base}.lrc"));
assert_eq!(lrc.source_url, "");
let body = render_clip_lrc(&with_lyrics, &LineageContext::own_root(&with_lyrics)).unwrap();
assert_eq!(lrc.content.as_deref(), Some(body.as_str()));
assert_eq!(lrc.hash, content_hash(&body));
assert!(!body.contains("[00:"));
}
#[test]
fn build_desired_omits_lrc_sidecar_when_clip_has_no_lyrics() {
let no_lyrics = clip("id-a", "Song", "alice");
assert!(no_lyrics.lyrics.is_empty());
let clips = [&no_lyrics];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lrc: true,
..Default::default()
},
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::Lrc)
);
}
#[test]
fn build_desired_omits_lyrics_sidecar_when_clip_has_no_lyrics() {
let no_lyrics = clip("id-a", "Song", "alice");
assert!(no_lyrics.lyrics.is_empty());
let clips = [&no_lyrics];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
lyrics: true,
..Default::default()
},
);
assert!(
desired[0]
.artifacts
.iter()
.all(|art| art.kind != ArtifactKind::LyricsTxt)
);
}
#[test]
fn build_desired_text_sidecars_are_independent() {
let full = Clip {
lyrics: "words".to_owned(),
..art_clip("id-a")
};
let clips = [&full];
let desired = build_desired(
&clips,
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles {
details: true,
lyrics: true,
..Default::default()
},
);
let base = desired[0].path.strip_suffix(".flac").unwrap();
let kinds: BTreeSet<ArtifactKind> = desired[0].artifacts.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ArtifactKind::CoverJpg));
assert!(kinds.contains(&ArtifactKind::DetailsTxt));
assert!(kinds.contains(&ArtifactKind::LyricsTxt));
let path_of = |k: ArtifactKind| {
desired[0]
.artifacts
.iter()
.find(|a| a.kind == k)
.unwrap()
.path
.clone()
};
assert_eq!(
path_of(ArtifactKind::DetailsTxt),
format!("{base}.details.txt")
);
assert_eq!(
path_of(ArtifactKind::LyricsTxt),
format!("{base}.lyrics.txt")
);
}
#[test]
fn fully_enumerated_requires_ok_and_unnarrowed() {
assert!(fully_enumerated(true, false));
assert!(!fully_enumerated(false, false));
assert!(!fully_enumerated(true, true));
assert!(!fully_enumerated(false, true));
}
#[test]
fn truncated_listing_is_never_authoritative_for_deletion() {
assert!(!fully_enumerated(false, false));
}
#[test]
fn is_narrowed_tracks_limit_and_since() {
assert!(!is_narrowed(None, None));
assert!(is_narrowed(Some(5), None));
assert!(is_narrowed(None, Some("7d")));
assert!(is_narrowed(Some(5), Some("7d")));
}
#[test]
fn is_scoped_tracks_liked_and_playlists() {
assert!(!is_scoped(false, &[]));
assert!(is_scoped(true, &[]));
assert!(is_scoped(false, &["set".to_owned()]));
assert!(is_scoped(true, &["set".to_owned()]));
}
#[test]
fn dedup_clips_by_id_keeps_first_occurrence() {
let clips = vec![
Clip {
id: "shared".to_owned(),
title: "Liked copy".to_owned(),
..Default::default()
},
Clip {
id: "only-playlist".to_owned(),
..Default::default()
},
Clip {
id: "shared".to_owned(),
title: "Playlist copy".to_owned(),
..Default::default()
},
];
let deduped = dedup_clips_by_id(clips);
assert_eq!(deduped.len(), 2);
assert_eq!(deduped[0].id, "shared");
assert_eq!(deduped[0].title, "Liked copy");
assert_eq!(deduped[1].id, "only-playlist");
}
#[test]
fn a_scoped_run_is_never_fully_enumerated() {
let scoped = is_scoped(true, &[]);
assert!(scoped);
assert!(!fully_enumerated(true, is_narrowed(None, None) || scoped));
}
fn playlist(id: &str, name: &str) -> Playlist {
Playlist {
id: id.to_owned(),
name: name.to_owned(),
num_clips: 0,
}
}
#[test]
fn resolve_playlist_matches_by_id_first() {
let playlists = vec![playlist("id-1", "Chill"), playlist("id-2", "id-1")];
assert_eq!(resolve_playlist("id-1", &playlists).unwrap().name, "Chill");
}
#[test]
fn resolve_playlist_matches_by_exact_name() {
let playlists = vec![playlist("id-1", "Chill"), playlist("id-2", "Focus")];
assert_eq!(resolve_playlist("Focus", &playlists).unwrap().id, "id-2");
}
#[test]
fn resolve_playlist_matches_case_insensitively() {
let playlists = vec![playlist("id-1", "Chill Beats")];
assert_eq!(
resolve_playlist("chill beats", &playlists).unwrap().id,
"id-1"
);
}
#[test]
fn resolve_playlist_rejects_an_unknown_value() {
let playlists = vec![playlist("id-1", "Chill")];
assert_eq!(
resolve_playlist("missing", &playlists),
Err(PlaylistResolveError::NotFound("missing".to_owned()))
);
}
#[test]
fn resolve_playlist_rejects_an_ambiguous_name() {
let playlists = vec![playlist("id-1", "Mix"), playlist("id-2", "mix")];
assert_eq!(
resolve_playlist("MIX", &playlists),
Err(PlaylistResolveError::Ambiguous("MIX".to_owned()))
);
}
#[test]
fn a_scoped_run_never_deletes_orphans() {
use suno_core::{LocalFile, Manifest, ManifestEntry, SourceMode, SourceStatus, reconcile};
let scoped = is_scoped(false, &["holiday".to_owned()]);
let enumerated = fully_enumerated(true, is_narrowed(None, None) || scoped);
assert!(!enumerated);
let mut manifest = Manifest::new();
for i in 0..5 {
let id = format!("orphan-{i}");
manifest.insert(
&id,
ManifestEntry {
path: format!("{id}.flac"),
format: AudioFormat::Flac,
size: 100,
..Default::default()
},
);
}
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: enumerated,
}];
let local: HashMap<String, LocalFile> = HashMap::new();
let plan = reconcile(&manifest, &[], &local, &sources);
assert_eq!(plan.deletes(), 0);
}
#[test]
fn mass_delete_abort_fires_on_empty_listing() {
assert!(mass_delete_abort(0, 147, 147, 1, false, false));
}
#[test]
fn mass_delete_abort_skips_when_nothing_deleted() {
assert!(!mass_delete_abort(0, 147, 0, 1, false, false));
}
#[test]
fn mass_delete_abort_skips_empty_manifest() {
assert!(!mass_delete_abort(0, 0, 0, 1, false, false));
}
#[test]
fn empty_listing_waiver_requires_explicit_cli_min_newest() {
assert!(mass_delete_abort(0, 147, 147, 0, false, true));
assert!(!mass_delete_abort(0, 147, 147, 0, true, true));
assert!(mass_delete_abort(0, 147, 147, 0, true, false));
}
#[test]
fn large_fraction_waiver_accepts_resolved_min_newest_zero() {
assert!(!mass_delete_abort(2, 10, 5, 0, false, true));
assert!(mass_delete_abort(2, 10, 5, 0, false, false));
assert!(mass_delete_abort(2, 10, 5, 1, false, true));
}
#[test]
fn mass_delete_abort_large_fraction() {
assert!(mass_delete_abort(2, 10, 5, 1, false, false));
assert!(mass_delete_abort(3, 10, 6, 1, false, false));
}
#[test]
fn mass_delete_abort_small_fraction_ok() {
assert!(!mass_delete_abort(98, 100, 2, 1, false, false));
}
#[test]
fn mass_delete_abort_small_library_below_floor() {
assert!(!mass_delete_abort(2, 4, 2, 1, false, false));
assert!(mass_delete_abort(0, 4, 4, 1, false, false));
}
#[test]
fn mass_delete_abort_counts_audio_and_artifact_deletes_together() {
use suno_core::{Action, ArtifactKind, Plan};
let del = |id: &str| Action::Delete {
path: format!("{id}.flac"),
clip_id: id.to_owned(),
};
let del_art = |id: &str| Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: format!("{id}/cover.jpg"),
owner_id: id.to_owned(),
};
let plan = Plan {
actions: vec![
del("a"),
del("b"),
del("c"),
del_art("a"),
del_art("b"),
del_art("c"),
],
};
let delete_count = plan.deletes() + plan.artifact_deletes();
assert_eq!(delete_count, 6);
assert!(mass_delete_abort(7, 10, delete_count, 1, false, false));
assert_eq!(plan.deletes(), 3);
assert!(!mass_delete_abort(7, 10, plan.deletes(), 1, false, false));
}
#[test]
fn mass_delete_abort_fires_on_sidecar_only_mass_delete() {
use suno_core::{Action, ArtifactKind, Plan};
let plan = Plan {
actions: (0..5)
.map(|i| Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: format!("clip{i}/cover.jpg"),
owner_id: format!("clip{i}"),
})
.collect(),
};
let delete_count = plan.deletes() + plan.artifact_deletes();
assert_eq!(plan.deletes(), 0);
assert_eq!(delete_count, 5);
assert!(mass_delete_abort(9, 10, delete_count, 1, false, false));
}
#[test]
fn artifact_deletes_on_incomplete_listing_never_reach_the_cap() {
use suno_core::{
Action, ArtifactState, LocalFile, Manifest, ManifestEntry, SourceMode, SourceStatus,
reconcile,
};
let mut manifest = Manifest::new();
for i in 0..10 {
let id = format!("c{i}");
manifest.insert(
&id,
ManifestEntry {
path: format!("{id}.flac"),
format: AudioFormat::Flac,
size: 100,
cover_jpg: Some(ArtifactState {
path: format!("{id}/cover.jpg"),
hash: "h".to_owned(),
}),
..Default::default()
},
);
}
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let local: HashMap<String, LocalFile> = HashMap::new();
let plan = reconcile(&manifest, &[], &local, &sources);
assert_eq!(plan.deletes(), 0);
assert_eq!(plan.artifact_deletes(), 0);
assert!(
!plan
.actions
.iter()
.any(|a| matches!(a, Action::Delete { .. } | Action::DeleteArtifact { .. }))
);
let delete_count = plan.deletes() + plan.artifact_deletes();
assert!(!mass_delete_abort(
0,
manifest.len(),
delete_count,
1,
false,
false
));
}
#[test]
fn confirm_copy_never_prompts() {
assert_eq!(confirm_decision(false, 9, false, true), Confirm::Proceed);
assert_eq!(confirm_decision(false, 9, false, false), Confirm::Proceed);
}
#[test]
fn confirm_sync_no_deletes_proceeds() {
assert_eq!(confirm_decision(true, 0, false, false), Confirm::Proceed);
}
#[test]
fn confirm_sync_yes_proceeds() {
assert_eq!(confirm_decision(true, 3, true, false), Confirm::Proceed);
}
#[test]
fn confirm_sync_tty_prompts() {
assert_eq!(confirm_decision(true, 3, false, true), Confirm::Prompt);
}
#[test]
fn confirm_sync_non_tty_refuses() {
assert_eq!(
confirm_decision(true, 3, false, false),
Confirm::RefuseNonInteractive
);
}
#[test]
fn confirmed_accepts_y_and_yes() {
assert!(confirmed("y"));
assert!(confirmed("Y"));
assert!(confirmed(" yes "));
assert!(confirmed("YES"));
assert!(!confirmed("n"));
assert!(!confirmed(""));
assert!(!confirmed("yeah"));
}
fn outcome(
downloaded: usize,
skipped: usize,
failures: usize,
status: RunStatus,
) -> ExecOutcome {
ExecOutcome {
downloaded,
skipped,
failures: (0..failures)
.map(|i| Failure {
clip_id: format!("c{i}"),
reason: "boom".to_owned(),
})
.collect(),
status,
..Default::default()
}
}
#[test]
fn exit_code_auth_abort() {
let o = outcome(3, 0, 1, RunStatus::AuthAborted);
assert_eq!(run_exit_code(&o), ExitCode::Auth);
}
#[test]
fn exit_code_disk_full_abort() {
let o = outcome(3, 0, 1, RunStatus::DiskFull);
assert_eq!(run_exit_code(&o), ExitCode::DiskFull);
}
#[test]
fn exit_code_clean_run() {
let o = outcome(12, 100, 0, RunStatus::Completed);
assert_eq!(run_exit_code(&o), ExitCode::Ok);
}
#[test]
fn exit_code_partial_when_some_progress() {
let o = outcome(10, 0, 2, RunStatus::Completed);
assert_eq!(run_exit_code(&o), ExitCode::Partial);
}
#[test]
fn exit_code_partial_counts_skips_as_progress() {
let o = outcome(0, 5, 2, RunStatus::Completed);
assert_eq!(run_exit_code(&o), ExitCode::Partial);
}
#[test]
fn exit_code_transient_when_nothing_progressed() {
let o = outcome(0, 0, 5, RunStatus::Completed);
assert_eq!(run_exit_code(&o), ExitCode::Transient);
}
#[test]
fn exit_code_values_match_spec() {
assert_eq!(ExitCode::Ok.code(), 0);
assert_eq!(ExitCode::General.code(), 1);
assert_eq!(ExitCode::Usage.code(), 2);
assert_eq!(ExitCode::Config.code(), 3);
assert_eq!(ExitCode::Auth.code(), 4);
assert_eq!(ExitCode::Partial.code(), 5);
assert_eq!(ExitCode::Transient.code(), 6);
assert_eq!(ExitCode::Safety.code(), 7);
assert_eq!(ExitCode::Interrupted.code(), 8);
assert_eq!(ExitCode::DiskFull.code(), 9);
}
fn path_of<'a>(desired: &'a [Desired], id: &str) -> &'a str {
desired
.iter()
.find(|d| d.clip.id == id)
.map(|d| d.path.as_str())
.expect("clip in desired set")
}
#[test]
fn build_playlist_desired_orders_members_and_marks_absent() {
let a = clip("id-a", "Song A", "alice");
let b = clip("id-b", "Song B", "alice");
let desired = build_desired(
&[&a, &b],
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
let missing = clip("id-x", "Missing Song", "bob");
let members = vec![b.clone(), missing.clone(), a.clone()];
let inputs = vec![PlaylistInput {
id: "pl1",
name: "Road/Trip",
members: &members,
}];
let out = build_playlist_desired(&inputs, &desired);
assert_eq!(out.len(), 1);
let pl = &out[0];
assert_eq!(pl.id, "pl1");
assert_eq!(pl.path, "Road Trip.m3u8");
assert!(pl.content.starts_with("#EXTM3U\n#PLAYLIST:Road/Trip\n"));
let pos_b = pl.content.find(path_of(&desired, "id-b")).unwrap();
let pos_missing = pl.content.find("# (not in library) Missing Song").unwrap();
let pos_a = pl.content.find(path_of(&desired, "id-a")).unwrap();
assert!(pos_b < pos_missing && pos_missing < pos_a);
assert!(!pl.content.contains("Missing Song\nbob/"));
assert_eq!(pl.hash, content_hash(&pl.content));
}
#[test]
fn build_playlist_desired_builds_liked_and_multiple_in_order() {
let a = clip("id-a", "Song A", "alice");
let desired = build_desired(
&[&a],
AudioFormat::Flac,
SourceMode::Mirror,
&no_contexts(),
&no_collisions(),
ArtifactToggles::default(),
);
let members = vec![a.clone()];
let inputs = vec![
PlaylistInput {
id: "pl1",
name: "First",
members: &members,
},
PlaylistInput {
id: LIKED_PLAYLIST_ID,
name: "Liked Songs",
members: &members,
},
];
let out = build_playlist_desired(&inputs, &desired);
assert_eq!(out.len(), 2);
assert_eq!(out[0].id, "pl1");
assert_eq!(out[1].id, LIKED_PLAYLIST_ID);
assert_eq!(out[1].path, "Liked Songs.m3u8");
assert!(out[0].content.contains(path_of(&desired, "id-a")));
assert!(out[1].content.contains(path_of(&desired, "id-a")));
}
#[test]
fn build_playlist_desired_is_empty_for_no_inputs() {
assert!(build_playlist_desired(&[], &[]).is_empty());
}
}