librespot_core/
spclient.rs

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