Files
Genarrative/server-rs/crates/platform-audio/src/client.rs
T
kdletters 838d819c3d 修复Suno背景音乐下载失败
识别audiopipe中转地址并通过item_id获取稳定WAV文件

避免封面地址混入音频候选并跳过失败任务的额外探测

补充Suno两跳解析回归测试并更新设计与排障文档
2026-07-13 22:06:36 +08:00

646 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::error::Error;
use reqwest::header;
use serde_json::Value;
use crate::response::{
extract_audio_urls, extract_string_by_path, extract_suno_clip_ids, find_first_string_by_key,
is_failed_task_status, normalize_task_status,
};
use crate::{
AudioError, AudioTaskKind, AudioTaskResponse, BackgroundMusicTaskRequest,
EditorBackgroundMusicTaskRequest, EditorSoundEffectTaskRequest, SoundEffectTaskRequest,
VectorEngineAudioSettings, build_background_music_task_body,
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
build_sound_effect_task_body,
};
pub fn build_vector_engine_audio_http_client(
settings: &VectorEngineAudioSettings,
) -> Result<reqwest::Client, AudioError> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(
settings.request_timeout_ms.max(1),
))
.build()
.map_err(|error| {
AudioError::invalid_config(format!(
"构造 VectorEngine 音频生成 HTTP 客户端失败:{error}"
))
})
}
pub async fn submit_background_music_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: BackgroundMusicTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_background_music_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::BackgroundMusic.submit_path(),
body,
"提交 Suno 背景音乐任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response).ok_or_else(|| {
AudioError::missing_audio("提交 Suno 背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(AudioTaskResponse {
kind: AudioTaskKind::BackgroundMusic,
task_id,
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
status: "submitted".to_string(),
})
}
pub async fn submit_sound_effect_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: SoundEffectTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_sound_effect_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::SoundEffect.submit_path(),
body,
"提交 Vidu 音效任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response)
.ok_or_else(|| AudioError::missing_audio("提交 Vidu 音效任务失败:上游未返回任务 ID"))?;
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(AudioTaskResponse {
kind: AudioTaskKind::SoundEffect,
task_id,
provider: AudioTaskKind::SoundEffect.provider().to_string(),
status,
})
}
pub async fn submit_editor_background_music_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: EditorBackgroundMusicTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_editor_background_music_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::BackgroundMusic.submit_path(),
body,
"提交编辑器背景音乐任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response).ok_or_else(|| {
AudioError::missing_audio("提交编辑器背景音乐任务失败:上游未返回任务 ID")
})?;
Ok(AudioTaskResponse {
kind: AudioTaskKind::BackgroundMusic,
task_id,
provider: AudioTaskKind::BackgroundMusic.provider().to_string(),
status: "submitted".to_string(),
})
}
pub async fn submit_editor_sound_effect_task(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
request: EditorSoundEffectTaskRequest,
) -> Result<AudioTaskResponse, AudioError> {
let body = build_editor_sound_effect_task_body(request)?;
let response = post_vector_engine_json(
http_client,
settings,
AudioTaskKind::SoundEffect.submit_path(),
body,
"提交编辑器音效任务失败",
)
.await?;
let task_id = extract_submit_task_id(&response)
.ok_or_else(|| AudioError::missing_audio("提交编辑器音效任务失败:上游未返回任务 ID"))?;
let status = find_first_string_by_key(&response, "state").unwrap_or_else(|| "created".into());
Ok(AudioTaskResponse {
kind: AudioTaskKind::SoundEffect,
task_id,
provider: AudioTaskKind::SoundEffect.provider().to_string(),
status,
})
}
async fn fetch_audio_task_payload(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
kind: AudioTaskKind,
task_id: &str,
) -> Result<Value, AudioError> {
get_vector_engine_json(
http_client,
settings,
&kind.fetch_path(task_id),
match kind {
AudioTaskKind::BackgroundMusic => "查询 Suno 背景音乐任务失败",
AudioTaskKind::SoundEffect => "查询 Vidu 音效任务失败",
AudioTaskKind::SunoSoundEffect => "查询 Suno 音效任务失败",
},
)
.await
}
pub async fn resolve_audio_task_download_urls(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
kind: AudioTaskKind,
task_id: &str,
) -> Result<(String, Vec<String>), AudioError> {
let task_payload = fetch_audio_task_payload(http_client, settings, kind, task_id).await?;
let status = normalize_task_status(
find_first_string_by_key(&task_payload, "status")
.or_else(|| find_first_string_by_key(&task_payload, "state"))
.or_else(|| find_first_string_by_key(&task_payload, "Status"))
.as_deref()
.unwrap_or(""),
);
let mut audio_urls = extract_audio_urls(&task_payload);
if matches!(
kind,
AudioTaskKind::BackgroundMusic | AudioTaskKind::SunoSoundEffect
) {
let (downloadable_audio_urls, clip_ids, has_audiopipe_url) =
resolve_suno_audio_candidates(&task_payload, audio_urls);
// Suno 查询结果还会混入 image_url 等非音频 URL。一旦存在 audiopipe
// 不保留同一 payload 的其它直连候选,强制用 item_id 获取稳定 wav。
audio_urls = if has_audiopipe_url {
Vec::new()
} else {
downloadable_audio_urls
};
let has_clip_ids = !clip_ids.is_empty();
let mut latest_wav_error = None;
if should_wait_for_suno_wav_audio(&status, has_clip_ids, &audio_urls) {
for clip_id in clip_ids {
let wav_payload = match get_vector_engine_json(
http_client,
settings,
&format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())),
match kind {
AudioTaskKind::SunoSoundEffect => "获取 Suno 音效 wav 失败",
_ => "获取 Suno wav 音频失败",
},
)
.await
{
Ok(payload) => {
latest_wav_error = None;
payload
}
Err(error) => {
latest_wav_error = Some(error);
continue;
}
};
audio_urls = extract_audio_urls(&wav_payload);
if !audio_urls.is_empty() {
break;
}
}
}
// 中文注释:Suno 的 audiopipe 地址只是携带 item_id 的流式中转地址,线上可能
// 返回 200 后不结束 chunked body。统一把 item_id 当 clip id 转走稳定 wav 地址;
// wav 地址尚未就绪或子请求暂时失败时保持 processing,让上层继续轮询。
if let Some(status) = resolve_suno_status_after_wav_probe(
&status,
has_clip_ids,
&audio_urls,
latest_wav_error,
) {
return Ok((status, audio_urls));
}
}
Ok((status, audio_urls))
}
fn resolve_suno_audio_candidates(
task_payload: &Value,
audio_urls: Vec<String>,
) -> (Vec<String>, Vec<String>, bool) {
let mut clip_ids = extract_suno_clip_ids(task_payload);
let mut downloadable_audio_urls = Vec::new();
let mut has_audiopipe_url = false;
for audio_url in audio_urls {
let Ok(parsed_url) = reqwest::Url::parse(audio_url.as_str()) else {
downloadable_audio_urls.push(audio_url);
continue;
};
if !parsed_url
.host_str()
.is_some_and(|host| host.eq_ignore_ascii_case("audiopipe.suno.ai"))
{
downloadable_audio_urls.push(audio_url);
continue;
}
has_audiopipe_url = true;
if let Some(clip_id) = parsed_url
.query_pairs()
.find(|(key, _)| key.eq_ignore_ascii_case("item_id"))
.map(|(_, value)| value.trim().to_string())
.filter(|value| {
!value.is_empty() && value.len() <= 180 && !value.chars().any(char::is_whitespace)
})
&& !clip_ids.contains(&clip_id)
{
clip_ids.push(clip_id);
}
}
(downloadable_audio_urls, clip_ids, has_audiopipe_url)
}
fn should_wait_for_suno_wav_audio(status: &str, has_clip_ids: bool, audio_urls: &[String]) -> bool {
audio_urls.is_empty() && has_clip_ids && !is_failed_task_status(status)
}
fn resolve_suno_status_after_wav_probe(
status: &str,
has_clip_ids: bool,
audio_urls: &[String],
_latest_wav_error: Option<AudioError>,
) -> Option<String> {
if should_wait_for_suno_wav_audio(status, has_clip_ids, audio_urls) {
return Some("processing".to_string());
}
None
}
async fn get_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
failure_context: &str,
) -> Result<Value, AudioError> {
let endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.get(endpoint.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
parse_vector_engine_response(response, failure_context).await
}
async fn post_vector_engine_json(
http_client: &reqwest::Client,
settings: &VectorEngineAudioSettings,
path: &str,
body: Value,
failure_context: &str,
) -> Result<Value, AudioError> {
let endpoint = vector_engine_audio_endpoint_url(settings, path);
let response = http_client
.post(endpoint.as_str())
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&body)
.send()
.await
.map_err(|error| map_reqwest_error(failure_context, endpoint.as_str(), error))?;
parse_vector_engine_response(response, failure_context).await
}
pub(crate) fn vector_engine_audio_endpoint_url(
settings: &VectorEngineAudioSettings,
path: &str,
) -> String {
let base_url = normalize_vector_engine_audio_base_url(settings.base_url.as_str());
let path = path.trim();
if path.starts_with('/') {
format!("{base_url}{path}")
} else {
format!("{base_url}/{path}")
}
}
fn normalize_vector_engine_audio_base_url(base_url: &str) -> String {
let normalized = base_url.trim().trim_end_matches('/');
// 中文注释:Suno / Vidu 音频接口在 VectorEngine 根路径下,不能拼成 /v1/suno/*。
normalized
.strip_suffix("/v1")
.unwrap_or(normalized)
.trim_end_matches('/')
.to_string()
}
async fn parse_vector_engine_response(
response: reqwest::Response,
failure_context: &str,
) -> Result<Value, AudioError> {
let status = response.status();
let raw_text = response.text().await.map_err(|error| {
AudioError::request(
format!("{failure_context}:读取响应失败:{error}"),
None,
false,
false,
false,
true,
Some(status.as_u16()),
None,
)
})?;
if !status.is_success() {
return Err(AudioError::upstream(
build_upstream_error_message(failure_context, status.as_u16(), raw_text.as_str()),
status.as_u16(),
truncate_raw(raw_text.as_str()),
));
}
let payload = serde_json::from_str::<Value>(&raw_text).map_err(|error| {
AudioError::response_parse(
format!("{failure_context}:解析响应失败:{error}"),
truncate_raw(raw_text.as_str()),
)
})?;
if let Some(code) = payload.get("code")
&& !is_success_response_code(code)
{
return Err(AudioError::upstream(
extract_upstream_payload_message(&payload)
.unwrap_or_else(|| failure_context.to_string()),
status.as_u16(),
truncate_raw(raw_text.as_str()),
));
}
Ok(payload)
}
fn map_reqwest_error(failure_context: &str, endpoint: &str, error: reqwest::Error) -> AudioError {
AudioError::request(
format!("{failure_context}{error}"),
Some(endpoint.to_string()),
error.is_timeout(),
error.is_connect(),
error.is_request(),
error.is_body(),
error.status().map(|status| status.as_u16()),
Error::source(&error).map(ToString::to_string),
)
}
fn truncate_raw(raw_text: &str) -> String {
raw_text.chars().take(800).collect()
}
fn extract_submit_task_id(response: &Value) -> Option<String> {
// 中文注释:Suno 音频提交接口文档未声明响应结构,实际可能直接把任务 ID 放在 data 字符串或 data 对象中。
extract_string_by_path(response, &["data"])
.or_else(|| extract_string_by_path(response, &["data", "task_id"]))
.or_else(|| extract_string_by_path(response, &["data", "taskId"]))
.or_else(|| extract_string_by_path(response, &["data", "id"]))
.or_else(|| find_first_string_by_key(response, "task_id"))
.or_else(|| find_first_string_by_key(response, "taskId"))
.or_else(|| find_first_string_by_key(response, "id"))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn build_upstream_error_message(failure_context: &str, status: u16, raw_text: &str) -> String {
let excerpt = truncate_raw(raw_text);
if excerpt.trim().is_empty() {
return format!("{failure_context}:上游 HTTP {status}");
}
format!("{failure_context}:上游 HTTP {status}{excerpt}")
}
fn is_success_response_code(code: &Value) -> bool {
match code {
Value::String(code) => matches!(
code.trim().to_ascii_lowercase().as_str(),
"success" | "succeeded" | "ok" | "0" | "200"
),
Value::Number(code) => code.as_i64().is_some_and(|code| code == 0 || code == 200),
_ => false,
}
}
fn extract_upstream_payload_message(payload: &Value) -> Option<String> {
payload
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
payload
.get("error")
.and_then(|value| match value {
Value::String(message) => Some(message.as_str()),
Value::Object(object) => object
.get("message")
.and_then(Value::as_str)
.or_else(|| object.get("detail").and_then(Value::as_str)),
_ => None,
})
.map(str::trim)
.filter(|message| !message.is_empty())
.map(ToOwned::to_owned)
})
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
thread,
time::Duration,
};
use super::*;
fn settings(base_url: &str) -> VectorEngineAudioSettings {
VectorEngineAudioSettings {
base_url: base_url.to_string(),
api_key: "test-key".to_string(),
request_timeout_ms: 1_000,
}
}
#[test]
fn audio_endpoint_urls_strip_v1_for_root_level_suno_paths() {
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings("https://api.vectorengine.cn/v1"),
"/suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
assert_eq!(
vector_engine_audio_endpoint_url(
&settings(" https://api.vectorengine.cn/v1/ "),
"suno/submit/music"
),
"https://api.vectorengine.cn/suno/submit/music"
);
}
#[test]
fn vector_engine_audio_success_codes_accept_numeric_and_string_values() {
for code in [
Value::String("success".to_string()),
Value::String("200".to_string()),
Value::String("0".to_string()),
Value::from(200),
Value::from(0),
] {
assert!(is_success_response_code(&code), "{code}");
}
assert!(!is_success_response_code(&Value::String(
"invalid_request".to_string()
)));
}
#[test]
fn submit_task_id_extracts_suno_data_string_and_nested_ids() {
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": "suno-task-1"
})),
Some("suno-task-1".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"code": 200,
"data": {
"id": "suno-task-2"
}
})),
Some("suno-task-2".to_string())
);
assert_eq!(
extract_submit_task_id(&serde_json::json!({
"taskId": "legacy-task-1"
})),
Some("legacy-task-1".to_string())
);
}
#[test]
fn suno_clip_ids_without_wav_url_keep_task_pending() {
assert!(should_wait_for_suno_wav_audio(
"completed",
true,
&Vec::new()
));
assert!(!should_wait_for_suno_wav_audio("failed", true, &Vec::new()));
assert!(!should_wait_for_suno_wav_audio(
"completed",
true,
&["https://cdn.example.test/audio.wav".to_string()]
));
}
#[test]
fn suno_wav_probe_error_keeps_completed_task_pending() {
let status = resolve_suno_status_after_wav_probe(
"completed",
true,
&Vec::new(),
Some(AudioError::upstream(
"获取 Suno 音效 wav 失败",
502,
r#"{"message":"wav not ready"}"#,
)),
);
assert_eq!(status.as_deref(), Some("processing"));
}
#[test]
fn suno_audiopipe_url_uses_item_id_to_resolve_stable_wav_url() {
let listener = TcpListener::bind("127.0.0.1:0").expect("mock server should bind");
let address = listener
.local_addr()
.expect("mock server address should be readable");
let server = thread::spawn(move || {
let responses = [
(
"/suno/fetch/task-1",
r#"{"code":200,"data":{"status":"complete","audio_url":"https://audiopipe.suno.ai/?item_id=clip-1","image_url":"https://cdn.example.test/cover.jpg"}}"#,
),
(
"/suno/act/wav/clip-1",
r#"{"code":200,"data":{"url":"https://cdn.example.test/audio.wav"}}"#,
),
];
for (expected_path, body) in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
let mut request = [0_u8; 2048];
let size = stream
.read(&mut request)
.expect("request should be readable");
let request = String::from_utf8_lossy(&request[..size]);
assert!(
request.starts_with(format!("GET {expected_path} HTTP/1.1").as_str()),
"unexpected request: {request}"
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("response should be writable");
}
});
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime should build");
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("http client should build");
let (status, audio_urls) = runtime
.block_on(resolve_audio_task_download_urls(
&http_client,
&settings(format!("http://{address}").as_str()),
AudioTaskKind::BackgroundMusic,
"task-1",
))
.expect("audiopipe item id should resolve through the wav endpoint");
assert_eq!(status, "completed");
assert_eq!(
audio_urls,
vec!["https://cdn.example.test/audio.wav".to_string()]
);
server.join().expect("mock server should finish");
}
}