sunox 0.0.14

Generate AI music from your terminal via direct Suno web workflows
use std::collections::HashMap;

use super::SunoClient;
use super::types::{Clip, GenerateRequest, GenerateResponse, Model};
use crate::core::CliError;

const FEED_IDS_CHUNK_SIZE: usize = 2;

impl SunoClient {
    /// Submit a music generation request (custom mode or inspiration mode).
    /// Posts only to the current `/api/generate/v2-web/` route. The older
    /// `/api/generate/v2/` route returned `Token validation failed` after Suno
    /// migrated creates to `v2-web` server-side in the April 2026 capture.
    /// Wrapped in `with_auth_retry` so a single stale-JWT failure recovers
    /// transparently via Clerk refresh.
    #[cfg(test)]
    pub async fn generate(&self, req: &GenerateRequest) -> Result<Vec<Clip>, CliError> {
        let mut req = req.clone();
        self.prepare_generation_request(&mut req).await?;
        self.submit_prepared_generation(&req).await
    }

    pub(crate) async fn prepare_generation_request(
        &self,
        req: &mut GenerateRequest,
    ) -> Result<(), CliError> {
        if !req.metadata.user_tier.trim().is_empty() && req.mv != "auto" {
            return Ok(());
        }

        let info = match self.billing_info().await {
            Ok(info) => info,
            Err(error) => {
                if error.is_auth_or_rate_limit() {
                    return Err(error);
                }
                if req.mv == "auto" {
                    req.mv = "chirp-fenix".into();
                }
                return Ok(());
            }
        };

        if req.metadata.user_tier.trim().is_empty()
            && let Some(user_tier) = info.plan.id
            && !user_tier.trim().is_empty()
        {
            req.metadata.user_tier = user_tier.trim().to_string();
        }

        let uses_account_generation_model =
            matches!(req.task.as_deref(), None | Some("playlist_condition"));
        if !uses_account_generation_model || info.models.is_empty() {
            if req.mv == "auto" {
                req.mv = "chirp-fenix".into();
            }
            return Ok(());
        }

        let model = select_generation_model(&info.models, &req.mv)?;
        req.mv = model.external_key.clone();
        validate_generation_lengths(req, model)
    }

    pub(crate) async fn submit_prepared_generation(
        &self,
        req: &GenerateRequest,
    ) -> Result<Vec<Clip>, CliError> {
        self.ensure_generation_challenge(req.token.is_some())
            .await?;
        let body = serde_json::to_value(req)?;
        self.submit_generation_body(&body, req.token.is_some())
            .await
    }

    async fn ensure_generation_challenge(&self, has_token: bool) -> Result<(), CliError> {
        if !has_token {
            let mut challenge = self.generation_challenge().await?;
            if challenge.required && self.try_refresh_jwt_for_challenge_recheck().await? {
                challenge = self.generation_challenge().await?;
            }
            if challenge.required {
                return Err(generation_challenge_error(&challenge));
            }
        }
        Ok(())
    }

    async fn submit_generation_body(
        &self,
        body: &serde_json::Value,
        has_challenge_token: bool,
    ) -> Result<Vec<Clip>, CliError> {
        self.with_auth_retry(|| async {
            let resp = self.post("/api/generate/v2-web/").json(body).send().await?;
            let resp = self
                .check_generation_response(resp, has_challenge_token)
                .await?;
            let result: GenerateResponse = resp.json().await?;
            Ok(result.clips)
        })
        .await
    }

    /// Fetch clips by IDs. Batches in pairs because Suno's feed endpoint can
    /// drop results when queried with larger mixed batches.
    /// Each chunk is wrapped in `with_auth_retry` so explicit wait/status
    /// flows survive Suno's JWT staleness window mid-generation.
    pub async fn get_clips(&self, ids: &[String]) -> Result<Vec<Clip>, CliError> {
        let mut all_clips = Vec::new();
        for chunk in ids.chunks(FEED_IDS_CHUNK_SIZE) {
            let ids_param = chunk.join(",");
            let path = format!("/api/feed/?ids={ids_param}");
            let clips: Vec<Clip> = self
                .with_auth_retry(|| async {
                    let resp = self.get(&path).send().await?;
                    let resp = self.check_response(resp).await?;
                    let clips: Vec<Clip> = resp.json().await?;
                    Ok(clips)
                })
                .await?;
            all_clips.extend(clips);
        }
        let mut clips_by_id = all_clips
            .into_iter()
            .map(|clip| (clip.id.clone(), clip))
            .collect::<HashMap<_, _>>();
        Ok(ids.iter().filter_map(|id| clips_by_id.remove(id)).collect())
    }
}

