sunox 0.0.14

Generate AI music from your terminal via direct Suno web workflows
use serde_json::{Value, json};

use crate::api::types::{ClipReaction, SetMetadataRequest};
use crate::app::AppContext;
use crate::cli::{
    DeleteArgs, EmptyTrashArgs, PublishArgs, PurgeArgs, ReactionArgs, RestoreArgs, SetArgs,
};
use crate::core::{CliError, ensure_clip_ids, ensure_destructive_confirmed};
use crate::output::{self, OutputFormat};
use crate::workflow::image_upload;

pub async fn delete(args: DeleteArgs, ctx: &AppContext) -> Result<(), CliError> {
    ensure_clip_ids(&args.ids)?;
    ensure_destructive_confirmed(args.yes, "sunox clip delete")?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    client.delete_clips(&args.ids).await?;
    match ctx.fmt {
        OutputFormat::Json => output::json::success(clip_ids_result(&args.ids, "deleted", true)),
        OutputFormat::Table => eprintln!("Deleted {} clip(s)", args.ids.len()),
    }
    Ok(())
}

pub async fn restore(args: RestoreArgs, ctx: &AppContext) -> Result<(), CliError> {
    ensure_clip_ids(&args.ids)?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    client.restore_clips(&args.ids).await?;
    match ctx.fmt {
        OutputFormat::Json => output::json::success(clip_ids_result(&args.ids, "restored", true)),
        OutputFormat::Table => eprintln!("Restored {} clip(s)", args.ids.len()),
    }
    Ok(())
}

pub async fn purge(args: PurgeArgs, ctx: &AppContext) -> Result<(), CliError> {
    ensure_clip_ids(&args.ids)?;
    ensure_destructive_confirmed(args.yes, "sunox clip purge")?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    client.purge_clips(&args.ids).await?;
    match ctx.fmt {
        OutputFormat::Json => output::json::success(clip_ids_result(&args.ids, "purged", true)),
        OutputFormat::Table => eprintln!("Permanently deleted {} clip(s)", args.ids.len()),
    }
    Ok(())
}

pub async fn empty_trash(args: EmptyTrashArgs, ctx: &AppContext) -> Result<(), CliError> {
    ensure_destructive_confirmed(args.yes, "sunox clip empty-trash")?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    let purged = client.empty_clip_trash().await?;

    match ctx.fmt {
        OutputFormat::Json => output::json::success(json!({
            "purged_clip_ids": purged,
            "purged": true
        })),
        OutputFormat::Table => eprintln!("Permanently deleted {} trashed clip(s)", purged.len()),
    }
    Ok(())
}

pub async fn like(args: ReactionArgs, ctx: &AppContext) -> Result<(), CliError> {
    react(args, ctx, ClipReaction::Like).await
}

pub async fn dislike(args: ReactionArgs, ctx: &AppContext) -> Result<(), CliError> {
    react(args, ctx, ClipReaction::Dislike).await
}

pub async fn set(args: SetArgs, ctx: &AppContext) -> Result<(), CliError> {
    let changes = set_changed_fields(&args);
    if changes.is_empty() {
        return Err(CliError::Config(
            "provide at least one metadata field: --title, --lyrics, --lyrics-file, --caption, --image-url, --image-file, --remove-cover, or --remove-video-cover".into(),
        ));
    }

    let lyrics = match (&args.lyrics, &args.lyrics_file) {
        (Some(l), _) => Some(l.clone()),
        (_, Some(path)) => Some(std::fs::read_to_string(path)?),
        _ => None,
    };
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    let uploaded_cover = if let Some(image_file) = args.image_file.as_deref() {
        if !ctx.quiet {
            eprintln!("Uploading clip cover image...");
        }
        Some(image_upload::run(&client, image_file).await?)
    } else {
        None
    };
    let image_url = uploaded_cover
        .as_ref()
        .map(|uploaded| uploaded.image_url.clone())
        .or_else(|| args.image_url.clone());
    let req = SetMetadataRequest {
        title: args.title.clone(),
        lyrics,
        caption: args.caption.clone(),
        image_url,
        is_audio_upload_tos_accepted: None,
        remove_image_cover: if args.remove_cover { Some(true) } else { None },
        remove_video_cover: if args.remove_video_cover {
            Some(true)
        } else {
            None
        },
    };
    match uploaded_cover.as_ref() {
        Some(cover) => {
            image_upload::apply_uploaded_cover_to_clip(&client, &args.id, &req, cover).await?
        }
        None => client.set_metadata(&args.id, &req).await?,
    }
    match ctx.fmt {
        OutputFormat::Json => output::json::success(set_result(&args.id, &changes)),
        OutputFormat::Table => eprintln!("Updated: {}", changes.join(", ")),
    }
    Ok(())
}

