1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
use super::SunoClient;
use super::types::{Clip, GenerateRequest, GenerateResponse};
use crate::errors::CliError;
impl SunoClient {
/// Submit a music generation request (custom mode or inspiration mode).
/// Posts to `/api/generate/v2-web/` — the legacy `/api/generate/v2/`
/// returns `Token validation failed` since Suno migrated creates to
/// `v2-web` server-side (verified 2026-04-07).
/// Wrapped in `with_auth_retry` so a single stale-JWT failure recovers
/// transparently via Clerk refresh.
pub async fn generate(&self, req: &GenerateRequest) -> Result<Vec<Clip>, CliError> {
self.with_auth_retry(|| async {
let resp = self.post("/api/generate/v2-web/").json(req).send().await?;
let resp = self.check_response(resp).await?;
let result: GenerateResponse = resp.json().await?;
Ok(result.clips)
})
.await
}
/// Poll clip status by IDs until all are complete or errored.
/// "streaming" means still generating — we wait for "complete".
pub async fn poll_clips(
&self,
ids: &[String],
timeout_secs: u64,
) -> Result<Vec<Clip>, CliError> {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
let mut delay = std::time::Duration::from_secs(3);
loop {
let clips = self.get_clips(ids).await?;
let all_done = clips
.iter()
.all(|c| matches!(c.status.as_str(), "complete" | "error"));
if all_done {
return Ok(clips);
}
if start.elapsed() >= timeout {
return Err(CliError::GenerationFailed(format!(
"generation timed out after {timeout_secs}s for {}",
ids.join(", ")
)));
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(std::time::Duration::from_secs(15));
}
}
/// Fetch clips by IDs. Batches in pairs to avoid Suno's limit
/// (SunoAI-API #49: 4+ IDs from different batches only returns first 2).
/// Each chunk is wrapped in `with_auth_retry` so long polling waits
/// 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(2) {
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);
}
Ok(all_clips)
}
}