Skip to content

Commit 3a700f0

Browse files
authored
fix: add fallback logic for CDN urls (#1524)
1 parent be37402 commit 3a700f0

File tree

4 files changed

+81
-25
lines changed

4 files changed

+81
-25
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- [core] MSRV is now 1.81 (breaking)
1313
- [core] AP connect and handshake have a combined 5 second timeout.
14+
- [core] `stream_from_cdn` now accepts the URL as a `&str` instead of `CdnUrl` (breaking)
1415
- [connect] Replaced `has_volume_ctrl` with `disable_volume` in `ConnectConfig` (breaking)
1516
- [connect] Changed `initial_volume` from `Option<u16>` to `u16` in `ConnectConfig` (breaking)
1617
- [connect] Replaced `SpircLoadCommand` with `LoadRequest`, `LoadRequestOptions` and `LoadContextOptions` (breaking)
@@ -30,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3031
- [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking)
3132
- [playback] Add `PlayerEvent::PositionChanged` event to notify about the current playback position
3233
- [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient`
34+
- [core] Add `try_get_urls` to `CdnUrl`
3335
- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process
3436

3537
### Fixed
@@ -48,10 +50,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4850
- [connect] Handle transfer of playback with empty "uri" field
4951
- [connect] Correctly apply playing/paused state when transferring playback
5052
- [player] Saturate invalid seek positions to track duration
53+
- [audio] Fall back to other URLs in case of a failure when downloading from CDN
5154

5255
### Deprecated
5356

5457
- [oauth] `get_access_token()` function marked for deprecation
58+
- [core] `try_get_url()` function marked for deprecation
5559

5660
### Removed
5761

audio/src/fetch/mod.rs

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ struct AudioFileDownloadStatus {
306306
}
307307

308308
struct AudioFileShared {
309-
cdn_url: CdnUrl,
309+
cdn_url: String,
310310
file_size: usize,
311311
bytes_per_second: usize,
312312
cond: Condvar,
@@ -426,25 +426,46 @@ impl AudioFileStreaming {
426426
) -> Result<AudioFileStreaming, Error> {
427427
let cdn_url = CdnUrl::new(file_id).resolve_audio(&session).await?;
428428

429-
if let Ok(url) = cdn_url.try_get_url() {
430-
trace!("Streaming from {}", url);
431-
}
432-
433429
let minimum_download_size = AudioFetchParams::get().minimum_download_size;
434430

435-
// When the audio file is really small, this `download_size` may turn out to be
436-
// larger than the audio file we're going to stream later on. This is OK; requesting
437-
// `Content-Range` > `Content-Length` will return the complete file with status code
438-
// 206 Partial Content.
439-
let mut streamer =
440-
session
441-
.spclient()
442-
.stream_from_cdn(&cdn_url, 0, minimum_download_size)?;
431+
let mut response_streamer_url = None;
432+
let urls = cdn_url.try_get_urls()?;
433+
for url in &urls {
434+
// When the audio file is really small, this `download_size` may turn out to be
435+
// larger than the audio file we're going to stream later on. This is OK; requesting
436+
// `Content-Range` > `Content-Length` will return the complete file with status code
437+
// 206 Partial Content.
438+
let mut streamer =
439+
session
440+
.spclient()
441+
.stream_from_cdn(*url, 0, minimum_download_size)?;
442+
443+
// Get the first chunk with the headers to get the file size.
444+
// The remainder of that chunk with possibly also a response body is then
445+
// further processed in `audio_file_fetch`.
446+
let streamer_result = tokio::time::timeout(Duration::from_secs(10), streamer.next())
447+
.await
448+
.map_err(|_| AudioFileError::WaitTimeout.into())
449+
.and_then(|x| x.ok_or_else(|| AudioFileError::NoData.into()))
450+
.and_then(|x| x.map_err(Error::from));
451+
452+
match streamer_result {
453+
Ok(r) => {
454+
response_streamer_url = Some((r, streamer, url));
455+
break;
456+
}
457+
Err(e) => warn!("Fetching {url} failed with error {e:?}, trying next"),
458+
}
459+
}
460+
461+
let Some((response, streamer, url)) = response_streamer_url else {
462+
return Err(Error::unavailable(format!(
463+
"{} URLs failed, none left to try",
464+
urls.len()
465+
)));
466+
};
443467

444-
// Get the first chunk with the headers to get the file size.
445-
// The remainder of that chunk with possibly also a response body is then
446-
// further processed in `audio_file_fetch`.
447-
let response = streamer.next().await.ok_or(AudioFileError::NoData)??;
468+
trace!("Streaming from {}", url);
448469

449470
let code = response.status();
450471
if code != StatusCode::PARTIAL_CONTENT {
@@ -473,7 +494,7 @@ impl AudioFileStreaming {
473494
};
474495

475496
let shared = Arc::new(AudioFileShared {
476-
cdn_url,
497+
cdn_url: url.to_string(),
477498
file_size,
478499
bytes_per_second,
479500
cond: Condvar::new(),

core/src/cdn_url.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ impl CdnUrl {
7878
Ok(cdn_url)
7979
}
8080

81+
#[deprecated = "This function only returns the first valid URL. Use try_get_urls instead, which allows for fallback logic."]
8182
pub fn try_get_url(&self) -> Result<&str, Error> {
8283
if self.urls.is_empty() {
8384
return Err(CdnUrlError::Unresolved.into());
@@ -95,6 +96,34 @@ impl CdnUrl {
9596
Err(CdnUrlError::Expired.into())
9697
}
9798
}
99+
100+
pub fn try_get_urls(&self) -> Result<Vec<&str>, Error> {
101+
if self.urls.is_empty() {
102+
return Err(CdnUrlError::Unresolved.into());
103+
}
104+
105+
let now = Date::now_utc();
106+
let urls: Vec<&str> = self
107+
.urls
108+
.iter()
109+
.filter_map(|MaybeExpiringUrl(url, expiry)| match *expiry {
110+
Some(expiry) => {
111+
if now < expiry {
112+
Some(url.as_str())
113+
} else {
114+
None
115+
}
116+
}
117+
None => Some(url.as_str()),
118+
})
119+
.collect();
120+
121+
if urls.is_empty() {
122+
Err(CdnUrlError::Expired.into())
123+
} else {
124+
Ok(urls)
125+
}
126+
}
98127
}
99128

100129
impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {

core/src/spclient.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ use std::{
66
use crate::config::{os_version, OS};
77
use crate::{
88
apresolve::SocketAddress,
9-
cdn_url::CdnUrl,
109
config::SessionConfig,
1110
error::ErrorKind,
1211
protocol::{
@@ -27,7 +26,7 @@ use crate::{
2726
use bytes::Bytes;
2827
use data_encoding::HEXUPPER_PERMISSIVE;
2928
use futures_util::future::IntoStream;
30-
use http::header::HeaderValue;
29+
use http::{header::HeaderValue, Uri};
3130
use hyper::{
3231
header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
3332
HeaderMap, Method, Request,
@@ -730,16 +729,19 @@ impl SpClient {
730729
self.request(&Method::GET, &endpoint, None, None).await
731730
}
732731

733-
pub fn stream_from_cdn(
732+
pub fn stream_from_cdn<U>(
734733
&self,
735-
cdn_url: &CdnUrl,
734+
cdn_url: U,
736735
offset: usize,
737736
length: usize,
738-
) -> Result<IntoStream<ResponseFuture>, Error> {
739-
let url = cdn_url.try_get_url()?;
737+
) -> Result<IntoStream<ResponseFuture>, Error>
738+
where
739+
U: TryInto<Uri>,
740+
<U as TryInto<Uri>>::Error: Into<http::Error>,
741+
{
740742
let req = Request::builder()
741743
.method(&Method::GET)
742-
.uri(url)
744+
.uri(cdn_url)
743745
.header(
744746
RANGE,
745747
HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?,

0 commit comments

Comments
 (0)