fn select_generation_model<'a>(
    models: &'a [Model],
    requested: &str,
) -> Result<&'a Model, CliError> {
    let selected = if requested == "auto" {
        models
            .iter()
            .find(|model| model.can_use && model.is_default_model)
            .or_else(|| models.iter().find(|model| model.can_use))
    } else {
        models
            .iter()
            .find(|model| model.external_key == requested && model.can_use)
    };

    selected.ok_or_else(|| {
        let requested = if requested == "auto" {
            "an account default model".to_string()
        } else {
            format!("model `{requested}`")
        };
        CliError::Config(format!(
            "Suno account cannot use {requested}; run `sunox models --json` and select a model whose can_use field is true"
        ))
    })
}

fn validate_generation_lengths(req: &GenerateRequest, model: &Model) -> Result<(), CliError> {
    validate_length("title", req.title.as_deref(), model.max_lengths.title)?;
    validate_length("prompt", Some(&req.prompt), model.max_lengths.prompt)?;
    validate_length("tags", req.tags.as_deref(), model.max_lengths.tags)?;
    validate_length(
        "negative_tags",
        Some(&req.negative_tags),
        model.max_lengths.negative_tags,
    )?;
    validate_length(
        "gpt_description_prompt",
        req.gpt_description_prompt.as_deref(),
        model.max_lengths.gpt_description_prompt,
    )
}

fn validate_length(field: &str, value: Option<&str>, limit: u32) -> Result<(), CliError> {
    let length = value.map(|value| value.chars().count()).unwrap_or(0);
    if limit > 0 && length > limit as usize {
        return Err(CliError::Config(format!(
            "generation field `{field}` is {length} characters, exceeding the account limit of {limit} for the selected model"
        )));
    }
    Ok(())
}

fn generation_challenge_error(challenge: &super::challenge::GenerationChallenge) -> CliError {
    let version = challenge
        .captcha_version
        .map(|version| version.to_string())
        .unwrap_or_else(|| "unknown".to_string());
    CliError::Config(format!(
        "Suno requires a generation challenge (captcha_version={version}). When stored Clerk refresh material is available, Sunox refreshes the JWT once and repeats the challenge preflight before showing this message. Complete a manual generation challenge in the Suno web app and retry, provide a valid challenge token with --token <token>, or force the browser-backed solver with --captcha."
    ))
}

#[cfg(test)]
mod tests {
    use super::{FEED_IDS_CHUNK_SIZE, select_generation_model, validate_generation_lengths};
    use crate::api::types::{GenerateRequest, MaxLengths, Model};

    fn model(can_use: bool, is_default_model: bool, max_lengths: MaxLengths) -> Model {
        Model {
            name: "v4.5-all".into(),
            external_key: "chirp-auk-turbo".into(),
            can_use,
            is_default_model,
            description: "fixture".into(),
            max_lengths,
        }
    }

    #[test]
    fn feed_id_batch_size_documents_current_web_limit() {
        assert_eq!(FEED_IDS_CHUNK_SIZE, 2);
    }

    #[test]
    fn account_model_selection_rejects_unusable_explicit_model() {
        let models = [model(false, true, MaxLengths::default())];

        let error = select_generation_model(&models, "chirp-auk-turbo")
            .expect_err("unusable model must be rejected");

        assert!(error.to_string().contains("cannot use"));
    }

    #[test]
    fn generation_limits_count_characters_instead_of_utf8_bytes() {
        let selected = model(
            true,
            true,
            MaxLengths {
                title: 2,
                ..MaxLengths::default()
            },
        );
        let mut request = GenerateRequest::new("chirp-auk-turbo", "custom");
        request.title = Some("中文歌".into());

        let error = validate_generation_lengths(&request, &selected)
            .expect_err("three characters exceed a two-character limit");

        assert!(error.to_string().contains("3 characters"));
    }
}