sunox 0.0.10

Generate AI music from your terminal via direct Suno web workflows
use std::time::Duration;

use tokio::time::sleep;

use super::session::CdpSession;
use crate::auth::AuthState;
use crate::captcha::SUNO_HCAPTCHA_SITEKEY;
use crate::captcha::cookies::extract_cookies;
use crate::core::CliError;

pub(in crate::captcha) async fn render_and_execute(
    ws_url: &str,
    auth: &AuthState,
) -> Result<String, CliError> {
    let mut session = CdpSession::connect(ws_url).await?;

    session
        .call("Network.enable", serde_json::json!({}))
        .await?;
    session.call("Page.enable", serde_json::json!({})).await?;
    session
        .call("Runtime.enable", serde_json::json!({}))
        .await?;
    session
        .call(
            "Emulation.setDeviceMetricsOverride",
            serde_json::json!({
                "width": 1280,
                "height": 900,
                "deviceScaleFactor": 1,
                "mobile": false
            }),
        )
        .await?;

    session
        .call("Network.clearBrowserCookies", serde_json::json!({}))
        .await?;

    let cookies = extract_cookies(auth)?;
    if !cookies.is_empty() {
        session
            .call(
                "Network.setCookies",
                serde_json::json!({ "cookies": cookies }),
            )
            .await?;
    }

    session
        .call(
            "Page.navigate",
            serde_json::json!({ "url": "https://suno.com/create" }),
        )
        .await?;

    wait_for_hcaptcha(&mut session).await?;
    sleep(Duration::from_secs(2)).await;

    let result = session
        .call(
            "Runtime.evaluate",
            serde_json::json!({
                "expression": solve_script(),
                "awaitPromise": true,
                "returnByValue": true,
            }),
        )
        .await?;

    let token = result
        .get("result")
        .and_then(|result| result.get("value"))
        .and_then(|value| value.as_str())
        .unwrap_or("")
        .to_string();

    if token.is_empty() {
        return Err(CliError::Config("hcaptcha returned empty token".into()));
    }
    if token.starts_with("ERR:") {
        return Err(CliError::Config(format!("hcaptcha solver: {token}")));
    }
    Ok(token)
}

async fn wait_for_hcaptcha(session: &mut CdpSession) -> Result<(), CliError> {
    for _ in 0..30 {
        sleep(Duration::from_secs(1)).await;
        let probe = session
            .call(
                "Runtime.evaluate",
                serde_json::json!({
                    "expression": "typeof hcaptcha !== 'undefined' && !!hcaptcha.render",
                    "returnByValue": true,
                }),
            )
            .await?;
        if probe
            .get("result")
            .and_then(|result| result.get("value"))
            .and_then(|value| value.as_bool())
            .unwrap_or(false)
        {
            return Ok(());
        }
    }

    let page_state = page_state_excerpt(session).await?;
    Err(CliError::Config(format!(
        "hcaptcha never finished loading on suno.com/create ({page_state})"
    )))
}

fn solve_script() -> String {
    format!(
        r#"
        (async () => {{
            try {{
                const div = document.createElement('div');
                div.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
                document.body.appendChild(div);
                const id = hcaptcha.render(div, {{
                    sitekey: '{SUNO_HCAPTCHA_SITEKEY}',
                    size: 'invisible',
                    sentry: false,
                    endpoint: 'https://hcaptcha-endpoint-prod.suno.com',
                    assethost: 'https://hcaptcha-assets-prod.suno.com',
                    imghost: 'https://hcaptcha-imgs-prod.suno.com',
                    reportapi: 'https://hcaptcha-reportapi-prod.suno.com',
                }});
                const r = await hcaptcha.execute(id, {{ async: true }});
                return (r && r.response) ? r.response : '';
            }} catch (e) {{
                return 'ERR:' + String(e);
            }}
        }})()
        "#
    )
}

async fn page_state_excerpt(session: &mut CdpSession) -> Result<String, CliError> {
    let state = session
        .call(
            "Runtime.evaluate",
            serde_json::json!({
                "expression": "JSON.stringify({ href: location.href, body: (document.body && document.body.innerText || '').slice(0, 240) })",
                "returnByValue": true,
            }),
        )
        .await?;
    let raw = state
        .get("result")
        .and_then(|result| result.get("value"))
        .and_then(|value| value.as_str())
        .unwrap_or("{}");
    let parsed: serde_json::Value = serde_json::from_str(raw).unwrap_or_default();
    let href = parsed
        .get("href")
        .and_then(|value| value.as_str())
        .unwrap_or("");
    let body = parsed
        .get("body")
        .and_then(|value| value.as_str())
        .unwrap_or("")
        .replace(['\n', '\r'], " ");
    if body.is_empty() {
        Ok(format!("page={href}"))
    } else {
        Ok(format!("page={href}; body={body}"))
    }
}