sunox 0.0.2

Generate AI music from your terminal via direct Suno web workflows
use crate::api::types::GenerateRequest;
use crate::app::AppContext;
use crate::cli::{CreateArgs, DescribeArgs, ExtendArgs, GenerateArgs, ModelVersion};
use crate::core::{AppConfig, CliError};
use crate::workflow::generation::{build_control_sliders, build_tags};

use super::support::{generation_token, output_clips};

pub async fn create(args: CreateArgs, ctx: &AppContext) -> Result<(), CliError> {
    if args.lyrics.is_some() || args.lyrics_file.is_some() {
        return generate(
            GenerateArgs {
                title: args.title,
                tags: args.tags,
                exclude: args.exclude,
                lyrics: args.lyrics,
                lyrics_file: args.lyrics_file,
                model: args.model,
                vocal: args.vocal,
                weirdness: args.weirdness,
                style_influence: args.style_influence,
                instrumental: args.instrumental,
                token: args.token,
                captcha: args.captcha,
                no_captcha: args.no_captcha,
                persona: args.persona,
            },
            ctx,
        )
        .await;
    }

    let prompt = args
        .prompt
        .ok_or_else(|| CliError::Config("provide a prompt or --lyrics/--lyrics-file".into()))?;
    describe(
        DescribeArgs {
            title: args.title,
            prompt,
            tags: args.tags,
            model: args.model,
            vocal: args.vocal,
            weirdness: args.weirdness,
            style_influence: args.style_influence,
            instrumental: args.instrumental,
            captcha: args.captcha,
            no_captcha: args.no_captcha,
            persona: args.persona,
        },
        ctx,
    )
    .await
}

async fn generate(args: GenerateArgs, ctx: &AppContext) -> Result<(), CliError> {
    let mut req = build_generate_request(&args, &ctx.config)?;
    let force_captcha = args.captcha && !args.no_captcha;
    req.set_challenge_token(generation_token(args.token.clone(), force_captcha, ctx).await?);

    if !ctx.quiet {
        let persona_note = if args.persona.is_some() {
            " with voice persona"
        } else {
            ""
        };
        eprintln!(
            "Submitting generation ({}{persona_note})...",
            model_label(args.model.as_ref(), &ctx.config)
        );
    }
    let client = ctx.client().await?;
    let clips = client.generate(&req).await?;
    output_clips(&clips, ctx);
    Ok(())
}

fn build_generate_request(
    args: &GenerateArgs,
    config: &AppConfig,
) -> Result<GenerateRequest, CliError> {
    let lyrics = match (&args.lyrics, &args.lyrics_file) {
        (Some(l), _) => Some(l.clone()),
        (_, Some(path)) => Some(std::fs::read_to_string(path)?),
        _ => None,
    };
    let tags = build_tags(args.tags.as_deref(), args.vocal.as_ref());
    let control_sliders = build_control_sliders(args.weirdness, args.style_influence);

    let mut req = GenerateRequest::new(model_api_key(args.model.as_ref(), config), "custom");
    if let (Some(lyrics), false) = (lyrics, args.instrumental) {
        req.gpt_description_prompt = Some(lyrics);
        req.metadata.lyrics_model = Some("default".into());
    }
    req.title = args.title.clone();
    req.tags = tags;
    req.negative_tags = args.exclude.clone().unwrap_or_default();
    req.make_instrumental = args.instrumental;
    req.persona_id = args.persona.clone();
    req.metadata.control_sliders = control_sliders;
    Ok(req)
}

async fn describe(args: DescribeArgs, ctx: &AppContext) -> Result<(), CliError> {
    let mut req = build_describe_request(&args, &ctx.config);
    let force_captcha = args.captcha && !args.no_captcha;
    req.set_challenge_token(generation_token(None, force_captcha, ctx).await?);

    if !ctx.quiet {
        eprintln!(
            "Submitting description ({})...",
            model_label(args.model.as_ref(), &ctx.config)
        );
    }
    let client = ctx.client().await?;
    let clips = client.generate(&req).await?;
    output_clips(&clips, ctx);
    Ok(())
}

fn build_describe_request(args: &DescribeArgs, config: &AppConfig) -> GenerateRequest {
    let tags = build_tags(args.tags.as_deref(), args.vocal.as_ref());
    let control_sliders = build_control_sliders(args.weirdness, args.style_influence);

    let mut req = GenerateRequest::new(model_api_key(args.model.as_ref(), config), "inspiration");
    req.prompt = args.prompt.clone();
    req.title = Some(args.title.clone().unwrap_or_default());
    req.tags = tags;
    req.make_instrumental = args.instrumental;
    req.persona_id = args.persona.clone();
    req.metadata.control_sliders = control_sliders;
    req
}

