librespot_core/
spclient.rs

1use std::{
2    fmt::Write,
3    time::{Duration, SystemTime},
4};
5
6use crate::config::{OS, os_version};
7use crate::{
8    Error, FileId, SpotifyId, SpotifyUri,
9    apresolve::SocketAddress,
10    config::SessionConfig,
11    dealer::protocol::TransferOptions,
12    error::ErrorKind,
13    protocol::{
14        autoplay_context_request::AutoplayContextRequest,
15        clienttoken_http::{
16            ChallengeAnswer, ChallengeType, ClientTokenRequest, ClientTokenRequestType,
17            ClientTokenResponse, ClientTokenResponseType,
18        },
19        connect::PutStateRequest,
20        context::Context,
21        extended_metadata::BatchedEntityRequest,
22        extended_metadata::{BatchedExtensionResponse, EntityRequest, ExtensionQuery},
23        extension_kind::ExtensionKind,
24    },
25    token::Token,
26    util,
27    version::spotify_semantic_version,
28};
29use bytes::Bytes;
30use data_encoding::HEXUPPER_PERMISSIVE;
31use futures_util::future::IntoStream;
32use http::{Uri, header::HeaderValue};
33use hyper::{
34    HeaderMap, Method, Request,
35    header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, HeaderName, RANGE},
36};
37use hyper_util::client::legacy::ResponseFuture;
38use protobuf::{Enum, EnumOrUnknown, Message, MessageFull};
39use rand::RngCore;
40use serde::Serialize;
41use sysinfo::System;
42use thiserror::Error;
43
44component! {
45    SpClient : SpClientInner {
46        accesspoint: Option<SocketAddress> = None,
47        strategy: RequestStrategy = RequestStrategy::default(),
48        client_token: Option<Token> = None,
49    }
50}
51
52pub type SpClientResult = Result<Bytes, Error>;
53
54#[allow(clippy::declare_interior_mutable_const)]
55pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
56#[allow(clippy::declare_interior_mutable_const)]
57const CONNECTION_ID: HeaderName = HeaderName::from_static("x-spotify-connection-id");
58
59const NO_METRICS_AND_SALT: RequestOptions = RequestOptions {
60    metrics: false,
61    salt: false,
62    base_url: None,
63};
64
65#[derive(Debug, Error)]
66pub enum SpClientError {
67    #[error("missing attribute {0}")]
68    Attribute(String),
69    #[error("expected data but received none")]
70    NoData,
71    #[error("expected an entry to exist in {0}")]
72    ExpectedEntry(&'static str),
73}
74
75impl From<SpClientError> for Error {
76    fn from(err: SpClientError) -> Self {
77        Self::failed_precondition(err)
78    }
79}
80
81#[derive(Copy, Clone, Debug)]
82pub enum RequestStrategy {
83    TryTimes(usize),
84    Infinitely,
85}
86
87impl Default for RequestStrategy {
88    fn default() -> Self {
89        RequestStrategy::TryTimes(10)
90    }
91}
92
93pub struct RequestOptions {
94    metrics: bool,
95    salt: bool,
96    base_url: Option<&'static str>,
97}
98
99impl Default for RequestOptions {
100    fn default() -> Self {
101        Self {
102            metrics: true,
103            salt: true,
104            base_url: None,
105        }
106    }
107}
108
109#[derive(Debug, Serialize)]
110pub struct TransferRequest {
111    pub transfer_options: TransferOptions,
112}
113
114impl SpClient {
115    pub fn set_strategy(&self, strategy: RequestStrategy) {
116        self.lock(|inner| inner.strategy = strategy)
117    }
118
119    pub async fn flush_accesspoint(&self) {
120        self.lock(|inner| inner.accesspoint = None)
121    }
122
123    pub async fn get_accesspoint(&self) -> Result<SocketAddress, Error> {
124        // Memoize the current access point.
125        let ap = self.lock(|inner| inner.accesspoint.clone());
126        let tuple = match ap {
127            Some(tuple) => tuple,
128            None => {
129                let tuple = self.session().apresolver().resolve("spclient").await?;
130                self.lock(|inner| inner.accesspoint = Some(tuple.clone()));
131                info!(
132                    "Resolved \"{}:{}\" as spclient access point",
133                    tuple.0, tuple.1
134                );
135                tuple
136            }
137        };
138        Ok(tuple)
139    }
140
141    pub async fn base_url(&self) -> Result<String, Error> {
142        let ap = self.get_accesspoint().await?;
143        Ok(format!("https://{}:{}", ap.0, ap.1))
144    }
145
146    async fn client_token_request<M: Message>(&self, message: &M) -> Result<Bytes, Error> {
147        let body = message.write_to_bytes()?;
148
149        let request = Request::builder()
150            .method(&Method::POST)
151            .uri("https://clienttoken.spotify.com/v1/clienttoken")
152            .header(ACCEPT, HeaderValue::from_static("application/x-protobuf"))
153            .body(body.into())?;
154
155        self.session().http_client().request_body(request).await
156    }
157
158    pub async fn client_token(&self) -> Result<String, Error> {
159        let client_token = self.lock(|inner| {
160            if let Some(token) = &inner.client_token {
161                if token.is_expired() {
162                    inner.client_token = None;
163                }
164            }
165            inner.client_token.clone()
166        });
167
168        if let Some(client_token) = client_token {
169            return Ok(client_token.access_token);
170        }
171
172        debug!("Client token unavailable or expired, requesting new token.");
173
174        let mut request = ClientTokenRequest::new();
175        request.request_type = ClientTokenRequestType::REQUEST_CLIENT_DATA_REQUEST.into();
176
177        let client_data = request.mut_client_data();
178
179        client_data.client_version = spotify_semantic_version();
180
181        // Current state of affairs: keymaster ID works on all tested platforms, but may be phased out,
182        // so it seems a good idea to mimick the real clients. `self.session().client_id()` returns the
183        // ID of the client that last connected, but requesting a client token with this ID only works
184        // on macOS and Windows. On Android and iOS we can send a platform-specific client ID and are
185        // then presented with a hash cash challenge. On Linux, we have to pass the old keymaster ID.
186        // We delegate most of this logic to `SessionConfig`.
187        let os = OS;
188        let client_id = match os {
189            "macos" | "windows" => self.session().client_id(),
190            os => SessionConfig::default_for_os(os).client_id,
191        };
192        client_data.client_id = client_id;
193
194        let connectivity_data = client_data.mut_connectivity_sdk_data();
195        connectivity_data.device_id = self.session().device_id().to_string();
196
197        let platform_data = connectivity_data
198            .platform_specific_data
199            .mut_or_insert_default();
200
201        let os_version = os_version();
202        let kernel_version = System::kernel_version().unwrap_or_else(|| String::from("0"));
203
204        match os {
205            "windows" => {
206                let os_version = os_version.parse::<f32>().unwrap_or(10.) as i32;
207                let kernel_version = kernel_version.parse::<i32>().unwrap_or(21370);
208
209                let (pe, image_file) = match std::env::consts::ARCH {
210                    "arm" => (448, 452),
211                    "aarch64" => (43620, 452),
212                    "x86_64" => (34404, 34404),
213                    _ => (332, 332), // x86
214                };
215
216                let windows_data = platform_data.mut_desktop_windows();
217                windows_data.os_version = os_version;
218                windows_data.os_build = kernel_version;
219                windows_data.platform_id = 2;
220                windows_data.unknown_value_6 = 9;
221                windows_data.image_file_machine = image_file;
222                windows_data.pe_machine = pe;
223                windows_data.unknown_value_10 = true;
224            }
225            "ios" => {
226                let ios_data = platform_data.mut_ios();
227                ios_data.user_interface_idiom = 0;
228                ios_data.target_iphone_simulator = false;
229                ios_data.hw_machine = "iPhone14,5".to_string();
230                ios_data.system_version = os_version;
231            }
232            "android" => {
233                let android_data = platform_data.mut_android();
234                android_data.android_version = os_version;
235                android_data.api_version = 31;
236                "Pixel".clone_into(&mut android_data.device_name);
237                "GF5KQ".clone_into(&mut android_data.model_str);
238                "Google".clone_into(&mut android_data.vendor);
239            }
240            "macos" => {
241                let macos_data = platform_data.mut_desktop_macos();
242                macos_data.system_version = os_version;
243                macos_data.hw_model = "iMac21,1".to_string();
244                macos_data.compiled_cpu_type = std::env::consts::ARCH.to_string();
245            }
246            _ => {
247                let linux_data = platform_data.mut_desktop_linux();
248                linux_data.system_name = "Linux".to_string();
249                linux_data.system_release = kernel_version;
250                linux_data.system_version = os_version;
251                linux_data.hardware = std::env::consts::ARCH.to_string();
252            }
253        }
254
255        let mut response = self.client_token_request(&request).await?;
256        let mut count = 0;
257        const MAX_TRIES: u8 = 3;
258
259        let token_response = loop {
260            count += 1;
261
262            let message = ClientTokenResponse::parse_from_bytes(&response)?;
263
264            match ClientTokenResponseType::from_i32(message.response_type.value()) {
265                // depending on the platform, you're either given a token immediately
266                // or are presented a hash cash challenge to solve first
267                Some(ClientTokenResponseType::RESPONSE_GRANTED_TOKEN_RESPONSE) => {
268                    debug!("Received a granted token");
269                    break message;
270                }
271                Some(ClientTokenResponseType::RESPONSE_CHALLENGES_RESPONSE) => {
272                    debug!("Received a hash cash challenge, solving...");
273
274                    let challenges = message.challenges().clone();
275                    let state = challenges.state;
276                    if let Some(challenge) = challenges.challenges.first() {
277                        let hash_cash_challenge = challenge.evaluate_hashcash_parameters();
278
279                        let ctx = vec![];
280                        let prefix = HEXUPPER_PERMISSIVE
281                            .decode(hash_cash_challenge.prefix.as_bytes())
282                            .map_err(|e| {
283                                Error::failed_precondition(format!(
284                                    "Unable to decode hash cash challenge: {e}"
285                                ))
286                            })?;
287                        let length = hash_cash_challenge.length;
288
289                        let mut suffix = [0u8; 0x10];
290                        let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix);
291
292                        match answer {
293                            Ok(_) => {
294                                // the suffix must be in uppercase
295                                let suffix = HEXUPPER_PERMISSIVE.encode(&suffix);
296
297                                let mut answer_message = ClientTokenRequest::new();
298                                answer_message.request_type =
299                                    ClientTokenRequestType::REQUEST_CHALLENGE_ANSWERS_REQUEST
300                                        .into();
301
302                                let challenge_answers = answer_message.mut_challenge_answers();
303
304                                let mut challenge_answer = ChallengeAnswer::new();
305                                challenge_answer.mut_hash_cash().suffix = suffix;
306                                challenge_answer.ChallengeType =
307                                    ChallengeType::CHALLENGE_HASH_CASH.into();
308
309                                challenge_answers.state = state.to_string();
310                                challenge_answers.answers.push(challenge_answer);
311
312                                trace!("Answering hash cash challenge");
313                                match self.client_token_request(&answer_message).await {
314                                    Ok(token) => {
315                                        response = token;
316                                        continue;
317                                    }
318                                    Err(e) => {
319                                        trace!("Answer not accepted {count}/{MAX_TRIES}: {e}");
320                                    }
321                                }
322                            }
323                            Err(e) => trace!(
324                                "Unable to solve hash cash challenge {count}/{MAX_TRIES}: {e}"
325                            ),
326                        }
327
328                        if count < MAX_TRIES {
329                            response = self.client_token_request(&request).await?;
330                        } else {
331                            return Err(Error::failed_precondition(format!(
332                                "Unable to solve any of {MAX_TRIES} hash cash challenges"
333                            )));
334                        }
335                    } else {
336                        return Err(Error::failed_precondition("No challenges found"));
337                    }
338                }
339
340                Some(unknown) => {
341                    return Err(Error::unimplemented(format!(
342                        "Unknown client token response type: {unknown:?}"
343                    )));
344                }
345                None => return Err(Error::failed_precondition("No client token response type")),
346            }
347        };
348
349        let granted_token = token_response.granted_token();
350        let access_token = granted_token.token.to_owned();
351
352        self.lock(|inner| {
353            let client_token = Token {
354                access_token: access_token.clone(),
355                expires_in: Duration::from_secs(
356                    granted_token
357                        .refresh_after_seconds
358                        .try_into()
359                        .unwrap_or(7200),
360                ),
361                token_type: "client-token".to_string(),
362                scopes: granted_token
363                    .domains
364                    .iter()
365                    .map(|d| d.domain.clone())
366                    .collect(),
367                timestamp: SystemTime::now(),
368            };
369
370            inner.client_token = Some(client_token);
371        });
372
373        trace!("Got client token: {granted_token:?}");
374
375        Ok(access_token)
376    }
377
378    pub async fn request_with_protobuf<M: Message + MessageFull>(
379        &self,
380        method: &Method,
381        endpoint: &str,
382        headers: Option<HeaderMap>,
383        message: &M,
384    ) -> SpClientResult {
385        self.request_with_protobuf_and_options(
386            method,
387            endpoint,
388            headers,
389            message,
390            &Default::default(),
391        )
392        .await
393    }
394
395    pub async fn request_with_protobuf_and_options<M: Message + MessageFull>(
396        &self,
397        method: &Method,
398        endpoint: &str,
399        headers: Option<HeaderMap>,
400        message: &M,
401        options: &RequestOptions,
402    ) -> SpClientResult {
403        let body = message.write_to_bytes()?;
404
405        let mut headers = headers.unwrap_or_default();
406        headers.insert(
407            CONTENT_TYPE,
408            HeaderValue::from_static("application/x-protobuf"),
409        );
410
411        self.request_with_options(method, endpoint, Some(headers), Some(&body), options)
412            .await
413    }
414
415    pub async fn request_as_json(
416        &self,
417        method: &Method,
418        endpoint: &str,
419        headers: Option<HeaderMap>,
420        body: Option<&str>,
421    ) -> SpClientResult {
422        let mut headers = headers.unwrap_or_default();
423        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
424
425        self.request(method, endpoint, Some(headers), body.map(|s| s.as_bytes()))
426            .await
427    }
428
429    pub async fn request(
430        &self,
431        method: &Method,
432        endpoint: &str,
433        headers: Option<HeaderMap>,
434        body: Option<&[u8]>,
435    ) -> SpClientResult {
436        self.request_with_options(method, endpoint, headers, body, &Default::default())
437            .await
438    }
439
440    pub async fn request_with_options(
441        &self,
442        method: &Method,
443        endpoint: &str,
444        headers: Option<HeaderMap>,
445        body: Option<&[u8]>,
446        options: &RequestOptions,
447    ) -> SpClientResult {
448        let mut tries: usize = 0;
449        let mut last_response;
450
451        let body = body.unwrap_or_default();
452
453        loop {
454            tries += 1;
455
456            // Reconnection logic: retrieve the endpoint every iteration, so we can try
457            // another access point when we are experiencing network issues (see below).
458            let mut url = match options.base_url {
459                Some(base_url) => base_url.to_string(),
460                None => self.base_url().await?,
461            };
462            url.push_str(endpoint);
463
464            // Add metrics. There is also an optional `partner` key with a value like
465            // `vodafone-uk` but we've yet to discover how we can find that value.
466            // For the sake of documentation you could also do "product=free" but
467            // we only support premium anyway.
468            if options.metrics && !url.contains("product=0") {
469                let _ = write!(
470                    url,
471                    "{}product=0&country={}",
472                    util::get_next_query_separator(&url),
473                    self.session().country()
474                );
475            }
476
477            // Defeat caches. Spotify-generated URLs already contain this.
478            if options.salt && !url.contains("salt=") {
479                let _ = write!(
480                    url,
481                    "{}salt={}",
482                    util::get_next_query_separator(&url),
483                    rand::rng().next_u32()
484                );
485            }
486
487            let mut request = Request::builder()
488                .method(method)
489                .uri(url)
490                .header(CONTENT_LENGTH, body.len())
491                .body(Bytes::copy_from_slice(body))?;
492
493            // Reconnection logic: keep getting (cached) tokens because they might have expired.
494            let token = self.session().login5().auth_token().await?;
495
496            let headers_mut = request.headers_mut();
497            if let Some(ref headers) = headers {
498                for (name, value) in headers {
499                    headers_mut.insert(name, value.clone());
500                }
501            }
502
503            headers_mut.insert(
504                AUTHORIZATION,
505                HeaderValue::from_str(&format!("{} {}", token.token_type, token.access_token,))?,
506            );
507
508            match self.client_token().await {
509                Ok(client_token) => {
510                    let _ = headers_mut.insert(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?);
511                }
512                Err(e) => {
513                    // currently these endpoints seem to work fine without it
514                    warn!("Unable to get client token: {e} Trying to continue without...")
515                }
516            }
517
518            last_response = self.session().http_client().request_body(request).await;
519
520            if last_response.is_ok() {
521                return last_response;
522            }
523
524            // Break before the reconnection logic below, so that the current access point
525            // is retained when max_tries == 1. Leave it up to the caller when to flush.
526            if let RequestStrategy::TryTimes(max_tries) = self.lock(|inner| inner.strategy) {
527                if tries >= max_tries {
528                    break;
529                }
530            }
531
532            // Reconnection logic: drop the current access point if we are experiencing issues.
533            // This will cause the next call to base_url() to resolve a new one.
534            if let Err(ref network_error) = last_response {
535                match network_error.kind {
536                    ErrorKind::Unavailable | ErrorKind::DeadlineExceeded => {
537                        // Keep trying the current access point three times before dropping it.
538                        if tries % 3 == 0 {
539                            self.flush_accesspoint().await
540                        }
541                    }
542                    _ => break, // if we can't build the request now, then we won't ever
543                }
544            }
545
546            debug!("Error was: {last_response:?}");
547        }
548
549        last_response
550    }
551
552    pub async fn put_connect_state_request(&self, state: &PutStateRequest) -> SpClientResult {
553        let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
554
555        let mut headers = HeaderMap::new();
556        headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
557
558        self.request_with_protobuf(&Method::PUT, &endpoint, Some(headers), state)
559            .await
560    }
561
562    pub async fn delete_connect_state_request(&self) -> SpClientResult {
563        let endpoint = format!("/connect-state/v1/devices/{}", self.session().device_id());
564        self.request(&Method::DELETE, &endpoint, None, None).await
565    }
566
567    pub async fn put_connect_state_inactive(&self, notify: bool) -> SpClientResult {
568        let endpoint = format!(
569            "/connect-state/v1/devices/{}/inactive?notify={notify}",
570            self.session().device_id()
571        );
572
573        let mut headers = HeaderMap::new();
574        headers.insert(CONNECTION_ID, self.session().connection_id().parse()?);
575
576        self.request(&Method::PUT, &endpoint, Some(headers), None)
577            .await
578    }
579
580    pub async fn get_extended_metadata(
581        &self,
582        request: BatchedEntityRequest,
583    ) -> Result<BatchedExtensionResponse, Error> {
584        let res = self
585            .request_with_protobuf(
586                &Method::POST,
587                "/extended-metadata/v0/extended-metadata",
588                None,
589                &request,
590            )
591            .await?;
592        Ok(BatchedExtensionResponse::parse_from_bytes(&res)?)
593    }
594
595    pub async fn get_metadata(&self, kind: ExtensionKind, id: &SpotifyUri) -> SpClientResult {
596        let req = BatchedEntityRequest {
597            entity_request: vec![EntityRequest {
598                entity_uri: id.to_uri()?,
599                query: vec![ExtensionQuery {
600                    extension_kind: EnumOrUnknown::new(kind),
601                    ..Default::default()
602                }],
603                ..Default::default()
604            }],
605            ..Default::default()
606        };
607
608        let mut res = self.get_extended_metadata(req).await?;
609        let mut extended_metadata = res
610            .extended_metadata
611            .pop()
612            .ok_or(SpClientError::ExpectedEntry("extended_metadata"))?;
613
614        let mut data = extended_metadata
615            .extension_data
616            .pop()
617            .ok_or(SpClientError::ExpectedEntry("extension_data"))?;
618
619        match data.extension_data.take() {
620            None => Err(SpClientError::ExpectedEntry("data").into()),
621            Some(data) => Ok(Bytes::from(data.value)),
622        }
623    }
624
625    pub async fn get_track_metadata(&self, track_uri: &SpotifyUri) -> SpClientResult {
626        self.get_metadata(ExtensionKind::TRACK_V4, track_uri).await
627    }
628
629    pub async fn get_episode_metadata(&self, episode_uri: &SpotifyUri) -> SpClientResult {
630        self.get_metadata(ExtensionKind::EPISODE_V4, episode_uri)
631            .await
632    }
633
634    pub async fn get_album_metadata(&self, album_uri: &SpotifyUri) -> SpClientResult {
635        self.get_metadata(ExtensionKind::ALBUM_V4, album_uri).await
636    }
637
638    pub async fn get_artist_metadata(&self, artist_uri: &SpotifyUri) -> SpClientResult {
639        self.get_metadata(ExtensionKind::ARTIST_V4, artist_uri)
640            .await
641    }
642
643    pub async fn get_show_metadata(&self, show_uri: &SpotifyUri) -> SpClientResult {
644        self.get_metadata(ExtensionKind::SHOW_V4, show_uri).await
645    }
646
647    pub async fn get_lyrics(&self, track_id: &SpotifyId) -> SpClientResult {
648        let endpoint = format!("/color-lyrics/v2/track/{}", track_id.to_base62()?);
649
650        self.request_as_json(&Method::GET, &endpoint, None, None)
651            .await
652    }
653
654    pub async fn get_lyrics_for_image(
655        &self,
656        track_id: &SpotifyId,
657        image_id: &FileId,
658    ) -> SpClientResult {
659        let endpoint = format!(
660            "/color-lyrics/v2/track/{}/image/spotify:image:{}",
661            track_id.to_base62()?,
662            image_id
663        );
664
665        self.request_as_json(&Method::GET, &endpoint, None, None)
666            .await
667    }
668
669    pub async fn get_playlist(&self, playlist_id: &SpotifyId) -> SpClientResult {
670        let endpoint = format!("/playlist/v2/playlist/{}", playlist_id.to_base62()?);
671
672        self.request(&Method::GET, &endpoint, None, None).await
673    }
674
675    pub async fn get_user_profile(
676        &self,
677        username: &str,
678        playlist_limit: Option<u32>,
679        artist_limit: Option<u32>,
680    ) -> SpClientResult {
681        let mut endpoint = format!("/user-profile-view/v3/profile/{username}");
682
683        if playlist_limit.is_some() || artist_limit.is_some() {
684            let _ = write!(endpoint, "?");
685
686            if let Some(limit) = playlist_limit {
687                let _ = write!(endpoint, "playlist_limit={limit}");
688                if artist_limit.is_some() {
689                    let _ = write!(endpoint, "&");
690                }
691            }
692
693            if let Some(limit) = artist_limit {
694                let _ = write!(endpoint, "artist_limit={limit}");
695            }
696        }
697
698        self.request_as_json(&Method::GET, &endpoint, None, None)
699            .await
700    }
701
702    pub async fn get_user_followers(&self, username: &str) -> SpClientResult {
703        let endpoint = format!("/user-profile-view/v3/profile/{username}/followers");
704
705        self.request_as_json(&Method::GET, &endpoint, None, None)
706            .await
707    }
708
709    pub async fn get_user_following(&self, username: &str) -> SpClientResult {
710        let endpoint = format!("/user-profile-view/v3/profile/{username}/following");
711
712        self.request_as_json(&Method::GET, &endpoint, None, None)
713            .await
714    }
715
716    pub async fn get_radio_for_track(&self, track_uri: &SpotifyUri) -> SpClientResult {
717        let endpoint = format!(
718            "/inspiredby-mix/v2/seed_to_playlist/{}?response-format=json",
719            track_uri.to_uri()?
720        );
721
722        self.request_as_json(&Method::GET, &endpoint, None, None)
723            .await
724    }
725
726    // Known working scopes: stations, tracks
727    // For others see: https://gist.github.com/roderickvd/62df5b74d2179a12de6817a37bb474f9
728    //
729    // Seen-in-the-wild but unimplemented query parameters:
730    // - image_style=gradient_overlay
731    // - excludeClusters=true
732    // - language=en
733    // - count_tracks=0
734    // - market=from_token
735    pub async fn get_apollo_station(
736        &self,
737        scope: &str,
738        context_uri: &str,
739        count: Option<usize>,
740        previous_tracks: Vec<SpotifyId>,
741        autoplay: bool,
742    ) -> SpClientResult {
743        let mut endpoint = format!("/radio-apollo/v3/{scope}/{context_uri}?autoplay={autoplay}");
744
745        // Spotify has a default of 50
746        if let Some(count) = count {
747            let _ = write!(endpoint, "&count={count}");
748        }
749
750        let previous_track_str = previous_tracks
751            .iter()
752            .map(|track| track.to_base62())
753            .collect::<Result<Vec<_>, _>>()?
754            .join(",");
755        // better than checking `previous_tracks.len() > 0` because the `filter_map` could still return 0 items
756        if !previous_track_str.is_empty() {
757            let _ = write!(endpoint, "&prev_tracks={previous_track_str}");
758        }
759
760        self.request_as_json(&Method::GET, &endpoint, None, None)
761            .await
762    }
763
764    pub async fn get_next_page(&self, next_page_uri: &str) -> SpClientResult {
765        let endpoint = next_page_uri.trim_start_matches("hm:/");
766        self.request_as_json(&Method::GET, endpoint, None, None)
767            .await
768    }
769
770    // TODO: Seen-in-the-wild but unimplemented endpoints
771    // - /presence-view/v1/buddylist
772
773    pub async fn get_audio_storage(&self, file_id: &FileId) -> SpClientResult {
774        let endpoint = format!(
775            "/storage-resolve/files/audio/interactive/{}",
776            file_id.to_base16()?
777        );
778        self.request(&Method::GET, &endpoint, None, None).await
779    }
780
781    pub fn stream_from_cdn<U>(
782        &self,
783        cdn_url: U,
784        offset: usize,
785        length: usize,
786    ) -> Result<IntoStream<ResponseFuture>, Error>
787    where
788        U: TryInto<Uri>,
789        <U as TryInto<Uri>>::Error: Into<http::Error>,
790    {
791        let req = Request::builder()
792            .method(&Method::GET)
793            .uri(cdn_url)
794            .header(
795                RANGE,
796                HeaderValue::from_str(&format!("bytes={}-{}", offset, offset + length - 1))?,
797            )
798            .body(Bytes::new())?;
799
800        let stream = self.session().http_client().request_stream(req)?;
801
802        Ok(stream)
803    }
804
805    pub async fn request_url(&self, url: &str) -> SpClientResult {
806        let request = Request::builder()
807            .method(&Method::GET)
808            .uri(url)
809            .body(Bytes::new())?;
810
811        self.session().http_client().request_body(request).await
812    }
813
814    // Audio preview in 96 kbps MP3, unencrypted
815    pub async fn get_audio_preview(&self, preview_id: &FileId) -> SpClientResult {
816        const ATTRIBUTE: &str = "audio-preview-url-template";
817        let template = self
818            .session()
819            .get_user_attribute(ATTRIBUTE)
820            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
821
822        let mut url = template.replace("{id}", &preview_id.to_base16()?);
823        let separator = match url.find('?') {
824            Some(_) => "&",
825            None => "?",
826        };
827        let _ = write!(url, "{}cid={}", separator, self.session().client_id());
828
829        self.request_url(&url).await
830    }
831
832    // The first 128 kB of a track, unencrypted
833    pub async fn get_head_file(&self, file_id: &FileId) -> SpClientResult {
834        const ATTRIBUTE: &str = "head-files-url";
835        let template = self
836            .session()
837            .get_user_attribute(ATTRIBUTE)
838            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
839
840        let url = template.replace("{file_id}", &file_id.to_base16()?);
841
842        self.request_url(&url).await
843    }
844
845    pub async fn get_image(&self, image_id: &FileId) -> SpClientResult {
846        const ATTRIBUTE: &str = "image-url";
847        let template = self
848            .session()
849            .get_user_attribute(ATTRIBUTE)
850            .ok_or_else(|| SpClientError::Attribute(ATTRIBUTE.to_string()))?;
851        let url = template.replace("{file_id}", &image_id.to_base16()?);
852
853        self.request_url(&url).await
854    }
855
856    /// Request the context for an uri
857    ///
858    /// All [SpotifyId] uris are supported in addition to the following special uris:
859    /// - liked songs:
860    ///   - all: `spotify:user:<user_id>:collection`
861    ///   - of artist: `spotify:user:<user_id>:collection:artist:<artist_id>`
862    /// - search: `spotify:search:<search+query>` (whitespaces are replaced with `+`)
863    ///
864    /// ## Query params found in the wild:
865    /// - include_video=true
866    ///
867    /// ## Known results of uri types:
868    /// - uris of type `track`
869    ///   - returns a single page with a single track
870    ///   - when requesting a single track with a query in the request, the returned track uri
871    ///     **will** contain the query
872    /// - uris of type `artist`
873    ///   - returns 2 pages with tracks: 10 most popular tracks and latest/popular album
874    ///   - remaining pages are artist albums sorted by popularity (only provided as page_url)
875    /// - uris of type `search`
876    ///   - is massively influenced by the provided query
877    ///   - the query result shown by the search expects no query at all
878    ///   - uri looks like `spotify:search:never+gonna`
879    pub async fn get_context(&self, uri: &str) -> Result<Context, Error> {
880        let uri = format!("/context-resolve/v1/{uri}");
881
882        let res = self
883            .request_with_options(&Method::GET, &uri, None, None, &NO_METRICS_AND_SALT)
884            .await?;
885        let ctx_json = String::from_utf8(res.to_vec())?;
886        if ctx_json.is_empty() {
887            Err(SpClientError::NoData)?
888        }
889
890        let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
891
892        if ctx.is_err() {
893            trace!("failed parsing context: {ctx_json}")
894        }
895
896        Ok(ctx?)
897    }
898
899    pub async fn get_autoplay_context(
900        &self,
901        context_request: &AutoplayContextRequest,
902    ) -> Result<Context, Error> {
903        let res = self
904            .request_with_protobuf_and_options(
905                &Method::POST,
906                "/context-resolve/v1/autoplay",
907                None,
908                context_request,
909                &NO_METRICS_AND_SALT,
910            )
911            .await?;
912
913        let ctx_json = String::from_utf8(res.to_vec())?;
914        if ctx_json.is_empty() {
915            Err(SpClientError::NoData)?
916        }
917
918        let ctx = protobuf_json_mapping::parse_from_str::<Context>(&ctx_json);
919
920        if ctx.is_err() {
921            trace!("failed parsing context: {ctx_json}")
922        }
923
924        Ok(ctx?)
925    }
926
927    pub async fn get_rootlist(&self, from: usize, length: Option<usize>) -> SpClientResult {
928        let length = length.unwrap_or(120);
929        let user = self.session().username();
930        let endpoint = format!(
931            "/playlist/v2/user/{user}/rootlist?decorate=revision,attributes,length,owner,capabilities,status_code&from={from}&length={length}"
932        );
933
934        self.request(&Method::GET, &endpoint, None, None).await
935    }
936
937    /// Triggers the transfers of the playback from one device to another
938    ///
939    /// Using the same `device_id` for `from_device_id` and `to_device_id`, initiates the transfer
940    /// from the currently active device.
941    pub async fn transfer(
942        &self,
943        from_device_id: &str,
944        to_device_id: &str,
945        transfer_request: Option<&TransferRequest>,
946    ) -> SpClientResult {
947        let body = transfer_request.map(serde_json::to_string).transpose()?;
948
949        let endpoint =
950            format!("/connect-state/v1/connect/transfer/from/{from_device_id}/to/{to_device_id}");
951        self.request_with_options(
952            &Method::POST,
953            &endpoint,
954            None,
955            body.as_deref().map(|s| s.as_bytes()),
956            &NO_METRICS_AND_SALT,
957        )
958        .await
959    }
960}