librespot_core/
cdn_url.rs

1use std::ops::{Deref, DerefMut};
2
3use protobuf::Message;
4use thiserror::Error;
5use time::Duration;
6use url::Url;
7
8use super::{Error, FileId, Session, date::Date};
9
10use librespot_protocol as protocol;
11use protocol::storage_resolve::StorageResolveResponse as CdnUrlMessage;
12use protocol::storage_resolve::storage_resolve_response::Result as StorageResolveResponse_Result;
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    #[deprecated = "This function only returns the first valid URL. Use try_get_urls instead, which allows for fallback logic."]
82    pub fn try_get_url(&self) -> Result<&str, Error> {
83        if self.urls.is_empty() {
84            return Err(CdnUrlError::Unresolved.into());
85        }
86
87        let now = Date::now_utc();
88        let url = self.urls.iter().find(|url| match url.1 {
89            Some(expiry) => now < expiry,
90            None => true,
91        });
92
93        if let Some(url) = url {
94            Ok(&url.0)
95        } else {
96            Err(CdnUrlError::Expired.into())
97        }
98    }
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    }
127}
128
129impl TryFrom<CdnUrlMessage> for MaybeExpiringUrls {
130    type Error = crate::Error;
131    fn try_from(msg: CdnUrlMessage) -> Result<Self, Self::Error> {
132        if !matches!(
133            msg.result.enum_value_or_default(),
134            StorageResolveResponse_Result::CDN
135        ) {
136            return Err(CdnUrlError::Storage.into());
137        }
138
139        let is_expiring = !msg.fileid.is_empty();
140
141        let result = msg
142            .cdnurl
143            .iter()
144            .map(|cdn_url| {
145                let url = Url::parse(cdn_url)?;
146                let mut expiry: Option<Date> = None;
147
148                if is_expiring {
149                    let mut expiry_str: Option<String> = None;
150                    if let Some(token) = url
151                        .query_pairs()
152                        .into_iter()
153                        .find(|(key, _value)| key == "verify")
154                    {
155                        // https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify=1750549951-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D
156                        if let Some((expiry_str_candidate, _)) = token.1.split_once('-') {
157                            expiry_str = Some(expiry_str_candidate.to_string());
158                        }
159                    } else if let Some(token) = url
160                        .query_pairs()
161                        .into_iter()
162                        .find(|(key, _value)| key == "__token__")
163                    {
164                        //"https://audio-ak-spotify-com.akamaized.net/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?__token__=exp=1688165560~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41",
165                        if let Some(mut start) = token.1.find("exp=") {
166                            start += 4;
167                            if token.1.len() >= start {
168                                let slice = &token.1[start..];
169                                if let Some(end) = slice.find('~') {
170                                    // this is the only valid invariant for akamaized.net
171                                    expiry_str = Some(String::from(&slice[..end]));
172                                } else {
173                                    expiry_str = Some(String::from(slice));
174                                }
175                            }
176                        }
177                    } else if let Some(token) = url
178                        .query_pairs()
179                        .into_iter()
180                        .find(|(key, _value)| key == "Expires")
181                    {
182                        //"https://audio-gm-off.spotifycdn.com/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?Expires=1688165560~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM=",
183                        if let Some(end) = token.1.find('~') {
184                            // this is the only valid invariant for spotifycdn.com
185                            let slice = &token.1[..end];
186                            expiry_str = Some(String::from(&slice[..end]));
187                        }
188                    } else if let Some(query) = url.query() {
189                        //"https://audio4-fa.scdn.co/audio/4712bc9e47f7feb4ee3450ef2bb545e1d83c3d54?1688165560_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4=",
190                        let mut items = query.split('_');
191                        if let Some(first) = items.next() {
192                            // this is the only valid invariant for scdn.co
193                            expiry_str = Some(String::from(first));
194                        }
195                    }
196
197                    if let Some(exp_str) = expiry_str {
198                        if let Ok(expiry_parsed) = exp_str.parse::<i64>() {
199                            if let Ok(expiry_at) = Date::from_timestamp_ms(expiry_parsed * 1_000) {
200                                let with_margin = expiry_at.saturating_sub(CDN_URL_EXPIRY_MARGIN);
201                                expiry = Some(Date::from(with_margin));
202                            }
203                        } else {
204                            warn!(
205                                "Cannot parse CDN URL expiry timestamp '{exp_str}' from '{cdn_url}'"
206                            );
207                        }
208                    } else {
209                        warn!("Unknown CDN URL format: {cdn_url}");
210                    }
211                }
212                Ok(MaybeExpiringUrl(cdn_url.to_owned(), expiry))
213            })
214            .collect::<Result<Vec<MaybeExpiringUrl>, Error>>()?;
215
216        Ok(Self(result))
217    }
218}
219
220#[cfg(test)]
221mod test {
222    use super::*;
223
224    #[test]
225    fn test_maybe_expiring_urls() {
226        let timestamp = 1688165560;
227        let mut msg = CdnUrlMessage::new();
228        msg.result = StorageResolveResponse_Result::CDN.into();
229        msg.cdnurl = vec![
230            format!(
231                "https://audio-cf.spotifycdn.com/audio/844ecdb297a87ebfee4399f28892ef85d9ba725f?verify={timestamp}-4R3I2w2q7OfNkR%2FGH8qH7xtIKUPlDxywBuADY%2BsvMeU%3D"
232            ),
233            format!(
234                "https://audio-ak-spotify-com.akamaized.net/audio/foo?__token__=exp={timestamp}~hmac=4e661527574fab5793adb99cf04e1c2ce12294c71fe1d39ffbfabdcfe8ce3b41"
235            ),
236            format!(
237                "https://audio-gm-off.spotifycdn.com/audio/foo?Expires={timestamp}~FullPath~hmac=IIZA28qptl8cuGLq15-SjHKHtLoxzpy_6r_JpAU4MfM="
238            ),
239            format!(
240                "https://audio4-fa.scdn.co/audio/foo?{timestamp}_0GKSyXjLaTW1BksFOyI4J7Tf9tZDbBUNNPu9Mt4mhH4="
241            ),
242            "https://audio4-fa.scdn.co/foo?baz".to_string(),
243        ];
244        msg.fileid = vec![0];
245
246        let urls = MaybeExpiringUrls::try_from(msg).expect("valid urls");
247        assert_eq!(urls.len(), 5);
248        assert!(urls[0].1.is_some());
249        assert!(urls[1].1.is_some());
250        assert!(urls[2].1.is_some());
251        assert!(urls[3].1.is_some());
252        assert!(urls[4].1.is_none());
253        let timestamp_margin = Duration::seconds(timestamp) - CDN_URL_EXPIRY_MARGIN;
254        assert_eq!(
255            urls[0].1.unwrap().as_timestamp_ms() as i128,
256            timestamp_margin.whole_milliseconds()
257        );
258    }
259}