fn model_api_key<'a>(model: Option<&'a ModelVersion>, config: &'a AppConfig) -> &'a str {
    model
        .map(ModelVersion::to_api_key)
        .unwrap_or(config.default_model.as_str())
}

fn model_label<'a>(model: Option<&'a ModelVersion>, config: &'a AppConfig) -> &'a str {
    model
        .map(ModelVersion::display_name)
        .unwrap_or(config.default_model.as_str())
}

pub async fn extend(args: ExtendArgs, ctx: &AppContext) -> Result<(), CliError> {
    let mut req = GenerateRequest::new("chirp-fenix", "custom");
    req.prompt = args.lyrics.unwrap_or_default();
    req.tags = args.tags;
    req.continue_clip_id = Some(args.clip_id);
    req.continue_at = Some(args.at);

    let client = ctx.client().await?;
    let clips = client.generate(&req).await?;
    output_clips(&clips, ctx);
    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::cli::{DescribeArgs, ModelVersion};
    use crate::core::AppConfig;

    use super::{build_describe_request, build_generate_request};

    fn config_with_default_model(default_model: &str) -> AppConfig {
        AppConfig {
            default_model: default_model.to_string(),
            ..AppConfig::default()
        }
    }

    fn describe_args(title: Option<String>, model: Option<ModelVersion>) -> DescribeArgs {
        DescribeArgs {
            title,
            prompt: "bright city pop about a clean morning".into(),
            tags: Some("city pop, bright".into()),
            model,
            vocal: None,
            weirdness: None,
            style_influence: None,
            instrumental: false,
            captcha: false,
            no_captcha: false,
            persona: None,
        }
    }

    #[test]
    fn describe_request_sends_empty_title_by_default() {
        let config = AppConfig::default();

        let req = build_describe_request(&describe_args(None, Some(ModelVersion::V55)), &config);

        let body = serde_json::to_value(req).expect("request json");
        assert_eq!(body["title"], "");
        assert_eq!(body["metadata"]["create_mode"], "inspiration");
    }

    #[test]
    fn describe_request_uses_supplied_title() {
        let config = AppConfig::default();

        let req = build_describe_request(
            &describe_args(Some("Morning Reset".into()), Some(ModelVersion::V55)),
            &config,
        );

        let body = serde_json::to_value(req).expect("request json");
        assert_eq!(body["title"], "Morning Reset");
    }

    #[test]
    fn describe_request_uses_config_default_model_when_flag_is_omitted() {
        let config = config_with_default_model("chirp-crow");

        let req = build_describe_request(&describe_args(None, None), &config);

        let body = serde_json::to_value(req).expect("request json");
        assert_eq!(body["mv"], "chirp-crow");
    }

    #[test]
    fn generate_request_uses_config_default_model_when_flag_is_omitted() {
        let args = crate::cli::GenerateArgs {
            title: Some("Morning Reset".into()),
            tags: Some("city pop".into()),
            exclude: None,
            lyrics: Some("[Verse]\nHello".into()),
            lyrics_file: None,
            model: None,
            vocal: None,
            weirdness: None,
            style_influence: None,
            instrumental: false,
            token: None,
            captcha: false,
            no_captcha: false,
            persona: None,
        };
        let config = config_with_default_model("chirp-crow");

        let req = build_generate_request(&args, &config).expect("request");

        let body = serde_json::to_value(req).expect("request json");
        assert_eq!(body["mv"], "chirp-crow");
        assert_eq!(body["prompt"], "");
        assert_eq!(body["gpt_description_prompt"], "[Verse]\nHello");
        assert_eq!(body["metadata"]["lyrics_model"], "default");
        assert!(
            body.as_object()
                .expect("object")
                .contains_key("token_provider")
        );
        assert!(body["token_provider"].is_null());
    }

    #[test]
    fn instrumental_generate_request_omits_custom_lyrics_fields() {
        let args = crate::cli::GenerateArgs {
            title: Some("Morning Reset".into()),
            tags: Some("city pop".into()),
            exclude: None,
            lyrics: Some("[Verse]\nHello".into()),
            lyrics_file: None,
            model: None,
            vocal: None,
            weirdness: None,
            style_influence: None,
            instrumental: true,
            token: None,
            captcha: false,
            no_captcha: false,
            persona: None,
        };
        let config = config_with_default_model("chirp-crow");

        let req = build_generate_request(&args, &config).expect("request");

        let body = serde_json::to_value(req).expect("request json");
        assert_eq!(body["prompt"], "");
        assert!(
            !body
                .as_object()
                .expect("object")
                .contains_key("gpt_description_prompt")
        );
        assert!(
            !body["metadata"]
                .as_object()
                .expect("metadata object")
                .contains_key("lyrics_model")
        );
    }
}