librespot_core/
cdn_url.rs1use std::ops::{Deref, DerefMut};
2
3use protobuf::Message;
4use thiserror::Error;
5use time::Duration;
6use url::Url;
7
8use super::{date::Date, Error, FileId, Session};
9
10use librespot_protocol as protocol;
11use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;
12use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
13
14#[derive(Debug, Clone)]
15pub struct MaybeExpiringUrl(pub String, pub Option<Date>);
16
17const CDN_URL_EXPIRY_MARGIN: Duration = Duration::seconds(5 * 60);
18
19#[derive(Debug, Clone)]
20pub struct MaybeExpiringUrls(pub Vec<MaybeExpiringUrl>);
21
22impl Deref for MaybeExpiringUrls {
23 type Target = Vec<MaybeExpiringUrl>;
24 fn deref(&self) -> &Self::Target {
25 &self.0
26 }
27}
28
29impl DerefMut for MaybeExpiringUrls {
30 fn deref_mut(&mut self) -> &mut Self::Target {
31 &mut self.0
32 }
33}
34
35#[derive(Debug, Error)]
36pub enum CdnUrlError {
37 #[error("all URLs expired")]
38 Expired,
39 #[error("resolved storage is not for CDN")]
40 Storage,
41 #[error("no URLs resolved")]
42 Unresolved,
43}
44
45impl From<CdnUrlError> for Error {
46 fn from(err: CdnUrlError) -> Self {
47 match err {
48 CdnUrlError::Expired => Error::deadline_exceeded(err),
49 CdnUrlError::Storage | CdnUrlError::Unresolved => Error::unavailable(err),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
55pub struct CdnUrl {
56 pub file_id: FileId,
57 urls: MaybeExpiringUrls,
58}
59
60impl CdnUrl {
61 pub fn new(file_id: FileId) -> Self {
62 Self {
63 file_id,
64 urls: MaybeExpiringUrls(Vec::new()),
65 }
66 }
67
68 pub async fn resolve_audio(&self, session: &Session) -> Result<Self, Error> {
69 let file_id = self.file_id;
70 let response = session.spclient().get_audio_storage(&file_id).await?;
71 let msg = CdnUrlMessage::parse_from_bytes(&response)?;
72 let urls = MaybeExpiringUrls::try_from(msg)?;
73
74 let cdn_url = Self { file_id, urls };
75
76 trace!("Resolved CDN storage: {:#?}", cdn_url);
77
78 Ok(cdn_url)
79 }
80
81 pub fn try_get_url(&self) -> Result<&str, Error> {
82 if self.urls.is_empty() {
83 return Err(CdnUrlError::Unresolved.into());
84 }
85
86 let now = Date::now_utc();
87 let url = self.urls.iter().find(|url| match url.1 {
88 Some(expiry) => now < expiry,
89 None => true,
90 });
91
92 if let Some(url) = url {
93 Ok(&url.0)
94 } else {
95 Err(CdnUrlError::Expired.into())
96 }
97 }
98}
99
100impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
101 type Error = crate::Error;
102 fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {
103 if !matches!(
104 msg.result.enum_value_or_default(),
105 StorageResolveResponse_Result::CDN
106 ) {
107 return Err(CdnUrlError::Storage.into());
108 }
109
110 let is_expiring = !msg.fileid.is_empty();
111
112 let result = msg
113 .cdnurl
114 .iter()
115 .map(|cdn_url| {
116 let url = Url::parse(cdn_url)?;
117 let mut expiry: Option<Date> = None;
118
119 if is_expiring {
120 let mut expiry_str: Option<String> = None;
121 if let Some(token) = url
122 .query_pairs()
123 .into_iter()
124 .find(|(key, _value)| key == "__token__")
125 {
126 if let Some(mut start) = token.1.find("exp=") {
128 start += 4;
129 if token.1.len() >= start {
130 let slice = &token.1[start..];
131 if let Some(end) = slice.find('~') {
132 expiry_str = Some(String::from(&slice[..end]));
134 } else {
135 expiry_str = Some(String::from(slice));
136 }
137 }
138 }
139 } else if let Some(token) = url
140 .query_pairs()
141 .into_iter()
142 .find(|(key, _value)| key == "Expires")
143 {
144 if let Some(end) = token.1.find('~') {
146 let slice = &token.1[..end];
148 expiry_str = Some(String::from(&slice[..end]));
149 }
150 } else if let Some(query) = url.query() {
151 let mut items = query.split('_');
153 if let Some(first) = items.next() {
154 expiry_str = Some(String::from(first));
156 }
157 }
158
159 if let Some(exp_str) = expiry_str {
160 if let Ok(expiry_parsed) = exp_str.parse::<i64>() {
161 if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) {
162 let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN);
163 expiry = Some(Date::from(with_margin));
164 }
165 } else {
166 warn!("Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'");
167 }
168 } else {
169 warn!("Unknown CDN URL format: {cdn_url}");
170 }
171 }
172 Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry))
173 })
174 .collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;
175
176 Ok(Self(result))
177 }
178}
179
180#[cfg(test)]
181mod test {
182 use super::*;
183
184 #[test]
185 fn test_maybe_expiring_urls() {
186 let timestamp = 1688165560;
187 let mut msg = CdnUrlMessage::new();
188 msg.result = StorageResolveResponse_Result::CDN.into();
189 msg.cdnurl = vec![
190 format!("https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"),
191 format!("https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="),
192 format!("https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="),
193 "https://audio4-fa.scdn.co/foo?baz".to_string(),
194 ];
195 msg.fileid = vec![0];
196
197 let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
198 assert_eq!(urls.len(), 4);
199 assert!(urls[0].1.is_some());
200 assert!(urls[1].1.is_some());
201 assert!(urls[2].1.is_some());
202 assert!(urls[3].1.is_none());
203 let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
204 assert_eq!(
205 urls[0].1.unwrap().as_timestamp_ms() as i128,
206 timestamp_margin.whole_milliseconds()
207 );
208 }
209}