pub async fn publish(args: PublishArgs, ctx: &AppContext) -> Result<(), CliError> {
    ensure_clip_ids(&args.ids)?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    let is_public = !args.private;
    let mut succeeded = Vec::with_capacity(args.ids.len());
    for (index, id) in args.ids.iter().enumerate() {
        if let Err(error) = client.set_visibility(id, is_public).await {
            return Err(clip_mutation_error(
                if is_public { "publish" } else { "unpublish" },
                &succeeded,
                id,
                &args.ids[index + 1..],
                error,
            ));
        }
        succeeded.push(id.clone());
    }
    let state = if is_public { "public" } else { "private" };
    match ctx.fmt {
        OutputFormat::Json => output::json::success(json!({
            "clip_ids": args.ids,
            "is_public": is_public
        })),
        OutputFormat::Table => eprintln!("Set {} clip(s) to {state}", args.ids.len()),
    }
    Ok(())
}

async fn react(
    args: ReactionArgs,
    ctx: &AppContext,
    reaction: ClipReaction,
) -> Result<(), CliError> {
    ensure_clip_ids(&args.ids)?;
    let (client, _mutation_guard) = ctx.mutation_client().await?;
    let next_reaction = if args.clear { None } else { Some(reaction) };
    let operation = match (reaction, args.clear) {
        (ClipReaction::Like, false) => "like",
        (ClipReaction::Like, true) => "clear_like",
        (ClipReaction::Dislike, false) => "dislike",
        (ClipReaction::Dislike, true) => "clear_dislike",
    };
    let mut succeeded = Vec::with_capacity(args.ids.len());
    for (index, id) in args.ids.iter().enumerate() {
        if let Err(error) = client.set_clip_reaction(id, next_reaction).await {
            return Err(clip_mutation_error(
                operation,
                &succeeded,
                id,
                &args.ids[index + 1..],
                error,
            ));
        }
        succeeded.push(id.clone());
    }
    let action = match (reaction, args.clear) {
        (ClipReaction::Like, false) => "Liked",
        (ClipReaction::Like, true) => "Cleared like for",
        (ClipReaction::Dislike, false) => "Disliked",
        (ClipReaction::Dislike, true) => "Cleared dislike for",
    };
    match ctx.fmt {
        OutputFormat::Json => {
            output::json::success(reaction_result(&args.ids, reaction, args.clear))
        }
        OutputFormat::Table => eprintln!("{action} {} clip(s)", args.ids.len()),
    }
    Ok(())
}

fn clip_ids_result(ids: &[String], key: &str, value: bool) -> Value {
    let mut result = serde_json::Map::new();
    result.insert("clip_ids".to_string(), json!(ids));
    result.insert(key.to_string(), json!(value));
    Value::Object(result)
}

fn clip_mutation_error(
    operation: &str,
    succeeded_clip_ids: &[String],
    failed_clip_id: &str,
    not_attempted_clip_ids: &[String],
    error: CliError,
) -> CliError {
    if succeeded_clip_ids.is_empty() {
        return error;
    }

    CliError::PartialMutation {
        message: format!(
            "{operation} completed for {} clip(s), failed for {failed_clip_id}, and left {} clip(s) not attempted",
            succeeded_clip_ids.len(),
            not_attempted_clip_ids.len()
        ),
        details: json!({
            "operation": operation,
            "succeeded_clip_ids": succeeded_clip_ids,
            "failed": {
                "clip_id": failed_clip_id,
                "code": error.error_code(),
                "message": error.to_string()
            },
            "not_attempted_clip_ids": not_attempted_clip_ids
        }),
    }
}

