fix: add retry with fresh URL on CDN premature EOF

Root cause: Qobuz CDN returns "end of file before message length
reached" for specific tracks - sends Content-Length header then
closes connection without body data. Consistently reproducible for
certain tracks while others from the same album work fine.

Add retry logic at all download paths: on failure, request a fresh
stream URL from the API and retry once (CDN may route to different
edge). Streaming probe failures now fall back to standard download
path instead of failing completely. Also stream response body in
chunks for better partial-read diagnostics.
This commit is contained in:
vicrodh committed 2026-03-11 20:41:58 -06:00
1 parent d02090f30d
commit df86363aac
2 files changed
+119 -28

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP Client
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "stream", "blocking", "multipart"], default-features = false }
reqwest = { version = "0.12", features = ["json", "cookies", "rustls-tls", "native-tls", "stream", "blocking", "multipart"], default-features = false }
# Async Runtime
tokio = { version = "1", features = ["full"] }
+118 -27
View File
@@ -323,7 +323,7 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.http1_only()
.use_native_tls()
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
@@ -344,24 +344,46 @@ async fn download_audio(url: &str) -> Result<Vec<u8>, String> {
log::info!("[V2] Downloading audio: {} bytes expected", len);
}
let bytes = response
.bytes()
.await
.map_err(|e| {
// Log detailed error chain for debugging
use std::error::Error as _;
let mut msg = format!("Failed to read audio bytes: {}", e);
let mut source = e.source();
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {}", cause));
source = cause.source();
}
log::error!("[V2] Download error details: {}", msg);
msg
})?;
// Stream body in chunks to handle partial reads gracefully
let expected_len = content_length.unwrap_or(0) as usize;
let mut all_data = Vec::with_capacity(expected_len);
let mut stream = response.bytes_stream();
log::info!("[V2] Downloaded {} bytes", bytes.len());
Ok(bytes.to_vec())
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
match chunk_result {
Ok(chunk) => all_data.extend_from_slice(&chunk),
Err(e) => {
use std::error::Error as _;
let mut msg = format!("Failed to read audio bytes: {}", e);
let mut source = e.source();
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {}", cause));
source = cause.source();
}
// If we got some data but not all, log what we received
if !all_data.is_empty() {
log::error!(
"[V2] Download error after {}/{} bytes: {}",
all_data.len(), expected_len, msg
);
} else {
log::error!("[V2] Download error (0 bytes received): {}", msg);
}
return Err(msg);
}
}
}
if expected_len > 0 && all_data.len() != expected_len {
log::warn!(
"[V2] Download size mismatch: got {} bytes, expected {}",
all_data.len(), expected_len
);
}
log::info!("[V2] Downloaded {} bytes", all_data.len());
Ok(all_data)
}
/// Stream info from probing a URL (HEAD + first 64KB)
@@ -380,7 +402,7 @@ async fn v2_get_stream_info(url: &str) -> Result<V2StreamInfo, String> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.http1_only()
.use_native_tls()
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
@@ -471,7 +493,7 @@ async fn v2_download_and_stream(
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(300))
.http1_only()
.use_native_tls()
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
@@ -1903,7 +1925,26 @@ fn spawn_v2_prefetch_with_hw_check(
log::info!("[V2/PREFETCH] Complete for track {}", track_id);
}
Err(e) => {
log::warn!("[V2/PREFETCH] Failed for track {}: {}", track_id, e);
log::warn!("[V2/PREFETCH] Failed for track {} (attempt 1): {}", track_id, e);
// Retry with fresh URL — CDN edge may have returned EOF
log::info!("[V2/PREFETCH] Retrying track {} with fresh URL...", track_id);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let retry_result = async {
let bridge_guard = bridge_clone.read().await;
let bridge = bridge_guard.as_ref().ok_or("CoreBridge not initialized")?;
let stream_url = bridge.get_stream_url(track_id, quality).await?;
drop(bridge_guard);
download_audio(&stream_url.url).await
}.await;
match retry_result {
Ok(data) => {
cache_clone.insert(track_id, data);
log::info!("[V2/PREFETCH] Complete for track {} (retry succeeded)", track_id);
}
Err(e2) => {
log::warn!("[V2/PREFETCH] Failed for track {} (attempt 2): {}", track_id, e2);
}
}
}
}
@@ -7309,9 +7350,46 @@ pub async fn v2_play_track(
!streaming_only
);
let stream_info = v2_get_stream_info(&stream_url.url)
.await
.map_err(RuntimeError::Internal)?;
let stream_info = match v2_get_stream_info(&stream_url.url).await {
Ok(info) => info,
Err(e) => {
// Probe failed (CDN EOF, etc.) — fall through to standard download path
log::warn!(
"[V2/STREAMING] Probe failed for track {}: {}. Falling back to full download.",
track_id, e
);
// Get a fresh URL and use the standard download path below
let retry_url = bridge_guard
.get_stream_url(track_id, final_quality)
.await
.map_err(RuntimeError::Internal)?;
stream_url = retry_url;
// Set stream_first_enabled to false to skip the streaming block
// and fall through to the standard download path
let audio_data = match download_audio(&stream_url.url).await {
Ok(data) => data,
Err(e2) => {
log::warn!("[V2/DOWNLOAD] Retry also failed for track {}: {}", track_id, e2);
return Err(RuntimeError::Internal(format!(
"Download failed after retry: {}", e2
)));
}
};
let data_size = audio_data.len();
if !streaming_only {
cache.insert(track_id, audio_data.clone());
}
player.play_data(audio_data, track_id).map_err(RuntimeError::Internal)?;
log::info!("[V2] Playing track {} ({} bytes, fallback from streaming)", track_id, data_size);
let upcoming_tracks = bridge_guard.peek_upcoming(V2_PREFETCH_LOOKAHEAD).await;
drop(bridge_guard);
spawn_v2_prefetch_with_hw_check(
bridge.0.clone(), cache, upcoming_tracks,
final_quality, streaming_only, hw_device_id,
);
return Ok(V2PlayTrackResult { format_id: Some(stream_url.format_id) });
}
};
log::info!(
"[V2/STREAMING] Info: {:.2} MB, {}Hz, {} ch, {}-bit, {:.1} MB/s",
@@ -7393,9 +7471,22 @@ pub async fn v2_play_track(
track_id,
!streaming_only
);
let audio_data = download_audio(&stream_url.url)
.await
.map_err(RuntimeError::Internal)?;
let audio_data = match download_audio(&stream_url.url).await {
Ok(data) => data,
Err(e) => {
log::warn!("[V2/DOWNLOAD] First attempt failed for track {}: {}", track_id, e);
// Retry with fresh URL — CDN edge may have returned premature EOF
log::info!("[V2/DOWNLOAD] Retrying track {} with fresh URL...", track_id);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let retry_url = bridge_guard
.get_stream_url(track_id, final_quality)
.await
.map_err(RuntimeError::Internal)?;
download_audio(&retry_url.url)
.await
.map_err(RuntimeError::Internal)?
}
};
let data_size = audio_data.len();
// Cache it (unless streaming_only mode)