use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::{IsTerminal, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use suno_core::select::{RecencySpec, SelectParams, select};
use suno_core::{
AdoptDecision, AlbumArt, AlbumDesired, ClerkAuth, Clip, Config, Error as CoreError,
ExecOptions, Filesystem, FlagOverrides, LineageContext, LocalFile, NamingConfig, Owner,
OwnerGate, PlaylistDesired, PlaylistState, Ports, ResolveOpts, RunStatus, SourceMode,
SourceStatus, SunoClient, adopt_decision, album_desired, deletion_allowed, is_downloadable,
owner_gate, plan_album_artifacts, plan_playlist_artifacts, reconcile, resolve_roots,
};
use crate::cli::args::{GlobalArgs, SyncArgs};
use crate::cli::desired::{
ArtifactToggles, Confirm, ExitCode, LIKED_PLAYLIST_ID, PlaylistInput, PlaylistPolicy,
ResolvedSelection, build_desired, build_modes_by_id, build_playlist_desired, confirm_decision,
confirmed, is_narrowed, mass_delete_abort, resolve_playlist, resolve_selection, run_exit_code,
};
use crate::cli::logs;
use crate::cli::output;
use crate::clock::TokioClock;
use crate::ffmpeg::FfmpegAdapter;
use crate::fs::FsAdapter;
use crate::http::ReqwestHttp;
const WAV_POLL_ATTEMPTS: u32 = 24;
const WAV_POLL_INTERVAL: Duration = Duration::from_secs(5);
const PROMPT_PATH_LIMIT: usize = 3;
const LAST_RUN_NAME: &str = ".suno-last-run";
#[cfg(unix)]
const PRIVATE_STATE_FILE_MODE: u32 = 0o600;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
Sync,
Copy,
Check,
}
impl Verb {
fn mode(self) -> SourceMode {
match self {
Verb::Sync | Verb::Check => SourceMode::Mirror,
Verb::Copy => SourceMode::Copy,
}
}
fn summary_label(self) -> &'static str {
match self {
Verb::Sync => "Sync",
Verb::Copy => "Copy",
Verb::Check => "Check",
}
}
fn progress_word(self) -> &'static str {
match self {
Verb::Sync => "sync",
Verb::Copy => "copy",
Verb::Check => "check",
}
}
}
pub async fn run_sync(global: &GlobalArgs, args: &SyncArgs) -> Result<ExitCode> {
run(Verb::Sync, global, args, false).await
}
pub async fn run_copy(global: &GlobalArgs, args: &SyncArgs) -> Result<ExitCode> {
run(Verb::Copy, global, args, false).await
}
pub async fn run_check(global: &GlobalArgs, args: &SyncArgs, exit_code: bool) -> Result<ExitCode> {
run(Verb::Check, global, args, exit_code).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TargetSpec {
pub label: String,
pub dest: PathBuf,
pub implicit: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct Selection<'a> {
pub all: bool,
pub account: Option<&'a str>,
pub dest: Option<&'a Path>,
pub token_available: bool,
}
pub fn plan_targets(
config: Option<&Config>,
sel: &Selection<'_>,
) -> std::result::Result<Vec<TargetSpec>, String> {
if sel.all {
let cfg = config.ok_or("--all needs a config file with at least one account")?;
if cfg.accounts.is_empty() {
return Err("--all: no accounts are configured".to_owned());
}
if sel.dest.is_some() {
return Err(
"--all cannot be combined with a DEST; each account uses its configured root"
.to_owned(),
);
}
let mut labels: Vec<&String> = cfg.accounts.keys().collect();
labels.sort();
return labels
.into_iter()
.map(|label| {
account_root(cfg, label).map(|dest| TargetSpec {
label: label.clone(),
dest,
implicit: false,
})
})
.collect();
}
if let Some(account) = sel.account {
let cfg = config.ok_or_else(|| format!("account '{account}' not found: no config file"))?;
if !cfg.accounts.contains_key(account) {
return Err(unknown_account_message(cfg, account));
}
let dest = dest_for(cfg, account, sel.dest)?;
return Ok(vec![TargetSpec {
label: account.to_owned(),
dest,
implicit: false,
}]);
}
match config {
Some(cfg) if cfg.accounts.len() == 1 => {
let label = cfg.accounts.keys().next().expect("one account").clone();
let dest = dest_for(cfg, &label, sel.dest)?;
Ok(vec![TargetSpec {
label,
dest,
implicit: false,
}])
}
Some(cfg) if cfg.accounts.len() > 1 => {
let mut labels: Vec<&str> = cfg.accounts.keys().map(String::as_str).collect();
labels.sort_unstable();
Err(format!(
"multiple accounts configured ({}); pass --account <label> or --all",
labels.join(", ")
))
}
_ => {
if !sel.token_available {
return Err(
"no account configured and no token provided; pass --token or run 'suno config init'"
.to_owned(),
);
}
let dest = sel
.dest
.map(Path::to_path_buf)
.ok_or("a destination directory is required")?;
Ok(vec![TargetSpec {
label: "default".to_owned(),
dest,
implicit: true,
}])
}
}
}
fn account_root(cfg: &Config, label: &str) -> std::result::Result<PathBuf, String> {
cfg.accounts
.get(label)
.and_then(|acc| acc.root.as_deref())
.map(PathBuf::from)
.ok_or_else(|| format!("account '{label}' has no configured root and no DEST was given"))
}
fn dest_for(
cfg: &Config,
label: &str,
dest: Option<&Path>,
) -> std::result::Result<PathBuf, String> {
if let Some(dest) = dest {
return Ok(dest.to_path_buf());
}
account_root(cfg, label)
}
fn unknown_account_message(cfg: &Config, account: &str) -> String {
let mut labels: Vec<&str> = cfg.accounts.keys().map(String::as_str).collect();
labels.sort_unstable();
if labels.is_empty() {
format!("account '{account}' not found; no accounts are configured")
} else {
format!(
"account '{account}' not found in config\n\nConfigured accounts: {}",
labels.join(", ")
)
}
}
async fn run(
verb: Verb,
global: &GlobalArgs,
args: &SyncArgs,
exit_code: bool,
) -> Result<ExitCode> {
let env: HashMap<String, String> = std::env::vars().collect();
let token_available = global.token.is_some() || env.contains_key("SUNO_TOKEN");
let config = match load_config(global.config.as_deref())? {
ConfigState::Loaded(cfg) => Some(cfg),
ConfigState::Absent => None,
ConfigState::Error(message) => {
eprintln!("error: {message}");
return Ok(ExitCode::Config);
}
};
let sel = Selection {
all: global.all,
account: global.account.as_deref(),
dest: args.dest.as_deref(),
token_available,
};
let targets = match plan_targets(config.as_ref(), &sel) {
Ok(targets) => targets,
Err(message) => {
eprintln!("error: {message}");
return Ok(ExitCode::Config);
}
};
let flags = flag_overrides(global, args);
let mut worst = ExitCode::Ok;
for target in targets {
let code = run_one(
verb,
global,
args,
&target,
config.as_ref(),
&flags,
&env,
exit_code,
)
.await?;
worst = worse(worst, code);
if code == ExitCode::Interrupted || code == ExitCode::DiskFull {
break;
}
}
Ok(worst)
}
enum ConfigState {
Loaded(Config),
Absent,
Error(String),
}
pub(crate) fn load_config_reported(
override_path: Option<&Path>,
) -> std::result::Result<Option<Config>, ExitCode> {
match load_config(override_path) {
Ok(ConfigState::Loaded(cfg)) => Ok(Some(cfg)),
Ok(ConfigState::Absent) => Ok(None),
Ok(ConfigState::Error(message)) => {
eprintln!("error: {message}");
Err(ExitCode::Config)
}
Err(err) => {
eprintln!("error: {err:#}");
Err(ExitCode::General)
}
}
}
pub(crate) fn single_account(
config: Option<&Config>,
global: &GlobalArgs,
flags: &FlagOverrides,
env: &HashMap<String, String>,
) -> std::result::Result<(String, suno_core::EffectiveSettings), String> {
let token_available = global.token.is_some() || env.contains_key("SUNO_TOKEN");
let (label, implicit) = if global.all {
return Err(
"this command runs a single account; pass --account instead of --all".to_owned(),
);
} else if let Some(account) = global.account.as_deref() {
let cfg = config.ok_or_else(|| format!("account '{account}' not found: no config file"))?;
if !cfg.accounts.contains_key(account) {
return Err(unknown_account_message(cfg, account));
}
(account.to_owned(), false)
} else {
match config {
Some(cfg) if cfg.accounts.len() == 1 => (
cfg.accounts.keys().next().expect("one account").clone(),
false,
),
Some(cfg) if cfg.accounts.len() > 1 => {
let mut labels: Vec<&str> = cfg.accounts.keys().map(String::as_str).collect();
labels.sort_unstable();
return Err(format!(
"multiple accounts configured ({}); pass --account <label>",
labels.join(", ")
));
}
_ => {
if !token_available {
return Err(
"no account configured and no token provided; pass --token".to_owned()
);
}
("default".to_owned(), true)
}
}
};
let settings = if implicit {
synthetic_config().resolve("default", None, env, flags)
} else {
config
.expect("non-implicit account has config")
.resolve(&label, None, env, flags)
}
.map_err(|err| err.to_string())?;
Ok((label, settings))
}
fn load_config(override_path: Option<&Path>) -> Result<ConfigState> {
let explicit = override_path.is_some();
let Some(path) = logs::config_path(override_path) else {
return Ok(ConfigState::Absent);
};
match std::fs::read_to_string(&path) {
Ok(text) => match Config::from_toml(&text) {
Ok(cfg) => Ok(ConfigState::Loaded(cfg)),
Err(err) => Ok(ConfigState::Error(format!("{}: {err}", path.display()))),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if explicit {
Ok(ConfigState::Error(format!(
"config file not found: {}",
path.display()
)))
} else {
Ok(ConfigState::Absent)
}
}
Err(err) => Err(err).with_context(|| format!("could not read {}", path.display())),
}
}
fn flag_overrides(global: &GlobalArgs, args: &SyncArgs) -> FlagOverrides {
FlagOverrides {
token: global.token.clone(),
format: args.format.map(Into::into),
concurrency: args.concurrency,
retries: args.retries,
min_newest: args.min_newest,
animated_covers: args.animated_covers.then_some(true),
details_sidecar: args.details_sidecar.then_some(true),
lyrics_sidecar: args.lyrics_sidecar.then_some(true),
lrc_sidecar: args.lrc_sidecar.then_some(true),
video_mp4: args.video_mp4.then_some(true),
naming_template: args.naming_template.clone(),
character_set: args.character_set.map(Into::into),
}
}
#[allow(clippy::too_many_arguments)]
async fn run_one(
verb: Verb,
global: &GlobalArgs,
args: &SyncArgs,
target: &TargetSpec,
config: Option<&Config>,
flags: &FlagOverrides,
env: &HashMap<String, String>,
exit_code: bool,
) -> Result<ExitCode> {
let verbosity = global.verbosity();
if args.allow_account_change && (global.dry_run || verb == Verb::Check) {
eprintln!(
"error: --allow-account-change only applies to an executing sync or copy, not check or --dry-run."
);
return Ok(ExitCode::Usage);
}
let settings = {
let resolved = if target.implicit {
synthetic_config().resolve("default", None, env, flags)
} else {
config
.expect("non-implicit target has config")
.resolve(&target.label, None, env, flags)
};
match resolved {
Ok(settings) => settings,
Err(err) => {
eprintln!("error: {err}");
return Ok(ExitCode::Config);
}
}
};
let Some(token) = settings.token.clone() else {
eprintln!(
"error: no token for account '{}'; pass --token or set it in config",
target.label
);
return Ok(ExitCode::Config);
};
if settings.format == suno_core::AudioFormat::Wav && verbosity >= -1 {
eprintln!(
"warning: WAV carries limited metadata; lyrics and album art will be omitted (use flac or mp3 for full tags)"
);
}
let http = ReqwestHttp::new().context("failed to build the HTTP client")?;
let dest = &target.dest;
let mut auth = ClerkAuth::new(&token);
if let Err(err) = auth.authenticate(&http).await {
return Ok(report_auth_failure(&target.label, &err));
}
let account = auth.display_name().to_owned();
crate::cli::expiry::warn_token_expiry(&target.label, &auth, verbosity);
let Some(user_id) = auth.user_id().map(str::to_owned) else {
eprintln!(
"error: could not determine the authenticated account for '{}'. Refusing to run to protect the library.",
target.label
);
return Ok(ExitCode::Auth);
};
let mut store = logs::load_graph(dest)?;
let mut owner_dirty = false;
let mut pending_pin: Option<PendingPin> = None;
let gate = owner_gate(
store.owner(),
settings.account_id.as_deref(),
&user_id,
args.allow_account_change,
);
let mut force_additive = gate.is_additive();
match gate {
OwnerGate::AbortConfigMismatch => {
eprintln!(
"error: the configured account_id ({}) does not match the authenticated account (id {}). Refusing to run to protect the library.",
short_id(settings.account_id.as_deref().unwrap_or_default()),
short_id(&user_id)
);
return Ok(ExitCode::Safety);
}
OwnerGate::AbortMismatch => {
let pinned = store.owner().expect("mismatch implies a pinned owner");
eprintln!(
"error: this library belongs to {} (id {}) but the token authenticates as {} (id {}). Refusing to run to protect the library. Pass --allow-account-change to re-pin it to the authenticated account, or use a different destination.",
pinned.display_name,
short_id(&pinned.user_id),
account,
short_id(&user_id)
);
return Ok(ExitCode::Safety);
}
OwnerGate::Repin => {
let previous = store
.owner()
.map(|owner| owner.display_name.clone())
.unwrap_or_default();
store.pin_owner(Owner {
user_id: user_id.clone(),
display_name: account.clone(),
});
owner_dirty = true;
pending_pin = Some(PendingPin {
action: "REPIN",
notice: format!(
"notice: re-pinned this library from {} to {} (id {}); this run is additive (no deletions). Run 'sync' again to mirror.",
previous,
account,
short_id(&user_id)
),
});
}
OwnerGate::Proceed => {
if store.refresh_display_name(&account) {
owner_dirty = true;
}
if args.allow_account_change && verbosity >= 0 {
eprintln!(
"notice: --allow-account-change had no effect; this library already belongs to {} (id {}).",
account,
short_id(&user_id)
);
}
}
OwnerGate::FirstUse => {}
}
let mut client = SunoClient::new(auth, TokioClock);
let force_copy_initial = verb == Verb::Copy || force_additive;
let selection = resolve_selection(
verb.mode(),
args.mode.map(SourceMode::from),
args.liked,
&args.playlist,
settings.areas.as_ref(),
force_copy_initial,
);
let areas = match enumerate_areas(
&selection,
&mut client,
&http,
&target.label,
args,
verbosity,
)
.await
{
Ok(areas) => areas,
Err(code) => return Ok(code),
};
let clips = union_clips(&areas);
if clips.is_empty() && selection.library.is_none() {
if verbosity >= -1 {
eprintln!("notice: nothing to do; the requested scope holds no downloadable clips.");
}
return Ok(ExitCode::Ok);
}
let resolution = match resolve_roots(&clips, &mut client, &http, ResolveOpts::default()).await {
Ok(resolution) => Some(resolution),
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: lineage resolution failed ({err}); using the last-known-good graph"
);
}
None
}
};
let graph_changed = resolution.is_some();
if let Some(resolution) = &resolution {
store.update(&clips, resolution, &now_rfc3339());
}
let colliding_albums = store.colliding_root_titles();
let enumerated = library_authoritative(&areas, force_copy_initial);
if gate == OwnerGate::FirstUse {
let owned = logs::load_manifest(dest)?;
let owned_ids: BTreeSet<&str> = owned.entries.keys().map(String::as_str).collect();
let listed_ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
let decision = adopt_decision(
&listed_ids,
&owned_ids,
enumerated,
args.allow_account_change,
);
force_additive = force_additive || decision.is_additive();
match decision {
AdoptDecision::PinFresh => {
store.pin_owner(Owner {
user_id: user_id.clone(),
display_name: account.clone(),
});
owner_dirty = true;
pending_pin = Some(PendingPin {
action: "PIN",
notice: format!(
"notice: pinned this library to {} (id {}).",
account,
short_id(&user_id)
),
});
}
AdoptDecision::PinAdopt => {
store.pin_owner(Owner {
user_id: user_id.clone(),
display_name: account.clone(),
});
owner_dirty = true;
pending_pin = Some(PendingPin {
action: "ADOPT",
notice: format!(
"notice: adopted this existing library for {} (id {}).",
account,
short_id(&user_id)
),
});
}
AdoptDecision::AdoptForced => {
store.pin_owner(Owner {
user_id: user_id.clone(),
display_name: account.clone(),
});
owner_dirty = true;
pending_pin = Some(PendingPin {
action: "ADOPT",
notice: format!(
"notice: adopted this library for {} (id {}) despite no overlap; this run is additive (no deletions). Run 'sync' again to mirror.",
account,
short_id(&user_id)
),
});
}
AdoptDecision::Abort => {
eprintln!(
"error: none of the authenticated account's clips ({}, id {}) match this library at {}. Refusing to run in case the token authenticates as a different Suno account. Pass --allow-account-change to adopt it, or use a different destination.",
account,
short_id(&user_id),
dest.display()
);
return Ok(ExitCode::Safety);
}
AdoptDecision::SkipPin => {}
}
}
let force_copy = verb == Verb::Copy || force_additive;
let sources: Vec<SourceStatus> = areas
.iter()
.map(|area| SourceStatus {
mode: area_mode(area, force_copy),
fully_enumerated: area_enumerated(area, force_copy),
})
.collect();
let can_delete = deletion_allowed(&sources);
let library_authoritative = library_authoritative(&areas, force_copy);
let area_modes: Vec<(SourceMode, Vec<String>)> = areas
.iter()
.map(|area| {
(
area_mode(area, force_copy),
area.clips.iter().map(|clip| clip.id.clone()).collect(),
)
})
.collect();
let modes_by_id = build_modes_by_id(&area_modes);
let since = match args.since.as_deref().map(RecencySpec::parse).transpose() {
Ok(since) => since,
Err(message) => {
eprintln!("error: {message}");
return Ok(ExitCode::Config);
}
};
let truncate = !can_delete && !library_authoritative;
let params = SelectParams {
limit: if truncate { args.limit } else { None },
since: if truncate { since } else { None },
min_newest: settings.min_newest as usize,
now: now_secs(),
last_run: read_last_run(dest),
};
let selected = select(&clips, ¶ms);
let contexts: HashMap<String, LineageContext> = selected
.iter()
.map(|clip| (clip.id.clone(), store.context_for(clip)))
.collect();
let desired = build_desired(
&selected,
settings.format,
&modes_by_id,
&contexts,
&colliding_albums,
ArtifactToggles {
animated_covers: settings.animated_covers,
details: settings.details_sidecar,
lyrics: settings.lyrics_sidecar,
lrc: settings.lrc_sidecar,
video: settings.video_mp4,
},
&NamingConfig {
template: settings.naming_template.clone(),
character_set: settings.character_set,
..NamingConfig::default()
},
);
let albums_desired = if library_authoritative {
album_desired(&desired, settings.animated_covers)
} else {
Vec::new()
};
let mut protected_playlists: BTreeSet<String> = BTreeSet::new();
let (playlist_desired, playlists_enumerated) =
if selection.is_plain_library() && library_authoritative {
fetch_playlist_desired(
&mut client,
&http,
&desired,
&mut protected_playlists,
verbosity,
)
.await
} else {
build_scoped_playlist_desired(
&areas,
&desired,
&store,
&mut protected_playlists,
force_copy,
!truncate,
)
};
let stored_playlists: BTreeMap<String, PlaylistState> = store
.playlists
.iter()
.filter(|(id, _)| !protected_playlists.contains(id.as_str()))
.map(|(id, state)| (id.clone(), state.clone()))
.collect();
let dry_run = global.dry_run || verb == Verb::Check;
if dry_run {
let (_manifest, plan) = load_and_reconcile(
dest,
&desired,
&albums_desired,
&store.albums,
&playlist_desired,
&stored_playlists,
&sources,
library_authoritative,
playlists_enumerated,
)?;
if verbosity >= 1 {
let no_failures = HashSet::new();
for line in output::action_lines(&plan, &no_failures, verbosity) {
eprintln!("{line}");
}
}
if verbosity >= -1 {
eprintln!("{}", output::dry_summary(&account, &plan));
}
if verb == Verb::Check && exit_code && plan_has_changes(&plan) {
return Ok(ExitCode::General);
}
return Ok(ExitCode::Ok);
}
std::fs::create_dir_all(dest)
.with_context(|| format!("could not create {}", dest.display()))?;
let _lock = logs::acquire_lock(dest)?;
let (manifest, plan) = load_and_reconcile(
dest,
&desired,
&albums_desired,
&store.albums,
&playlist_desired,
&stored_playlists,
&sources,
library_authoritative,
playlists_enumerated,
)?;
if graph_changed || owner_dirty {
logs::save_graph(dest, &store)?;
}
if let Some(pin) = &pending_pin {
if verbosity >= -1 {
eprintln!("{}", pin.notice);
}
if let Some(owner) = store.owner() {
logs::append_owner_pin(dest, pin.action, &owner.user_id, &owner.display_name)?;
}
}
let is_sync = verb == Verb::Sync && !force_additive;
let delete_count = plan.deletes() + plan.artifact_deletes();
if is_sync
&& mass_delete_abort(
desired.len(),
manifest.len(),
delete_count,
settings.min_newest,
args.min_newest == Some(0),
global.yes,
)
{
eprintln!(
"error: sync aborted -- deletion safety rule triggered\n\nThe listing yielded {} clip(s), which would delete {} of {} local file(s).\nThis is almost certainly a listing error. No files were deleted.\n\nIf you intended to delete everything, pass --min-newest 0 --yes to confirm.",
desired.len(),
delete_count,
manifest.len()
);
return Ok(ExitCode::Safety);
}
match confirm_decision(
is_sync,
delete_count,
global.yes,
std::io::stdin().is_terminal(),
) {
Confirm::Proceed => {}
Confirm::Prompt => {
if !prompt_delete(&plan, verbosity)? {
eprintln!("Aborted; no changes made.");
return Ok(ExitCode::Ok);
}
}
Confirm::RefuseNonInteractive => {
eprintln!(
"error: sync would delete {} file(s) but stdin is not a TTY and --yes was not passed\n Pass --yes to confirm, or use 'copy' to skip deletions.",
delete_count
);
return Ok(ExitCode::Safety);
}
}
if verbosity == 0 {
eprintln!(
"{}",
output::progress_start(verb.progress_word(), &account, &plan)
);
}
execute_plan(
verb,
&plan,
&desired,
manifest,
&mut store,
&mut client,
&http,
dest,
&settings,
&account,
verbosity,
library_authoritative,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn execute_plan(
verb: Verb,
plan: &suno_core::Plan,
desired: &[suno_core::Desired],
mut manifest: suno_core::Manifest,
store: &mut suno_core::LineageStore,
client: &mut SunoClient<TokioClock>,
http: &ReqwestHttp,
dest: &Path,
settings: &suno_core::EffectiveSettings,
account: &str,
verbosity: i8,
library_authoritative: bool,
) -> Result<ExitCode> {
let fs = FsAdapter::new(dest);
let ffmpeg = FfmpegAdapter::new(dest);
let clock = TokioClock;
let opts = ExecOptions {
max_retries: settings.retries,
wav_poll_attempts: WAV_POLL_ATTEMPTS,
wav_poll_interval: WAV_POLL_INTERVAL,
};
let started = std::time::Instant::now();
let outcome = {
let ports = Ports {
client,
http,
fs: &fs,
ffmpeg: &ffmpeg,
clock: &clock,
};
tokio::select! {
out = suno_core::execute(plan, &mut manifest, &mut store.albums, &mut store.playlists, desired, ports, &opts) => Some(out),
_ = wait_for_signal() => None,
}
};
let Some(outcome) = outcome else {
logs::save_manifest(dest, &manifest)?;
logs::save_graph(dest, store)?;
let _ = fs.prune_empty_dirs("");
eprintln!(
"warning: interrupted -- partial run saved\n Progress so far is recorded in the manifest; re-run to continue."
);
return Ok(ExitCode::Interrupted);
};
if outcome.status == RunStatus::DiskFull {
let _ = logs::save_manifest(dest, &manifest);
let _ = logs::save_graph(dest, store);
let _ = fs.prune_empty_dirs("");
if verbosity >= -1 {
eprintln!(
"{}",
output::run_summary(
verb.summary_label(),
account,
&outcome,
started.elapsed().as_secs_f64()
)
);
}
eprintln!(
"error: {} The library is unchanged for the failing action.",
crate::diskspace::DISK_FULL_HINT
);
if let Some(last) = outcome.failures.last() {
eprintln!(" {}", last.reason);
}
return Ok(ExitCode::DiskFull);
}
logs::save_manifest(dest, &manifest)?;
logs::save_graph(dest, store)?;
let clips_by_id: HashMap<&str, &Clip> = desired
.iter()
.map(|d| (d.clip.id.as_str(), &d.clip))
.collect();
if library_authoritative
&& let Err(err) = logs::save_index(dest, &manifest, store, &clips_by_id)
&& verbosity >= -1
{
eprintln!("warning: could not write {}: {err}", logs::INDEX_NAME);
}
logs::append_failures(dest, &outcome.failures, &clips_by_id)?;
let failed: HashSet<&str> = outcome
.failures
.iter()
.map(|f| f.clip_id.as_str())
.collect();
let rename_owner: HashMap<&str, &str> = desired
.iter()
.map(|d| (d.path.as_str(), d.clip.id.as_str()))
.collect();
logs::append_audit(dest, plan, &failed, &rename_owner)?;
write_last_run(dest);
if verbosity >= 1 {
for line in output::action_lines(plan, &failed, verbosity) {
eprintln!("{line}");
}
}
if !outcome.failures.is_empty() && verbosity >= -1 {
eprintln!(
"warning: {} clip(s) failed after retries\n See {} for details.",
outcome.failures.len(),
dest.join(".suno-failures.log").display()
);
}
if verbosity >= -1 {
eprintln!(
"{}",
output::run_summary(
verb.summary_label(),
account,
&outcome,
started.elapsed().as_secs_f64()
)
);
}
Ok(run_exit_code(&outcome))
}
struct AreaListing {
kind: AreaKind,
mode: SourceMode,
clips: Vec<Clip>,
authoritative_ignoring_empty: bool,
}
enum AreaKind {
Library,
Liked,
Playlist { id: String, name: String },
}
fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
if force_copy {
SourceMode::Copy
} else {
area.mode
}
}
fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
let mode = area_mode(area, force_copy);
area.authoritative_ignoring_empty && !(area.clips.is_empty() && mode == SourceMode::Mirror)
}
fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
areas
.iter()
.any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
}
fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
let mut seen: HashSet<String> = HashSet::new();
let mut union: Vec<Clip> = Vec::new();
for area in areas {
for clip in &area.clips {
if seen.insert(clip.id.clone()) {
union.push(clip.clone());
}
}
}
union
}
fn unresolved_playlist_area(mode: SourceMode) -> AreaListing {
AreaListing {
kind: AreaKind::Playlist {
id: String::new(),
name: String::new(),
},
mode,
clips: Vec::new(),
authoritative_ignoring_empty: false,
}
}
async fn enumerate_areas(
selection: &ResolvedSelection,
client: &mut SunoClient<TokioClock>,
http: &ReqwestHttp,
label: &str,
args: &SyncArgs,
verbosity: i8,
) -> std::result::Result<Vec<AreaListing>, ExitCode> {
let mut areas: Vec<AreaListing> = Vec::new();
let narrowed = is_narrowed(args.limit, args.since.as_deref());
if let Some(lib) = selection.library {
if lib.unfiltered {
match client.list_clips(http, false, None).await {
Ok((clips, complete)) => areas.push(AreaListing {
kind: AreaKind::Library,
mode: lib.mode,
clips,
authoritative_ignoring_empty: complete,
}),
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: library listing failed ({err}); suppressing deletion this run"
);
}
areas.push(AreaListing {
kind: AreaKind::Library,
mode: lib.mode,
clips: Vec::new(),
authoritative_ignoring_empty: false,
});
}
}
} else {
match client.list_clips(http, false, args.limit).await {
Ok((clips, complete)) => areas.push(AreaListing {
kind: AreaKind::Library,
mode: lib.mode,
clips,
authoritative_ignoring_empty: complete && !narrowed,
}),
Err(err) => return Err(report_listing_failure(label, &err)),
}
}
}
if let Some(mode) = selection.liked {
match client.list_clips(http, true, None).await {
Ok((clips, complete)) => areas.push(AreaListing {
kind: AreaKind::Liked,
mode,
clips,
authoritative_ignoring_empty: complete && !narrowed,
}),
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: liked feed failed to list ({err}); suppressing deletion this run"
);
}
areas.push(AreaListing {
kind: AreaKind::Liked,
mode,
clips: Vec::new(),
authoritative_ignoring_empty: false,
});
}
}
}
if !matches!(selection.playlists, PlaylistPolicy::None) {
let playlists = match client.get_playlists(http).await {
Ok(playlists) => Some(playlists),
Err(err) => {
if selection.cli_scoped {
return Err(report_listing_failure(label, &err));
}
if verbosity >= -1 {
eprintln!(
"warning: playlist listing failed ({err}); suppressing deletion this run"
);
}
None
}
};
match (&selection.playlists, playlists) {
(PlaylistPolicy::Explicit(list), Some(pls)) => {
for (value, mode) in list {
let playlist = match resolve_playlist(value, &pls) {
Ok(playlist) => playlist,
Err(err) => {
if selection.cli_scoped {
eprintln!("error: {err}.");
print_visible_playlists(&pls, verbosity);
return Err(ExitCode::Config);
}
if verbosity >= -1 {
eprintln!(
"warning: a configured playlist could not be resolved ({err}); leaving its .m3u8 untouched"
);
}
areas.push(unresolved_playlist_area(*mode));
continue;
}
};
areas.push(
list_playlist_area(
client,
http,
&playlist.id,
&playlist.name,
*mode,
verbosity,
)
.await,
);
}
}
(PlaylistPolicy::All { default, overrides }, Some(pls)) => {
for playlist in &pls {
let mode = overrides.get(&playlist.id).copied().unwrap_or(*default);
areas.push(
list_playlist_area(
client,
http,
&playlist.id,
&playlist.name,
mode,
verbosity,
)
.await,
);
}
}
(PlaylistPolicy::Explicit(list), None) => {
for (_, mode) in list {
areas.push(unresolved_playlist_area(*mode));
}
}
(PlaylistPolicy::All { default, .. }, None) => {
areas.push(unresolved_playlist_area(*default));
}
(PlaylistPolicy::None, _) => {}
}
}
Ok(areas)
}
async fn list_playlist_area(
client: &mut SunoClient<TokioClock>,
http: &ReqwestHttp,
id: &str,
name: &str,
mode: SourceMode,
verbosity: i8,
) -> AreaListing {
match client.get_playlist_clips(http, id).await {
Ok((raw, complete)) => {
let raw_len = raw.len();
let clips: Vec<Clip> = raw.into_iter().filter(is_downloadable).collect();
let any_filtered = clips.len() < raw_len;
AreaListing {
kind: AreaKind::Playlist {
id: id.to_owned(),
name: name.to_owned(),
},
mode,
clips,
authoritative_ignoring_empty: complete && !any_filtered,
}
}
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: playlist '{name}' members failed to list ({err}); suppressing deletion this run"
);
}
AreaListing {
kind: AreaKind::Playlist {
id: id.to_owned(),
name: name.to_owned(),
},
mode,
clips: Vec::new(),
authoritative_ignoring_empty: false,
}
}
}
}
fn build_scoped_playlist_desired(
areas: &[AreaListing],
desired: &[suno_core::Desired],
store: &suno_core::LineageStore,
protected: &mut BTreeSet<String>,
force_copy: bool,
members_intact: bool,
) -> (Vec<PlaylistDesired>, bool) {
let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
for area in areas {
match &area.kind {
AreaKind::Playlist { id, name } => {
if members_intact && !id.is_empty() && area_enumerated(area, force_copy) {
owned.push((id.clone(), name.clone(), area.clips.clone()));
} else if !id.is_empty() {
protected.insert(id.clone());
}
}
AreaKind::Liked => {
if members_intact && area_enumerated(area, force_copy) {
owned.push((
LIKED_PLAYLIST_ID.to_owned(),
"Liked Songs".to_owned(),
area.clips.clone(),
));
} else {
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
}
AreaKind::Library => {}
}
}
let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
for id in store.playlists.keys() {
if !rendered.contains(id.as_str()) {
protected.insert(id.clone());
}
}
let inputs: Vec<PlaylistInput<'_>> = owned
.iter()
.map(|(id, name, members)| PlaylistInput {
id: id.as_str(),
name: name.as_str(),
members: members.as_slice(),
})
.collect();
(build_playlist_desired(&inputs, desired), true)
}
fn print_visible_playlists(playlists: &[suno_core::Playlist], verbosity: i8) {
if verbosity < -1 {
return;
}
if playlists.is_empty() {
eprintln!("no playlists are visible for this account.");
return;
}
eprintln!("visible playlists:");
for playlist in playlists {
eprintln!(" {} ({})", playlist.name, playlist.id);
}
}
async fn fetch_playlist_desired(
client: &mut SunoClient<TokioClock>,
http: &ReqwestHttp,
desired: &[suno_core::Desired],
protected: &mut BTreeSet<String>,
verbosity: i8,
) -> (Vec<PlaylistDesired>, bool) {
let playlists = match client.get_playlists(http).await {
Ok(playlists) => playlists,
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: playlist listing failed ({err}); leaving existing .m3u8 files untouched"
);
}
return (Vec::new(), false);
}
};
let mut fetched: Vec<(String, String, Vec<Clip>)> = Vec::new();
for playlist in &playlists {
match client.get_playlist_clips(http, &playlist.id).await {
Ok((members, true)) => {
fetched.push((playlist.id.clone(), playlist.name.clone(), members))
}
Ok((_, false)) => {
if verbosity >= -1 {
eprintln!(
"warning: playlist '{}' returned an incomplete member page; keeping its .m3u8 unchanged",
playlist.name
);
}
protected.insert(playlist.id.clone());
}
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: playlist '{}' members failed to list ({err}); keeping its .m3u8 unchanged",
playlist.name
);
}
protected.insert(playlist.id.clone());
}
}
}
match client.list_clips(http, true, None).await {
Ok((liked, true)) => {
fetched.push((
LIKED_PLAYLIST_ID.to_owned(),
"Liked Songs".to_owned(),
liked,
));
}
Ok((_, false)) => {
if verbosity >= -1 {
eprintln!("warning: liked feed was truncated; keeping Liked Songs.m3u8 unchanged");
}
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
Err(err) => {
if verbosity >= -1 {
eprintln!(
"warning: liked feed failed to list ({err}); keeping Liked Songs.m3u8 unchanged"
);
}
protected.insert(LIKED_PLAYLIST_ID.to_owned());
}
}
let inputs: Vec<PlaylistInput<'_>> = fetched
.iter()
.map(|(id, name, members)| PlaylistInput {
id: id.as_str(),
name: name.as_str(),
members: members.as_slice(),
})
.collect();
(build_playlist_desired(&inputs, desired), true)
}
#[allow(clippy::too_many_arguments)]
fn load_and_reconcile(
dest: &Path,
desired: &[suno_core::Desired],
albums_desired: &[AlbumDesired],
albums: &BTreeMap<String, AlbumArt>,
playlist_desired: &[PlaylistDesired],
playlists: &BTreeMap<String, PlaylistState>,
sources: &[SourceStatus],
library_authoritative: bool,
playlists_enumerated: bool,
) -> Result<(suno_core::Manifest, suno_core::Plan)> {
let manifest = logs::load_manifest(dest)?;
let local = stat_manifest(dest, &manifest);
let can_delete = deletion_allowed(sources);
let art_can_delete = can_delete && library_authoritative;
let mut plan = reconcile(&manifest, desired, &local, sources);
plan.actions
.extend(plan_album_artifacts(albums_desired, albums, art_can_delete));
plan.actions.extend(plan_playlist_artifacts(
playlist_desired,
playlists,
can_delete,
playlists_enumerated,
));
Ok((manifest, plan))
}
fn stat_manifest(dest: &Path, manifest: &suno_core::Manifest) -> HashMap<String, LocalFile> {
manifest
.iter()
.map(|(clip_id, entry)| {
let stat = std::fs::metadata(dest.join(&entry.path)).ok();
let local = LocalFile {
exists: stat.is_some(),
size: stat.map(|m| m.len()).unwrap_or(0),
};
(clip_id.clone(), local)
})
.collect()
}
fn plan_has_changes(plan: &suno_core::Plan) -> bool {
plan.downloads()
+ plan.reformats()
+ plan.retags()
+ plan.renames()
+ plan.deletes()
+ plan.artifact_writes()
+ plan.artifact_deletes()
> 0
}
fn deletion_paths(plan: &suno_core::Plan) -> Vec<String> {
plan.actions
.iter()
.filter_map(|action| match action {
suno_core::Action::Delete { path, .. }
| suno_core::Action::DeleteArtifact { path, .. } => Some(path.clone()),
_ => None,
})
.collect()
}
fn prompt_delete(plan: &suno_core::Plan, verbosity: i8) -> Result<bool> {
let paths = deletion_paths(plan);
let show = if verbosity >= 1 {
paths.len()
} else {
PROMPT_PATH_LIMIT
};
eprint!("{} [y/N] ", output::delete_prompt(&paths, show));
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("could not read confirmation")?;
Ok(confirmed(&answer))
}
pub(crate) fn report_auth_failure(label: &str, err: &CoreError) -> ExitCode {
eprintln!(
"error: authentication failed for account '{label}'\n\nThe stored token may have expired. Re-authenticate with:\n suno auth refresh {label}\n\nIf the token was rotated in Suno, update it with:\n suno config add-account {label} --token <new-token>"
);
let _ = err;
ExitCode::Auth
}
pub(crate) fn report_listing_failure(label: &str, err: &CoreError) -> ExitCode {
match err {
CoreError::Auth(_) => report_auth_failure(label, err),
CoreError::Connection(_) | CoreError::RateLimited { .. } => {
eprintln!(
"error: could not list the library for '{label}': {err}\n No files were written. Re-run when connectivity is restored."
);
ExitCode::Transient
}
other => {
eprintln!("error: could not list the library for '{label}': {other}");
ExitCode::General
}
}
}
fn synthetic_config() -> Config {
let mut config = Config::default();
config
.accounts
.insert("default".to_owned(), suno_core::AccountConfig::default());
config
}
fn worse(a: ExitCode, b: ExitCode) -> ExitCode {
if b.code() >= a.code() { b } else { a }
}
fn short_id(id: &str) -> &str {
id.get(..8).unwrap_or(id)
}
struct PendingPin {
action: &'static str,
notice: String,
}
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn now_rfc3339() -> String {
rfc3339_from_unix(now_secs())
}
fn rfc3339_from_unix(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let tod = (secs % 86_400) as i64;
let (hour, minute, second) = (tod / 3_600, (tod % 3_600) / 60, tod % 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn read_last_run(dest: &Path) -> Option<u64> {
std::fs::read_to_string(dest.join(LAST_RUN_NAME))
.ok()?
.trim()
.parse()
.ok()
}
fn write_last_run(dest: &Path) {
let path = dest.join(LAST_RUN_NAME);
if std::fs::write(&path, now_secs().to_string()).is_ok() {
#[cfg(unix)]
let _ = std::fs::set_permissions(
&path,
std::fs::Permissions::from_mode(PRIVATE_STATE_FILE_MODE),
);
}
}
async fn wait_for_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
Ok(term) => term,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with(accounts: &[(&str, Option<&str>)]) -> Config {
let mut cfg = Config::default();
for (label, root) in accounts {
let acc = suno_core::AccountConfig {
root: root.map(str::to_owned),
..Default::default()
};
cfg.accounts.insert((*label).to_owned(), acc);
}
cfg
}
fn sel<'a>(
all: bool,
account: Option<&'a str>,
dest: Option<&'a Path>,
token: bool,
) -> Selection<'a> {
Selection {
all,
account,
dest,
token_available: token,
}
}
#[cfg(unix)]
#[test]
fn last_run_marker_uses_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = Path::new("target").join(format!(
"run-last-run-perms-{}-{}",
std::process::id(),
now_secs()
));
std::fs::create_dir_all(&dir).unwrap();
write_last_run(&dir);
let mode = std::fs::metadata(dir.join(LAST_RUN_NAME))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn implicit_target_needs_token_and_dest() {
let dest = PathBuf::from("/music");
let s = sel(false, None, Some(&dest), true);
let targets = plan_targets(None, &s).unwrap();
assert_eq!(targets.len(), 1);
assert!(targets[0].implicit);
assert_eq!(targets[0].dest, dest);
}
#[test]
fn load_and_reconcile_does_not_create_the_destination() {
let dir =
Path::new("target").join(format!("run-nodir-{}-{}", std::process::id(), now_secs()));
let _ = std::fs::remove_dir_all(&dir);
assert!(!dir.exists());
let sources = vec![SourceStatus {
mode: SourceMode::Mirror,
fully_enumerated: false,
}];
let (manifest, plan) = load_and_reconcile(
&dir,
&[],
&[],
&BTreeMap::new(),
&[],
&BTreeMap::new(),
&sources,
false,
false,
)
.unwrap();
assert!(manifest.is_empty());
assert!(plan.actions.is_empty());
assert!(
!dir.exists(),
"dry-run path must not create the destination directory"
);
}
#[test]
fn implicit_without_token_errors() {
let dest = PathBuf::from("/music");
let s = sel(false, None, Some(&dest), false);
assert!(plan_targets(None, &s).is_err());
}
#[test]
fn implicit_without_dest_errors() {
let s = sel(false, None, None, true);
assert!(plan_targets(None, &s).is_err());
}
#[test]
fn account_uses_dest_then_root() {
let cfg = config_with(&[("alice", Some("/lib/alice"))]);
let dest = PathBuf::from("/override");
let with_dest =
plan_targets(Some(&cfg), &sel(false, Some("alice"), Some(&dest), true)).unwrap();
assert_eq!(with_dest[0].dest, dest);
let from_root = plan_targets(Some(&cfg), &sel(false, Some("alice"), None, true)).unwrap();
assert_eq!(from_root[0].dest, PathBuf::from("/lib/alice"));
}
#[test]
fn account_without_dest_or_root_errors() {
let cfg = config_with(&[("alice", None)]);
assert!(plan_targets(Some(&cfg), &sel(false, Some("alice"), None, true)).is_err());
}
#[test]
fn unknown_account_errors_with_listing() {
let cfg = config_with(&[("alice", Some("/a")), ("bob", Some("/b"))]);
let err = plan_targets(Some(&cfg), &sel(false, Some("carol"), None, true)).unwrap_err();
assert!(err.contains("carol"));
assert!(err.contains("alice"));
assert!(err.contains("bob"));
}
#[test]
fn all_runs_every_account_from_roots() {
let cfg = config_with(&[("alice", Some("/a")), ("bob", Some("/b"))]);
let targets = plan_targets(Some(&cfg), &sel(true, None, None, true)).unwrap();
assert_eq!(targets.len(), 2);
assert!(targets.iter().all(|t| !t.implicit));
assert_eq!(targets[0].label, "alice");
assert_eq!(targets[1].label, "bob");
}
#[test]
fn all_rejects_dest() {
let cfg = config_with(&[("alice", Some("/a"))]);
let dest = PathBuf::from("/x");
assert!(plan_targets(Some(&cfg), &sel(true, None, Some(&dest), true)).is_err());
}
#[test]
fn all_requires_roots() {
let cfg = config_with(&[("alice", None)]);
assert!(plan_targets(Some(&cfg), &sel(true, None, None, true)).is_err());
}
#[test]
fn all_without_config_errors() {
assert!(plan_targets(None, &sel(true, None, None, true)).is_err());
}
#[test]
fn single_account_config_is_used_implicitly() {
let cfg = config_with(&[("solo", Some("/solo"))]);
let targets = plan_targets(Some(&cfg), &sel(false, None, None, false)).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].label, "solo");
assert!(!targets[0].implicit);
}
#[test]
fn multiple_accounts_need_selection() {
let cfg = config_with(&[("alice", Some("/a")), ("bob", Some("/b"))]);
let err = plan_targets(Some(&cfg), &sel(false, None, None, true)).unwrap_err();
assert!(err.contains("--account"));
assert!(err.contains("--all"));
}
#[test]
fn worse_prefers_higher_code() {
assert_eq!(worse(ExitCode::Ok, ExitCode::Partial), ExitCode::Partial);
assert_eq!(worse(ExitCode::Safety, ExitCode::Auth), ExitCode::Safety);
assert_eq!(worse(ExitCode::Ok, ExitCode::Ok), ExitCode::Ok);
}
#[test]
fn verb_modes_and_labels() {
assert_eq!(Verb::Sync.mode(), SourceMode::Mirror);
assert_eq!(Verb::Check.mode(), SourceMode::Mirror);
assert_eq!(Verb::Copy.mode(), SourceMode::Copy);
assert_eq!(Verb::Copy.summary_label(), "Copy");
}
#[test]
fn artifact_only_deletes_drive_the_confirmation_gate() {
use suno_core::{Action, ArtifactKind, Plan};
let plan = Plan {
actions: (0..3)
.map(|i| Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: format!("c{i}/cover.jpg"),
owner_id: format!("c{i}"),
})
.collect(),
};
let delete_count = plan.deletes() + plan.artifact_deletes();
assert_eq!(plan.deletes(), 0);
assert_eq!(delete_count, 3);
assert_eq!(
confirm_decision(true, delete_count, false, true),
Confirm::Prompt
);
assert_eq!(
confirm_decision(true, delete_count, false, false),
Confirm::RefuseNonInteractive
);
assert_eq!(
confirm_decision(true, delete_count, true, false),
Confirm::Proceed
);
assert_eq!(
deletion_paths(&plan),
vec!["c0/cover.jpg", "c1/cover.jpg", "c2/cover.jpg"]
);
}
#[test]
fn deletion_paths_lists_both_audio_and_sidecar_removals() {
use suno_core::{Action, ArtifactKind, Plan};
let plan = Plan {
actions: vec![
Action::Delete {
path: "a.flac".to_owned(),
clip_id: "a".to_owned(),
},
Action::DeleteArtifact {
kind: ArtifactKind::CoverJpg,
path: "a/cover.jpg".to_owned(),
owner_id: "a".to_owned(),
},
Action::Skip {
clip_id: "z".to_owned(),
},
],
};
assert_eq!(deletion_paths(&plan), vec!["a.flac", "a/cover.jpg"]);
}
#[tokio::test]
async fn allow_account_change_is_rejected_on_check_before_any_network() {
let global = GlobalArgs::default();
let args = SyncArgs {
allow_account_change: true,
..Default::default()
};
let target = TargetSpec {
label: "alice".to_owned(),
dest: PathBuf::from("/nonexistent-check-guard"),
implicit: false,
};
let flags = FlagOverrides::default();
let env = HashMap::new();
let code = run_one(
Verb::Check,
&global,
&args,
&target,
None,
&flags,
&env,
false,
)
.await
.unwrap();
assert_eq!(code, ExitCode::Usage);
}
#[tokio::test]
async fn allow_account_change_is_rejected_on_dry_run() {
let global = GlobalArgs {
dry_run: true,
..Default::default()
};
let args = SyncArgs {
allow_account_change: true,
..Default::default()
};
let target = TargetSpec {
label: "alice".to_owned(),
dest: PathBuf::from("/nonexistent-dryrun-guard"),
implicit: false,
};
let flags = FlagOverrides::default();
let env = HashMap::new();
let code = run_one(
Verb::Sync,
&global,
&args,
&target,
None,
&flags,
&env,
false,
)
.await
.unwrap();
assert_eq!(code, ExitCode::Usage);
}
fn tclip(id: &str) -> Clip {
Clip {
id: id.to_owned(),
title: "Song".to_owned(),
handle: "alice".to_owned(),
..Default::default()
}
}
fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
AreaListing {
kind,
mode,
clips: ids.iter().map(|id| tclip(id)).collect(),
authoritative_ignoring_empty: authoritative,
}
}
#[test]
fn empty_mirror_area_is_not_enumerated() {
let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
assert!(!area_enumerated(&mirror, false));
let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
assert!(area_enumerated(©, false));
let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
assert!(area_enumerated(&full, false));
}
#[test]
fn library_authoritative_counts_protector_not_off() {
let with_protector = vec![
area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&["pl"],
true,
),
];
assert!(library_authoritative(&with_protector, false));
let off = vec![area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&["pl"],
true,
)];
assert!(!library_authoritative(&off, false));
}
#[test]
fn union_keeps_first_area_payload() {
let mut lib = tclip("shared");
lib.title = "Library".to_owned();
let mut pl = tclip("shared");
pl.title = "Playlist".to_owned();
let areas = vec![
AreaListing {
kind: AreaKind::Library,
mode: SourceMode::Copy,
clips: vec![lib, tclip("lib-only")],
authoritative_ignoring_empty: true,
},
AreaListing {
kind: AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
mode: SourceMode::Mirror,
clips: vec![pl],
authoritative_ignoring_empty: true,
},
];
let union = union_clips(&areas);
assert_eq!(union.len(), 2);
assert_eq!(union[0].id, "shared");
assert_eq!(union[0].title, "Library");
assert_eq!(union[1].id, "lib-only");
}
#[test]
fn mirror_playlist_protects_library_exclusive_files() {
use suno_core::{LocalFile, Manifest, ManifestEntry, reconcile};
let selection = resolve_selection(
SourceMode::Mirror,
Some(SourceMode::Mirror),
false,
&["holiday".to_owned()],
None,
false,
);
assert!(selection.library.unwrap().protector);
let areas = vec![
area(
AreaKind::Library,
SourceMode::Copy,
&["lib-only", "shared"],
true,
),
area(
AreaKind::Playlist {
id: "holiday".into(),
name: "Holiday".into(),
},
SourceMode::Mirror,
&["shared", "pl-only"],
true,
),
];
let force_copy = false;
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, force_copy),
fully_enumerated: area_enumerated(a, force_copy),
})
.collect();
assert!(deletion_allowed(&sources), "armed and fully enumerated");
let area_modes: Vec<(SourceMode, Vec<String>)> = areas
.iter()
.map(|a| {
(
area_mode(a, force_copy),
a.clips.iter().map(|c| c.id.clone()).collect(),
)
})
.collect();
let modes = build_modes_by_id(&area_modes);
assert_eq!(modes["lib-only"], vec![SourceMode::Copy]);
assert_eq!(modes["shared"], vec![SourceMode::Mirror, SourceMode::Copy]);
assert_eq!(modes["pl-only"], vec![SourceMode::Mirror]);
let union = union_clips(&areas);
let desired = build_desired(
&union.iter().collect::<Vec<_>>(),
suno_core::AudioFormat::Flac,
&modes,
&HashMap::new(),
&BTreeSet::new(),
ArtifactToggles::default(),
&suno_core::NamingConfig::default(),
);
let mut manifest = Manifest::new();
for id in ["lib-only", "shared", "pl-only", "gone-orphan"] {
manifest.insert(
id,
ManifestEntry {
path: format!("{id}.flac"),
format: suno_core::AudioFormat::Flac,
size: 100,
..Default::default()
},
);
}
let local: HashMap<String, LocalFile> = manifest
.iter()
.map(|(id, _)| {
(
id.clone(),
LocalFile {
exists: true,
size: 100,
},
)
})
.collect();
let plan = reconcile(&manifest, &desired, &local, &sources);
let deleted: Vec<&str> = plan
.actions
.iter()
.filter_map(|a| match a {
suno_core::Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
_ => None,
})
.collect();
assert_eq!(deleted, vec!["gone-orphan"]);
}
#[test]
fn a_failed_area_suppresses_deletion_for_the_run() {
let areas = [
area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Mirror,
&[],
false,
),
];
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, false),
fully_enumerated: area_enumerated(a, false),
})
.collect();
assert!(!deletion_allowed(&sources));
}
#[test]
fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
use suno_core::{LocalFile, Manifest, ManifestEntry, reconcile};
let areas = vec![
area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
area(
AreaKind::Playlist {
id: "p".into(),
name: "P".into(),
},
SourceMode::Copy,
&["c-live"],
true,
),
];
let sources: Vec<SourceStatus> = areas
.iter()
.map(|a| SourceStatus {
mode: area_mode(a, false),
fully_enumerated: area_enumerated(a, false),
})
.collect();
assert!(deletion_allowed(&sources));
let area_modes: Vec<(SourceMode, Vec<String>)> = areas
.iter()
.map(|a| {
(
area_mode(a, false),
a.clips.iter().map(|c| c.id.clone()).collect(),
)
})
.collect();
let modes = build_modes_by_id(&area_modes);
let union = union_clips(&areas);
let desired = build_desired(
&union.iter().collect::<Vec<_>>(),
suno_core::AudioFormat::Flac,
&modes,
&HashMap::new(),
&BTreeSet::new(),
ArtifactToggles::default(),
&suno_core::NamingConfig::default(),
);
let mut manifest = Manifest::new();
for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
manifest.insert(
id,
ManifestEntry {
path: format!("{id}.flac"),
format: suno_core::AudioFormat::Flac,
size: 100,
preserve: id == "c-orphan",
..Default::default()
},
);
}
let local: HashMap<String, LocalFile> = manifest
.iter()
.map(|(id, _)| {
(
id.clone(),
LocalFile {
exists: true,
size: 100,
},
)
})
.collect();
let plan = reconcile(&manifest, &desired, &local, &sources);
let deleted: Vec<&str> = plan
.actions
.iter()
.filter_map(|a| match a {
suno_core::Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
_ => None,
})
.collect();
assert_eq!(deleted, vec!["m-orphan"]);
}
}