fn reaction_result(ids: &[String], reaction: ClipReaction, cleared: bool) -> Value {
    json!({
        "clip_ids": ids,
        "reaction": reaction.as_api_value(),
        "cleared": cleared
    })
}

fn set_changed_fields(args: &SetArgs) -> Vec<&'static str> {
    let mut changes = Vec::new();
    if args.title.is_some() {
        changes.push("title");
    }
    if args.lyrics.is_some() || args.lyrics_file.is_some() {
        changes.push("lyrics");
    }
    if args.caption.is_some() {
        changes.push("caption");
    }
    if args.image_url.is_some() || args.image_file.is_some() || args.remove_cover {
        changes.push("cover");
    }
    if args.remove_video_cover {
        changes.push("video_cover");
    }
    changes
}

fn set_result(clip_id: &str, changes: &[&str]) -> Value {
    json!({
        "clip_id": clip_id,
        "updated": changes
    })
}

#[cfg(test)]
mod tests {
    use super::{
        clip_ids_result, clip_mutation_error, reaction_result, set_changed_fields, set_result,
    };
    use crate::api::types::ClipReaction;
    use crate::cli::SetArgs;

    #[test]
    fn delete_result_reports_deleted_clip_ids() {
        let ids = vec!["clip-a".to_string(), "clip-b".to_string()];

        let value = clip_ids_result(&ids, "deleted", true);

        assert_eq!(
            value,
            serde_json::json!({
                "clip_ids": ["clip-a", "clip-b"],
                "deleted": true
            })
        );
    }

    #[test]
    fn later_serial_clip_mutation_failure_reports_recoverable_progress() {
        let error = clip_mutation_error(
            "publish",
            &["clip-a".into()],
            "clip-b",
            &["clip-c".into()],
            crate::core::CliError::Api {
                code: "api_error",
                message: "server rejected mutation".into(),
            },
        );

        assert_eq!(error.error_code(), "partial_mutation");
        assert_eq!(
            error.details().expect("partial mutation details"),
            &serde_json::json!({
                "operation": "publish",
                "succeeded_clip_ids": ["clip-a"],
                "failed": {
                    "clip_id": "clip-b",
                    "code": "api_error",
                    "message": "API error: server rejected mutation"
                },
                "not_attempted_clip_ids": ["clip-c"]
            })
        );
    }

    #[test]
    fn first_serial_clip_mutation_failure_preserves_semantic_error() {
        let error = clip_mutation_error(
            "like",
            &[],
            "clip-a",
            &["clip-b".into()],
            crate::core::CliError::RateLimited,
        );

        assert_eq!(error.error_code(), "rate_limited");
    }

    #[test]
    fn reaction_result_reports_clear_state() {
        let ids = vec!["clip-a".to_string()];

        let value = reaction_result(&ids, ClipReaction::Dislike, true);

        assert_eq!(
            value,
            serde_json::json!({
                "clip_ids": ["clip-a"],
                "reaction": "DISLIKE",
                "cleared": true
            })
        );
    }

    #[test]
    fn set_result_reports_changed_fields() {
        let value = set_result("clip-a", &["title", "lyrics"]);

        assert_eq!(
            value,
            serde_json::json!({
                "clip_id": "clip-a",
                "updated": ["title", "lyrics"]
            })
        );
    }

    #[test]
    fn set_changed_fields_reports_cover_updates() {
        let args = SetArgs {
            id: "clip-a".into(),
            title: None,
            lyrics: None,
            lyrics_file: None,
            caption: None,
            image_url: Some("https://cdn2.suno.ai/image_upload-1.jpeg".into()),
            image_file: None,
            remove_cover: false,
            remove_video_cover: true,
        };

        assert_eq!(set_changed_fields(&args), vec!["cover", "video_cover"]);
    }
}