openlibspot_core/
spclient.rs

1use std::{
2    env::consts::OS,
3    fmt::Write,
4    time::{Duration, Instant},
5};
6
7use byteorder::{BigEndian, ByteOrder};
8use bytes::Bytes;
9use data_encoding::HEXUPPER_PERMISSIVE;
10use futures_util::future::IntoStream;
11use http::header::HeaderValue;
12use hyper::{
13    client::ResponseFuture,
14    header::{HeaderName, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RANGE},
15    Body, HeaderMap, Method, Request,
16};
17use protobuf::{Enum, Message, MessageFull};
18use rand::RngCore;
19use sha1::{Digest, Sha1};
20use sysinfo::{System, SystemExt};
21use thiserror::Error;
22
23use crate::{
24    apresolve::SocketAddress,
25    cdn_url::CdnUrl,
26    config::SessionConfig,
27    error::ErrorKind,
28    protocol::{
29        canvaz::EntityCanvazRequest,
30        clienttoken_http::{
31            ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,
32            ClientTokenResponse, ClientTokenResponseType,
33        },
34        connect::PutStateRequest,
35        extended_metadata::BatchedEntityRequest,
36    },
37    token::Token,
38    version::spotify_version,
39    Error, FileId, SpotifyId,
40};
41
42component! {
43    SpClient : SpClientInner {
44        accesspoint: Option<SocketAddress> = None,
45        strategy: RequestStrategy = RequestStrategy::default(),
46        client_token: Option<Token> = None,
47    }
48}
49
50pub type SpClientResult = Result<Bytes, Error>;
51
52#[allow(clippy::declare_interior_mutable_const)]
53const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
54
55#[derive(Debug, Error)]
56pub enum SpClientError {
57    #[error("missing attribute {0}")]
58    Attribute(String),
59}
60
61impl From<SpClientError> for Error {
62    fn from(err: SpClientError) -> Self {
63        Self::failed_precondition(err)
64    }
65}
66
67#[derive(Copy, Clone, Debug)]
68pub enum RequestStrategy {
69    TryTimes(usize),
70    Infinitely,
71}
72
73impl Default for RequestStrategy {
74    fn default() -> Self {
75        RequestStrategy::TryTimes(10)
76    }
77}
78
79impl SpClient {
80    pub fn set_strategy(&self, strategy: RequestStrategy) {
81        self.lock(|inner| inner.strategy = strategy)
82    }
83
84    pub async fn flush_accesspoint(&self) {
85        self.lock(|inner| inner.accesspoint = None)
86    }
87
88    pub async fn get_accesspoint(&self) -> Result<SocketAddress, Error> {
89        // Memoize the current access point.
90        let ap = self.lock(|inner| inner.accesspoint.clone());
91        let tuple = match ap {
92            Some(tuple) => tuple,
93            None => {
94                let tuple = self.session().apresolver().resolve("spclient").await?;
95                self.lock(|inner| inner.accesspoint = Some(tuple.clone()));
96                info!(
97                    "Resolved \"{}:{}\" as spclient access point",
98                    tuple.0, tuple.1
99                );
100                tuple
101            }
102        };
103        Ok(tuple)
104    }
105
106    pub async fn base_url(&self) -> Result<String, Error> {
107        let ap = self.get_accesspoint().await?;
108        Ok(format!("https://{}:{}", ap.0, ap.1))
109    }
110
111    fn solve_hash_cash(
112        ctx: &[u8],
113        prefix: &[u8],
114        length: i32,
115        dst: &mut [u8],
116    ) -> Result<(), Error> {
117        // after a certain number of seconds, the challenge expires
118        const TIMEOUT: u64 = 5; // seconds
119        let now = Instant::now();
120
121        let md = Sha1::digest(ctx);
122
123        let mut counter: i64 = 0;
124        let target: i64 = BigEndian::read_i64(&md[12..20]);
125
126        let suffix = loop {
127            if now.elapsed().as_secs() >= TIMEOUT {
128                return Err(Error::deadline_exceeded(format!(
129                    "{TIMEOUT} seconds expired"
130                )));
131            }
132
133            let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat();
134
135            let mut hasher = Sha1::new();
136            hasher.update(prefix);
137            hasher.update(&suffix);
138            let md = hasher.finalize();
139
140            if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) {
141                break suffix;
142            }
143
144            counter += 1;
145        };
146
147        dst.copy_from_slice(&suffix);
148
149        Ok(())
150    }
151
152    async fn client_token_request<M: Message>(&self, message: &M) -> Result<Bytes, Error> {
153        let body = message.write_to_bytes()?;
154
155        let request = Request::builder()
156            .method(&Method::POST)
157            .uri("https://clienttoken.spotify.com/v1/clienttoken")
158            .header(ACCEPT, HeaderValue::from_static("application/x-protobuf"))
159            .body(Body::from(body))?;
160
161        self.session().http_client().request_body(request).await
162    }
163
164    pub async fn client_token(&self) -> Result<String, Error> {
165        let client_token = self.lock(|inner| {
166            if let Some(token) = &inner.client_token {
167                if token.is_expired() {
168                    inner.client_token = None;
169                }
170            }
171            inner.client_token.clone()
172        });
173
174        if let Some(client_token) = client_token {
175            return Ok(client_token.access_token);
176        }
177
178        debug!("Client token unavailable or expired, requesting new token.");
179
180        let mut request = ClientTokenRequest::new();
181        request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into();
182
183        let client_data = request.mut_client_data();
184
185        client_data.client_version = spotify_version();
186
187        // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out,
188        // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the
189        // ID of the client that last connected, but requesting a client token with this ID only works
190        // on macOS and Windows. On Android and iOS we can send a platform-specific client ID and are
191        // then presented with a hash cash challenge. On Linux, we have to pass the old keymaster ID.
192        // We delegate most of this logic to `SessionConfig`.
193        let os = OS;
194        let client_id = match os {
195            "macos" | "windows" => self.session().client_id(),
196            os => SessionConfig::default_for_os(os).client_id,
197        };
198        client_data.client_id = client_id;
199
200        let connectivity_data = client_data.mut_connectivity_sdk_data();
201        connectivity_data.device_id = self.session().device_id().to_string();
202
203        let platform_data = connectivity_data
204            .platform_specific_data
205            .mut_or_insert_default();
206
207        let sys = System::new();
208        let os_version = sys.os_version().unwrap_or_else(|| String::from("0"));
209        let kernel_version = sys.kernel_version().unwrap_or_else(|| String::from("0"));
210
211        match os {
212            "windows" => {
213                let os_version = os_version.parse::<f32>().unwrap_or(10.) as i32;
214                let kernel_version = kernel_version.parse::<i32>().unwrap_or(21370);
215
216                let (pe, image_file) = match std::env::consts::ARCH {
217                    "arm" => (448, 452),
218                    "aarch64" => (43620, 452),
219                    "x86_64" => (34404, 34404),
220                    _ => (332, 332), // x86
221                };
222
223                let windows_data = platform_data.mut_desktop_windows();
224                windows_data.os_version = os_version;
225                windows_data.os_build = kernel_version;
226                windows_data.platform_id = 2;
227                windows_data.unknown_value_6 = 9;
228                windows_data.image_file_machine = image_file;
229                windows_data.pe_machine = pe;
230                windows_data.unknown_value_10 = true;
231            }
232            "ios" => {
233                let ios_data = platform_data.mut_ios();
234                ios_data.user_interface_idiom = 0;
235                ios_data.target_iphone_simulator = false;
236                ios_data.hw_machine = "iPhone14,5".to_string();
237                ios_data.system_version = os_version;
238            }
239            "android" => {
240                let android_data = platform_data.mut_android();
241                android_data.android_version = os_version;
242                android_data.api_version = 31;
243                android_data.device_name = "Pixel".to_owned();
244                android_data.model_str = "GF5KQ".to_owned();
245                android_data.vendor = "Google".to_owned();
246            }
247            "macos" => {
248                let macos_data = platform_data.mut_desktop_macos();
249                macos_data.system_version = os_version;
250                macos_data.hw_model = "iMac21,1".to_string();
251                macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string();
252            }
253            _ => {
254                let linux_data = platform_data.mut_desktop_linux();
255                linux_data.system_name = "Linux".to_string();
256                linux_data.system_release = kernel_version;
257                linux_data.system_version = os_version;
258                linux_data.hardware = std::env::consts::ARCH.to_string();
259            }
260        }
261
262        let mut response = self.client_token_request(&request).await?;
263        let mut count = 0;
264        const MAX_TRIES: u8 = 3;
265
266        let token_response = loop {
267            count += 1;
268
269            let message = ClientTokenResponse::parse_from_bytes(&response)?;
270
271            match ClientTokenResponseType::from_i32(message.response_type.value()) {
272                // depending on the platform, you're either given a token immediately
273                // or are presented a hash cash challenge to solve first
274                Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => {
275                    debug!("Received a granted token");
276                    break message;
277                }
278                Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => {
279                    debug!("Received a hash cash challenge, solving...");
280
281                    let challenges = message.challenges().clone();
282                    let state = challenges.state;
283                    if let Some(challenge) = challenges.challenges.first() {
284                        let hash_cash_challenge = challenge.evaluate_hashcash_parameters();
285
286                        let ctx = vec![];
287                        let prefix = HEXUPPER_PERMISSIVE
288                            .decode(hash_cash_challenge.prefix.as_bytes())
289                            .map_err(|e| {
290                                Error::failed_precondition(format!(
291                                    "Unable to decode hash cash challenge: {e}"
292                                ))
293                            })?;
294                        let length = hash_cash_challenge.length;
295
296                        let mut suffix = [0u8; 0x10];
297                        let answer = Self::solve_hash_cash(&ctx, &prefix, length, &mut suffix);
298
299                        match answer {
300                            Ok(_) => {
301                                // the suffix must be in uppercase
302                                let suffix = HEXUPPER_PERMISSIVE.encode(&suffix);
303
304                                let mut answer_message = ClientTokenRequest::new();
305                                answer_message.request_type =
306                                    ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST
307                                        .into();
308
309                                let challenge_answers = answer_message.mut_challenge_answers();
310
311                                let mut challenge_answer = ChallengeAnswer::new();
312                                challenge_answer.mut_hash_cash().suffix = suffix;
313                                challenge_answer.ChallengeType =
314                                    ChallengeType::CHALLENGE_HASH_CASH.into();
315
316                                challenge_answers.state = state.to_string();
317                                challenge_answers.answers.push(challenge_answer);
318
319                                trace!("Answering hash cash challenge");
320                                match self.client_token_request(&answer_message).await {
321                                    Ok(token) => {
322                                        response = token;
323                                        continue;
324                                    }
325                                    Err(e) => {
326                                        trace!(
327                                            "Answer not accepted {}/{}: {}",
328                                            count,
329                                            MAX_TRIES,
330                                            e
331                                        );
332                                    }
333                                }
334                            }
335                            Err(e) => trace!(
336                                "Unable to solve hash cash challenge {}/{}: {}",
337                                count,
338                                MAX_TRIES,
339                                e
340                            ),
341                        }
342
343                        if count < MAX_TRIES {
344                            response = self.client_token_request(&request).await?;
345                        } else {
346                            return Err(Error::failed_precondition(format!(
347                                "Unable to solve any of {MAX_TRIES} hash cash challenges"
348                            )));
349                        }
350                    } else {
351                        return Err(Error::failed_precondition("No challenges found"));
352                    }
353                }
354
355                Some(unknown) => {
356                    return Err(Error::unimplemented(format!(
357                        "Unknown client token response type: {unknown:?}"
358                    )))
359                }
360                None => return Err(Error::failed_precondition("No client token response type")),
361            }
362        };
363
364        let granted_token = token_response.granted_token();
365        let access_token = granted_token.token.to_owned();
366
367        self.lock(|inner| {
368            let client_token = Token {
369                access_token: access_token.clone(),
370                expires_in: Duration::from_secs(
371                    granted_token
372                        .refresh_after_seconds
373                        .try_into()
374                        .unwrap_or(7200),
375                ),
376                token_type: "client-token".to_string(),
377                scopes: granted_token
378                    .domains
379                    .iter()
380                    .map(|d| d.domain.clone())
381                    .collect(),
382                timestamp: Instant::now(),
383            };
384
385            inner.client_token = Some(client_token);
386        });
387
388        trace!("Got client token: {:?}", granted_token);
389
390        Ok(access_token)
391    }
392
393    pub async fn request_with_protobuf<M: Message + MessageFull>(
394        &self,
395        method: &Method,
396        endpoint: &str,
397        headers: Option<HeaderMap>,
398        message: &M,
399    ) -> SpClientResult {
400        let body = protobuf::text_format::print_to_string(message);
401
402        let mut headers = headers.unwrap_or_default();
403        headers.insert(
404            CONTENT_TYPE,
405            HeaderValue::from_static("application/x-protobuf"),
406        );
407
408        self.request(method, endpoint, Some(headers), Some(&body))
409            .await
410    }
411
412    pub async fn request_as_json(
413        &self,
414        method: &Method,
415        endpoint: &str,
416        headers: Option<HeaderMap>,
417        body: Option<&str>,
418    ) -> SpClientResult {
419        let mut headers = headers.unwrap_or_default();
420        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
421
422        self.request(method, endpoint, Some(headers), body).await
423    }
424
425    pub async fn request(
426        &self,
427        method: &Method,
428        endpoint: &str,
429        headers: Option<HeaderMap>,
430        body: Option<&str>,
431    ) -> SpClientResult {
432        let mut tries: usize = 0;
433        let mut last_response;
434
435        let body = body.unwrap_or_default();
436
437        loop {
438            tries += 1;
439
440            // Reconnection logic: retrieve the endpoint every iteration, so we can try
441            // another access point when we are experiencing network issues (see below).
442            let mut url = self.base_url().await?;
443            url.push_str(endpoint);
444
445            let separator = match url.find('?') {
446                Some(_) => "&",
447                None => "?",
448            };
449
450            // Add metrics. There is also an optional `partner` key with a value like
451            // `vodafone-uk` but we've yet to discover how we can find that value.
452            // For the sake of documentation you could also do "product=free" but
453            // we only support premium anyway.
454            let _ = write!(
455                url,
456                "{}product=0&country={}",
457                separator,
458                self.session().country()
459            );
460
461            // Defeat caches. Spotify-generated URLs already contain this.
462            if !url.contains("salt=") {
463                let _ = write!(url, "&salt={}", rand::thread_rng().next_u32());
464            }
465
466            let mut request = Request::builder()
467                .method(method)
468                .uri(url)
469                .body(Body::from(body.to_owned()))?;
470
471            // Reconnection logic: keep getting (cached) tokens because they might have expired.
472            let token = self
473                .session()
474                .token_provider()
475                .get_token("playlist-read")
476                .await?;
477
478            let headers_mut = request.headers_mut();
479            if let Some(ref hdrs) = headers {
480                *headers_mut = hdrs.clone();
481            }
482            headers_mut.insert(
483                AUTHORIZATION,
484                HeaderValue::from_str(&format!("{} {}", token.token_type, token.access_token,))?,
485            );
486
487            match self.client_token().await {
488                Ok(client_token) => {
489                    let _ = headers_mut.insert(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?);
490                }
491                Err(e) => {
492                    // currently these endpoints seem to work fine without it
493                    warn!("Unable to get client token: {e} Trying to continue without...")
494                }
495            }
496
497            last_response = self.session().http_client().request_body(request).await;
498
499            if last_response.is_ok() {
500                return last_response;
501            }
502
503            // Break before the reconnection logic below, so that the current access point
504            // is retained when max_tries == 1. Leave it up to the caller when to flush.
505            if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) {
506                if tries >= max_tries {
507                    break;
508                }
509            }
510
511            // Reconnection logic: drop the current access point if we are experiencing issues.
512            // This will cause the next call to base_url() to resolve a new one.
513            if let Err(ref network_error) = last_response {
514                match network_error.kind {
515                    ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
516                        // Keep trying the current access point three times before dropping it.
517                        if tries % 3 == 0 {
518                            self.flush_accesspoint().await
519                        }
520                    }
521                    _ => break, // if we can't build the request now, then we won't ever
522                }
523            }
524
525            debug!("Error was: {:?}", last_response);
526        }
527
528        last_response
529    }
530
531    pub async fn put_connect_state(
532        &self,
533        connection_id: &str,
534        state: &PutStateRequest,
535    ) -> SpClientResult {
536        let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
537
538        let mut headers = HeaderMap::new();
539        headers.insert("X-Spotify-Connection-Id", connection_id.parse()?);
540
541        self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)
542            .await
543    }
544
545    pub async fn get_metadata(&self, scope: &str, id: &SpotifyId) -> SpClientResult {
546        let endpoint = format!("/metadata/4/{}/{}", scope, id.to_base16()?);
547        self.request(&Method::GET, &endpoint, None, None).await
548    }
549
550    pub async fn get_track_metadata(&self, track_id: &SpotifyId) -> SpClientResult {
551        self.get_metadata("track", track_id).await
552    }
553
554    pub async fn get_episode_metadata(&self, episode_id: &SpotifyId) -> SpClientResult {
555        self.get_metadata("episode", episode_id).await
556    }
557
558    pub async fn get_album_metadata(&self, album_id: &SpotifyId) -> SpClientResult {
559        self.get_metadata("album", album_id).await
560    }
561
562    pub async fn get_artist_metadata(&self, artist_id: &SpotifyId) -> SpClientResult {
563        self.get_metadata("artist", artist_id).await
564    }
565
566    pub async fn get_show_metadata(&self, show_id: &SpotifyId) -> SpClientResult {
567        self.get_metadata("show", show_id).await
568    }
569
570    pub async fn get_lyrics(&self, track_id: &SpotifyId) -> SpClientResult {
571        let endpoint = format!("/color-lyrics/v2/track/{}", track_id.to_base62()?);
572
573        self.request_as_json(&Method::GET, &endpoint, None, None)
574            .await
575    }
576
577    pub async fn get_lyrics_for_image(
578        &self,
579        track_id: &SpotifyId,
580        image_id: &FileId,
581    ) -> SpClientResult {
582        let endpoint = format!(
583            "/color-lyrics/v2/track/{}/image/spotify:image:{}",
584            track_id.to_base62()?,
585            image_id
586        );
587
588        self.request_as_json(&Method::GET, &endpoint, None, None)
589            .await
590    }
591
592    pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult {
593        let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62()?);
594
595        self.request(&Method::GET, &endpoint, None, None).await
596    }
597
598    pub async fn get_user_profile(
599        &self,
600        username: &str,
601        playlist_limit: Option<u32>,
602        artist_limit: Option<u32>,
603    ) -> SpClientResult {
604        let mut endpoint = format!("/user-profile-view/v3/profile/{username}");
605
606        if playlist_limit.is_some() || artist_limit.is_some() {
607            let _ = write!(endpoint, "?");
608
609            if let Some(limit) = playlist_limit {
610                let _ = write!(endpoint, "playlist_limit={limit}");
611                if artist_limit.is_some() {
612                    let _ = write!(endpoint, "&");
613                }
614            }
615
616            if let Some(limit) = artist_limit {
617                let _ = write!(endpoint, "artist_limit={limit}");
618            }
619        }
620
621        self.request_as_json(&Method::GET, &endpoint, None, None)
622            .await
623    }
624
625    pub async fn get_user_followers(&self, username: &str) -> SpClientResult {
626        let endpoint = format!("/user-profile-view/v3/profile/{username}/followers");
627
628        self.request_as_json(&Method::GET, &endpoint, None, None)
629            .await
630    }
631
632    pub async fn get_user_following(&self, username: &str) -> SpClientResult {
633        let endpoint = format!("/user-profile-view/v3/profile/{username}/following");
634
635        self.request_as_json(&Method::GET, &endpoint, None, None)
636            .await
637    }
638
639    pub async fn get_radio_for_track(&self, track_id: &SpotifyId) -> SpClientResult {
640        let endpoint = format!(
641            "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json",
642            track_id.to_uri()?
643        );
644
645        self.request_as_json(&Method::GET, &endpoint, None, None)
646            .await
647    }
648
649    // Known working scopes: stations, tracks
650    // For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9
651    //
652    // Seen-in-the-wild but unimplemented query parameters:
653    // - image_style=gradient_overlay
654    // - excludeClusters=true
655    // - language=en
656    // - count_tracks=0
657    // - market=from_token
658    pub async fn get_apollo_station(
659        &self,
660        scope: &str,
661        context_uri: &str,
662        count: Option<usize>,
663        previous_tracks: Vec<SpotifyId>,
664        autoplay: bool,
665    ) -> SpClientResult {
666        let mut endpoint = format!("/radio-apollo/v3/{scope}/{context_uri}?autoplay={autoplay}");
667
668        // Spotify has a default of 50
669        if let Some(count) = count {
670            let _ = write!(endpoint, "&count={count}");
671        }
672
673        let previous_track_str = previous_tracks
674            .iter()
675            .map(|track| track.to_base62())
676            .collect::<Result<Vec<_>, _>>()?
677            .join(",");
678        // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items
679        if !previous_track_str.is_empty() {
680            let _ = write!(endpoint, "&prev_tracks={previous_track_str}");
681        }
682
683        self.request_as_json(&Method::GET, &endpoint, None, None)
684            .await
685    }
686
687    pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult {
688        let endpoint = next_page_uri.trim_start_matches("hm:/");
689        self.request_as_json(&Method::GET, endpoint, None, None)
690            .await
691    }
692
693    // TODO: Seen-in-the-wild but unimplemented endpoints
694    // - /presence-view/v1/buddylist
695
696    // TODO: Find endpoint for newer canvas.proto and upgrade to that.
697    pub async fn get_canvases(&self, request: EntityCanvazRequest) -> SpClientResult {
698        let endpoint = "/canvaz-cache/v0/canvases";
699        self.request_with_protobuf(&Method::POST, endpoint, None, &request)
700            .await
701    }
702
703    pub async fn get_extended_metadata(&self, request: BatchedEntityRequest) -> SpClientResult {
704        let endpoint = "/extended-metadata/v0/extended-metadata";
705        self.request_with_protobuf(&Method::POST, endpoint, None, &request)
706            .await
707    }
708
709    pub async fn get_audio_storage(&self, file_id: &FileId) -> SpClientResult {
710        let endpoint = format!(
711            "/storage-resolve/files/audio/interactive/{}",
712            file_id.to_base16()?
713        );
714        self.request(&Method::GET, &endpoint, None, None).await
715    }
716
717    pub fn stream_from_cdn(
718        &self,
719        cdn_url: &CdnUrl,
720        offset: usize,
721        length: usize,
722    ) -> Result<IntoStream<ResponseFuture>, Error> {
723        let url = cdn_url.try_get_url()?;
724        let req = Request::builder()
725            .method(&Method::GET)
726            .uri(url)
727            .header(
728                RANGE,
729                HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?,
730            )
731            .body(Body::empty())?;
732
733        let stream = self.session().http_client().request_stream(req)?;
734
735        Ok(stream)
736    }
737
738    pub async fn request_url(&self, url: &str) -> SpClientResult {
739        let request = Request::builder()
740            .method(&Method::GET)
741            .uri(url)
742            .body(Body::empty())?;
743
744        self.session().http_client().request_body(request).await
745    }
746
747    // Audio preview in 96 kbps MP3, unencrypted
748    pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult {
749        let attribute = "audio-preview-url-template";
750        let template = self
751            .session()
752            .get_user_attribute(attribute)
753            .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
754
755        let mut url = template.replace("{id}", &preview_id.to_base16()?);
756        let separator = match url.find('?') {
757            Some(_) => "&",
758            None => "?",
759        };
760        let _ = write!(url, "{}cid={}", separator, self.session().client_id());
761
762        self.request_url(&url).await
763    }
764
765    // The first 128 kB of a track, unencrypted
766    pub async fn get_head_file(&self, file_id: &FileId) -> SpClientResult {
767        let attribute = "head-files-url";
768        let template = self
769            .session()
770            .get_user_attribute(attribute)
771            .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
772
773        let url = template.replace("{file_id}", &file_id.to_base16()?);
774
775        self.request_url(&url).await
776    }
777
778    pub async fn get_image(&self, image_id: &FileId) -> SpClientResult {
779        let attribute = "image-url";
780        let template = self
781            .session()
782            .get_user_attribute(attribute)
783            .ok_or_else(|| SpClientError::Attribute(attribute.to_string()))?;
784        let url = template.replace("{file_id}", &image_id.to_base16()?);
785
786        self.request_url(&url).await
787    }
788}