838d819c3d
识别audiopipe中转地址并通过item_id获取稳定WAV文件 避免封面地址混入音频候选并跳过失败任务的额外探测 补充Suno两跳解析回归测试并更新设计与排障文档
646 lines
22 KiB
Rust
646 lines
22 KiB
Rust
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");
|
||
}
|
||
}
|