1use crate::{
2 client::{
3 album::{Album, AlbumSearchResults},
4 artist::{Artist, ArtistSearchResults},
5 playlist::{Playlist, UserPlaylistsResult},
6 search_results::SearchAllResults,
7 track::Track,
8 AudioQuality, TrackURL,
9 },
10 Credentials, Error, Result,
11};
12use base64::{engine::general_purpose, Engine as _};
13use clap::ValueEnum;
14use reqwest::{
15 header::{HeaderMap, HeaderValue},
16 Method, Response, StatusCode,
17};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::HashMap;
21
22const BUNDLE_REGEX: &str =
23 r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z0-9]\d{3}/bundle\.js)"></script>"#;
24const APP_REGEX: &str = r#"cluster:"eu"}\):\(n.qobuzapi=\{app_id:"(?P<app_id>\d{9})",app_secret:"(?P<app_secret>\w{32})",base_port:"80",base_url:"https://www\.qobuz\.com",base_method:"/api\.json/0\.2/"},n"#;
25const SEED_REGEX: &str =
26 r#"[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#;
27
28macro_rules! info_regex {
29 () => {
30 r#"name:"\w+/(?P<timezone>{}([a-z]?))",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)""#
31 };
32}
33
34#[derive(Debug, Clone)]
35pub struct Client {
36 secrets: HashMap<String, String>,
37 active_secret: String,
38 app_id: String,
39 credentials: Option<Credentials>,
40 base_url: String,
41 client: reqwest::Client,
42 default_quality: AudioQuality,
43 user_token: Option<String>,
44 bundle_regex: regex::Regex,
45 app_id_regex: regex::Regex,
46 seed_regex: regex::Regex,
47}
48
49pub async fn new(
50 credentials: Option<Credentials>,
51 active_secret: Option<String>,
52 app_id: Option<String>,
53 audio_quality: Option<AudioQuality>,
54 user_token: Option<String>,
55) -> Result<Client> {
56 let mut headers = HeaderMap::new();
57 headers.insert(
58 "User-Agent",
59 HeaderValue::from_str(
60 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36",
61 )
62 .unwrap(),
63 );
64
65 let client = reqwest::Client::builder()
66 .cookie_store(true)
67 .default_headers(headers)
68 .build()
69 .unwrap();
70
71 let default_quality = if let Some(quality) = audio_quality {
72 quality
73 } else {
74 AudioQuality::Mp3
75 };
76
77 let app_id = if let Some(id) = app_id {
78 id
79 } else {
80 "".to_string()
81 };
82
83 let active_secret = if let Some(id) = active_secret {
84 id
85 } else {
86 "".to_string()
87 };
88
89 Ok(Client {
90 client,
91 secrets: HashMap::new(),
92 active_secret,
93 user_token,
94 credentials,
95 app_id,
96 default_quality,
97 base_url: "https://www.qobuz.com/api.json/0.2/".to_string(),
98 bundle_regex: regex::Regex::new(BUNDLE_REGEX).unwrap(),
99 app_id_regex: regex::Regex::new(APP_REGEX).unwrap(),
100 seed_regex: regex::Regex::new(SEED_REGEX).unwrap(),
101 })
102}
103
104#[non_exhaustive]
105enum Endpoint {
106 Album,
107 Artist,
108 Login,
109 Track,
110 UserPlaylist,
111 SearchArtists,
112 SearchAlbums,
113 TrackURL,
114 Playlist,
115 PlaylistCreate,
116 PlaylistDelete,
117 PlaylistAddTracks,
118 PlaylistDeleteTracks,
119 PlaylistUpdatePosition,
120 Search,
121}
122
123impl Endpoint {
124 fn as_str(&self) -> &str {
125 match self {
126 Endpoint::Album => "album/get",
127 Endpoint::Artist => "artist/get",
128 Endpoint::Login => "user/login",
129 Endpoint::Playlist => "playlist/get",
130 Endpoint::PlaylistCreate => "playlist/create",
131 Endpoint::PlaylistDelete => "playlist/delete",
132 Endpoint::PlaylistAddTracks => "playlist/addTracks",
133 Endpoint::PlaylistDeleteTracks => "playlist/deleteTracks",
134 Endpoint::PlaylistUpdatePosition => "playlist/updateTracksPosition",
135 Endpoint::Search => "catalog/search",
136 Endpoint::SearchAlbums => "album/search",
137 Endpoint::SearchArtists => "artist/search",
138 Endpoint::Track => "track/get",
139 Endpoint::TrackURL => "track/getFileUrl",
140 Endpoint::UserPlaylist => "playlist/getUserPlaylists",
141 }
142 }
143}
144
145macro_rules! get {
146 ($self:ident, $endpoint:expr, $params:expr) => {
147 match $self.make_get_call($endpoint, $params).await {
148 Ok(response) => match serde_json::from_str(response.as_str()) {
149 Ok(item) => Ok(item),
150 Err(error) => Err(Error::DeserializeJSON {
151 message: error.to_string(),
152 }),
153 },
154 Err(error) => Err(Error::Api {
155 message: error.to_string(),
156 }),
157 }
158 };
159}
160
161macro_rules! post {
162 ($self:ident, $endpoint:expr, $form:expr) => {
163 match $self.make_post_call($endpoint, $form).await {
164 Ok(response) => match serde_json::from_str(response.as_str()) {
165 Ok(item) => Ok(item),
166 Err(error) => Err(Error::DeserializeJSON {
167 message: error.to_string(),
168 }),
169 },
170 Err(error) => Err(Error::Api {
171 message: error.to_string(),
172 }),
173 }
174 };
175}
176
177impl Client {
178 pub fn quality(&self) -> AudioQuality {
179 self.default_quality.clone()
180 }
181
182 pub async fn setup(&mut self) -> Result<&Self> {
184 info!("setting up the api client");
185
186 let mut refresh_config = false;
187
188 if self.app_id.is_empty() || self.active_secret.is_empty() {
189 refresh_config = true;
190 }
191
192 if refresh_config {
193 self.refresh().await?;
194 }
195
196 if self.credentials.is_none() {
197 error!("credentials missing");
198 }
199
200 Ok(self)
201 }
202
203 pub async fn login(&mut self) -> Result<()> {
205 let endpoint = format!("{}{}", self.base_url, Endpoint::Login.as_str());
206
207 if let Some(creds) = &self.credentials {
208 if let (Some(username), Some(password)) = (&creds.username, &creds.password) {
209 info!(
210 "logging in with email ({}) and password **HIDDEN** for app_id {}",
211 username, self.app_id
212 );
213
214 let params = vec![
215 ("email", username.as_str()),
216 ("password", password.as_str()),
217 ("app_id", self.app_id.as_str()),
218 ];
219
220 match self.make_get_call(endpoint, Some(params)).await {
221 Ok(response) => {
222 let json: Value = serde_json::from_str(response.as_str()).unwrap();
223 info!("Successfully logged in");
224 debug!("{}", json);
225 let mut token = json["user_auth_token"].to_string();
226 token = token[1..token.len() - 1].to_string();
227
228 self.user_token = Some(token);
229 Ok(())
230 }
231 Err(_) => Err(Error::Login),
232 }
233 } else {
234 Err(Error::Login)
235 }
236 } else {
237 Err(Error::Login)
238 }
239 }
240
241 pub async fn user_playlists(&self) -> Result<UserPlaylistsResult> {
243 let endpoint = format!("{}{}", self.base_url, Endpoint::UserPlaylist.as_str());
244 let params = vec![("limit", "500"), ("extra", "tracks"), ("offset", "0")];
245
246 get!(self, endpoint, Some(params))
247 }
248
249 pub async fn playlist(&self, playlist_id: i64) -> Result<Playlist> {
251 let endpoint = format!("{}{}", self.base_url, Endpoint::Playlist.as_str());
252 let id_string = playlist_id.to_string();
253 let params = vec![
254 ("limit", "500"),
255 ("extra", "tracks"),
256 ("playlist_id", id_string.as_str()),
257 ("offset", "0"),
258 ];
259 let playlist: Result<Playlist> = get!(self, endpoint.clone(), Some(params.clone()));
260
261 if let Ok(mut playlist) = playlist {
262 if let Ok(all_items_playlist) = self.playlist_items(&mut playlist, endpoint).await {
263 Ok(all_items_playlist.clone())
264 } else {
265 Err(Error::Api {
266 message: "error fetching playlist".to_string(),
267 })
268 }
269 } else {
270 Err(Error::Api {
271 message: "error fetching playlist".to_string(),
272 })
273 }
274 }
275
276 async fn playlist_items<'p>(
277 &self,
278 playlist: &'p mut Playlist,
279 endpoint: String,
280 ) -> Result<&'p Playlist> {
281 let total_tracks = playlist.tracks_count as usize;
282 let mut all_tracks: Vec<Track> = Vec::new();
283
284 if let Some(mut tracks) = playlist.tracks.clone() {
285 all_tracks.append(&mut tracks.items);
286
287 while all_tracks.len() < total_tracks {
288 let id = playlist.id.to_string();
289 let limit_string = (total_tracks - all_tracks.len()).to_string();
290 let offset_string = all_tracks.len().to_string();
291
292 let params = vec![
293 ("limit", limit_string.as_str()),
294 ("extra", "tracks"),
295 ("playlist_id", id.as_str()),
296 ("offset", offset_string.as_str()),
297 ];
298
299 let playlist: Result<Playlist> = get!(self, endpoint.clone(), Some(params));
300
301 match &playlist {
302 Ok(playlist) => {
303 debug!("appending tracks to playlist");
304 if let Some(new_tracks) = &playlist.tracks {
305 all_tracks.append(&mut new_tracks.clone().items);
306 }
307 }
308 Err(error) => error!("{}", error.to_string()),
309 }
310 }
311
312 if !all_tracks.is_empty() {
313 tracks.items = all_tracks;
314 playlist.set_tracks(tracks);
315 }
316 }
317
318 Ok(playlist)
319 }
320
321 pub async fn create_playlist(
322 &self,
323 name: String,
324 is_public: bool,
325 description: Option<String>,
326 is_collaborative: Option<bool>,
327 ) -> Result<Playlist> {
328 let endpoint = format!("{}{}", self.base_url, Endpoint::PlaylistCreate.as_str());
329
330 let mut form_data = HashMap::new();
331 form_data.insert("name", name.as_str());
332
333 let is_collaborative = if !is_public || is_collaborative.is_none() {
334 "false".to_string()
335 } else if let Some(is_collaborative) = is_collaborative {
336 is_collaborative.to_string()
337 } else {
338 "false".to_string()
339 };
340
341 form_data.insert("is_collaborative", is_collaborative.as_str());
342
343 let is_public = is_public.to_string();
344 form_data.insert("is_public", is_public.as_str());
345
346 let description = if let Some(description) = description {
347 description
348 } else {
349 "".to_string()
350 };
351 form_data.insert("description", description.as_str());
352
353 post!(self, endpoint, form_data)
354 }
355
356 pub async fn delete_playlist(&self, playlist_id: String) -> Result<SuccessfulResponse> {
357 let endpoint = format!("{}{}", self.base_url, Endpoint::PlaylistDelete.as_str());
358
359 let mut form_data = HashMap::new();
360 form_data.insert("playlist_id", playlist_id.as_str());
361
362 post!(self, endpoint, form_data)
363 }
364
365 pub async fn playlist_add_track(
367 &self,
368 playlist_id: String,
369 track_ids: Vec<String>,
370 ) -> Result<Playlist> {
371 let endpoint = format!("{}{}", self.base_url, Endpoint::PlaylistAddTracks.as_str());
372
373 let track_ids = track_ids.join(",");
374
375 let mut form_data = HashMap::new();
376 form_data.insert("playlist_id", playlist_id.as_str());
377 form_data.insert("track_ids", track_ids.as_str());
378 form_data.insert("no_duplicate", "true");
379
380 post!(self, endpoint, form_data)
381 }
382
383 pub async fn playlist_delete_track(
385 &self,
386 playlist_id: String,
387 playlist_track_ids: Vec<String>,
388 ) -> Result<Playlist> {
389 let endpoint = format!(
390 "{}{}",
391 self.base_url,
392 Endpoint::PlaylistDeleteTracks.as_str()
393 );
394
395 let playlist_track_ids = playlist_track_ids.join(",");
396
397 let mut form_data = HashMap::new();
398 form_data.insert("playlist_id", playlist_id.as_str());
399 form_data.insert("playlist_track_ids", playlist_track_ids.as_str());
400
401 post!(self, endpoint, form_data)
402 }
403
404 pub async fn playlist_track_position(
406 &self,
407 index: usize,
408 playlist_id: String,
409 track_id: String,
410 ) -> Result<Playlist> {
411 let endpoint = format!(
412 "{}{}",
413 self.base_url,
414 Endpoint::PlaylistUpdatePosition.as_str()
415 );
416
417 let index = index.to_string();
418
419 let mut form_data = HashMap::new();
420 form_data.insert("playlist_id", playlist_id.as_str());
421 form_data.insert("playlist_track_ids", track_id.as_str());
422 form_data.insert("insert_before", index.as_str());
423
424 post!(self, endpoint, form_data)
425 }
426
427 pub async fn track(&self, track_id: i32) -> Result<Track> {
429 let endpoint = format!("{}{}", self.base_url, Endpoint::Track.as_str());
430 let track_id_string = track_id.to_string();
431 let params = vec![("track_id", track_id_string.as_str())];
432
433 get!(self, endpoint, Some(params))
434 }
435
436 pub async fn track_url(
438 &self,
439 track_id: i32,
440 fmt_id: Option<AudioQuality>,
441 sec: Option<String>,
442 ) -> Result<TrackURL> {
443 let endpoint = format!("{}{}", self.base_url, Endpoint::TrackURL.as_str());
444 let now = format!("{}", chrono::Utc::now().timestamp());
445 let secret = if let Some(secret) = sec {
446 secret
447 } else if !self.active_secret.is_empty() {
448 self.active_secret.clone()
449 } else {
450 return Err(Error::ActiveSecret);
451 };
452
453 let format_id = if let Some(quality) = fmt_id {
454 quality
455 } else {
456 self.quality()
457 };
458
459 let sig = format!(
460 "trackgetFileUrlformat_id{}intentstreamtrack_id{}{}{}",
461 format_id.clone(),
462 track_id,
463 now,
464 secret
465 );
466 let hashed_sig = format!("{:x}", md5::compute(sig.as_str()));
467
468 let track_id = track_id.to_string();
469 let format_string = format_id.to_string();
470
471 let params = vec![
472 ("request_ts", now.as_str()),
473 ("request_sig", hashed_sig.as_str()),
474 ("track_id", track_id.as_str()),
475 ("format_id", format_string.as_str()),
476 ("intent", "stream"),
477 ];
478
479 get!(self, endpoint, Some(params))
480 }
481
482 pub async fn search_all(&self, query: String) -> Result<SearchAllResults> {
483 let endpoint = format!("{}{}", self.base_url, Endpoint::Search.as_str());
484 let params = vec![("query", query.as_str()), ("limit", "500")];
485
486 get!(self, endpoint, Some(params))
487 }
488
489 pub async fn album(&self, album_id: String) -> Result<Album> {
491 let endpoint = format!("{}{}", self.base_url, Endpoint::Album.as_str());
492 let params = vec![("album_id", album_id.as_str())];
493
494 get!(self, endpoint, Some(params))
495 }
496
497 pub async fn search_albums(
499 &self,
500 query: String,
501 limit: Option<i32>,
502 ) -> Result<AlbumSearchResults> {
503 let endpoint = format!("{}{}", self.base_url, Endpoint::SearchAlbums.as_str());
504 let limit = if let Some(limit) = limit {
505 limit.to_string()
506 } else {
507 100.to_string()
508 };
509 let params = vec![("query", query.as_str()), ("limit", limit.as_str())];
510
511 get!(self, endpoint, Some(params))
512 }
513
514 pub async fn artist(&self, artist_id: i32, limit: Option<i32>) -> Result<Artist> {
516 let endpoint = format!("{}{}", self.base_url, Endpoint::Artist.as_str());
517 let limit = if let Some(limit) = limit {
518 limit.to_string()
519 } else {
520 100.to_string()
521 };
522
523 let artistid_string = artist_id.to_string();
524
525 let params = vec![
526 ("artist_id", artistid_string.as_str()),
527 ("app_id", &self.app_id),
528 ("limit", limit.as_str()),
529 ("offset", "0"),
530 ("extra", "albums"),
531 ];
532
533 get!(self, endpoint, Some(params))
534 }
535
536 pub async fn search_artists(
538 &self,
539 query: String,
540 limit: Option<i32>,
541 ) -> Result<ArtistSearchResults> {
542 let endpoint = format!("{}{}", self.base_url, Endpoint::SearchArtists.as_str());
543 let limit = if let Some(limit) = limit {
544 limit.to_string()
545 } else {
546 100.to_string()
547 };
548 let params = vec![("query", query.as_str()), ("limit", &limit)];
549
550 get!(self, endpoint, Some(params))
551 }
552
553 pub fn set_token(&mut self, token: String) {
555 self.user_token = Some(token);
556 }
557
558 pub fn set_credentials(&mut self, credentials: Credentials) {
560 self.credentials = Some(credentials);
561 }
562
563 pub fn set_app_id(&mut self, app_id: String) {
565 self.app_id = app_id;
566 }
567
568 pub fn set_active_secret(&mut self, active_secret: String) {
570 self.active_secret = active_secret;
571 }
572
573 pub fn set_default_quality(&mut self, quality: AudioQuality) {
574 self.default_quality = quality;
575 }
576
577 pub fn get_token(&self) -> Option<String> {
578 self.user_token.clone()
579 }
580
581 pub fn get_active_secret(&self) -> String {
582 self.active_secret.clone()
583 }
584
585 pub fn get_app_id(&self) -> String {
586 self.app_id.clone()
587 }
588
589 fn client_headers(&self) -> HeaderMap {
590 let mut headers = HeaderMap::new();
591
592 if !self.app_id.is_empty() {
593 info!("adding app_id to request headers: {}", self.app_id);
594 headers.insert(
595 "X-App-Id",
596 HeaderValue::from_str(self.app_id.as_str()).unwrap(),
597 );
598 } else {
599 error!("no app_id");
600 }
601
602 if let Some(token) = &self.user_token {
603 info!("adding token to request headers: {}", token);
604 headers.insert(
605 "X-User-Auth-Token",
606 HeaderValue::from_str(token.as_str()).unwrap(),
607 );
608 }
609
610 headers
611 }
612
613 async fn make_get_call(
615 &self,
616 endpoint: String,
617 params: Option<Vec<(&str, &str)>>,
618 ) -> Result<String> {
619 let headers = self.client_headers();
620
621 debug!("calling {} endpoint", endpoint);
622 let request = self.client.request(Method::GET, endpoint).headers(headers);
623
624 if let Some(p) = params {
625 let response = request.query(&p).send().await?;
626 self.handle_response(response).await
627 } else {
628 let response = request.send().await?;
629 self.handle_response(response).await
630 }
631 }
632
633 async fn make_post_call(
635 &self,
636 endpoint: String,
637 params: HashMap<&str, &str>,
638 ) -> Result<String> {
639 let headers = self.client_headers();
640
641 debug!("calling {} endpoint, with params {params:?}", endpoint);
642 let response = self
643 .client
644 .request(Method::POST, endpoint)
645 .headers(headers)
646 .form(¶ms)
647 .send()
648 .await?;
649
650 self.handle_response(response).await
651 }
652
653 async fn handle_response(&self, response: Response) -> Result<String> {
655 if response.status() == StatusCode::OK {
656 let res = response.text().await.unwrap();
657 Ok(res)
658 } else {
659 Err(Error::Api {
660 message: response.status().to_string(),
661 })
662 }
663 }
664
665 pub async fn refresh(&mut self) -> Result<()> {
668 debug!("fetching login page");
669 let play_url = "https://play.qobuz.com";
670 let login_page = self.client.get(format!("{play_url}/login")).send().await?;
671
672 let contents = login_page.text().await.unwrap();
673
674 if let Some(captures) = self.bundle_regex.captures(contents.as_str()) {
675 let bundle_path = captures.get(1).map_or("", |m| m.as_str());
676 let bundle_url = format!("{play_url}{bundle_path}");
677 if let Ok(bundle_page) = self.client.get(bundle_url).send().await {
678 if let Ok(bundle_contents) = bundle_page.text().await {
679 if let Some(captures) = self.app_id_regex.captures(bundle_contents.as_str()) {
680 let app_id = captures
681 .name("app_id")
682 .map_or("".to_string(), |m| m.as_str().to_string());
683
684 self.app_id = app_id.clone();
685
686 let seed_data = self.seed_regex.captures_iter(bundle_contents.as_str());
687
688 seed_data.for_each(|s| {
689 let seed = s.name("seed").map_or("", |m| m.as_str()).to_string();
690 let mut timezone =
691 s.name("timezone").map_or("", |m| m.as_str()).to_string();
692 crate::client::capitalize(timezone.as_mut_str());
693
694 let info_regex = format!(info_regex!(), &timezone);
695 regex::Regex::new(info_regex.as_str())
696 .unwrap()
697 .captures_iter(bundle_contents.as_str())
698 .for_each(|c| {
699 let timezone =
700 c.name("timezone").map_or("", |m| m.as_str()).to_string();
701 let info =
702 c.name("info").map_or("", |m| m.as_str()).to_string();
703 let extras =
704 c.name("extras").map_or("", |m| m.as_str()).to_string();
705
706 let chars = format!("{seed}{info}{extras}");
707
708 let encoded_secret = chars[..chars.len() - 44].to_string();
709 let decoded_secret = general_purpose::URL_SAFE
710 .decode(encoded_secret)
711 .expect("failed to decode base64 secret");
712 let secret_utf8 = std::str::from_utf8(&decoded_secret)
713 .expect("failed to convert base64 to string")
714 .to_string();
715
716 debug!(
717 "{}\t{}\t{}",
718 app_id,
719 timezone.to_lowercase(),
720 secret_utf8
721 );
722 self.secrets.insert(timezone, secret_utf8);
723 });
724 });
725
726 Ok(())
727 } else {
728 Err(Error::AppID)
729 }
730 } else {
731 Err(Error::AppID)
732 }
733 } else {
734 Err(Error::AppID)
735 }
736 } else {
737 Err(Error::AppID)
738 }
739 }
740
741 pub async fn test_secrets(&mut self) -> Result<()> {
743 let secrets = self.secrets.clone();
744 debug!("testing secrets: {secrets:?}");
745
746 for (timezone, secret) in secrets.iter() {
747 let response = self
748 .track_url(64868955, Some(AudioQuality::Mp3), Some(secret.to_string()))
749 .await;
750
751 if response.is_ok() {
752 debug!("found good secret: {}\t{}", timezone, secret);
753 let secret_string = secret.to_string();
754
755 self.set_active_secret(secret_string);
756
757 return Ok(());
758 }
759 }
760
761 Err(Error::ActiveSecret)
762 }
763}
764
765#[derive(Default, Debug, Clone, Serialize, Deserialize)]
766pub struct SuccessfulResponse {
767 status: String,
768}
769
770#[derive(Clone, Debug, Serialize, Deserialize, ValueEnum)]
771pub enum OutputFormat {
772 Json,
773 Tsv,
774}
775
776#[tokio::test]
777async fn can_use_methods() {
778 pretty_env_logger::init();
779
780 use insta::assert_yaml_snapshot;
781
782 let creds = Credentials {
783 username: Some(env!("QOBUZ_USERNAME").to_string()),
784 password: Some(env!("QOBUZ_PASSWORD").to_string()),
785 };
786
787 let mut client = new(Some(creds.clone()), None, None, None, None)
788 .await
789 .expect("failed to create client");
790
791 client.refresh().await.expect("failed to refresh config");
792 client.login().await.expect("failed to login");
793 client.test_secrets().await.expect("failed to test secrets");
794
795 assert_yaml_snapshot!(client
796 .user_playlists()
797 .await
798 .expect("failed to fetch user playlists"),
799 {
800 ".user.id" => "[id]",
801 ".user.login" => "[login]",
802 ".playlists.items[].users_count" => "0",
803 ".playlists.items[].updated_at" => "0",
804 ".playlists.total" => "0",
805 ".playlists.items[].duration" => "0",
806 ".playlists.items[].tracks_count" => "0",
807 });
808 assert_yaml_snapshot!(client
809 .search_albums("a love supreme".to_string(), Some(10))
810 .await
811 .expect("failed to search for albums"),
812 {
813 ".albums.total" => "0",
814 ".albums.items[].artist.albums_count" => "0",
815 ".albums.items[].label.albums_count" => "0",
816 ".albums.items[].purchasable_at" => "0"
817 });
818 assert_yaml_snapshot!(client
819 .album("lhrak0dpdxcbc".to_string())
820 .await
821 .expect("failed to get album"));
822 assert_yaml_snapshot!(client
823 .search_artists("pink floyd".to_string(), Some(10))
824 .await
825 .expect("failed to search artists"),
826 {
827 ".artists.items[].albums_count" => "0"
828 });
829 assert_yaml_snapshot!(client
830 .artist(148745, Some(10))
831 .await
832 .expect("failed to get artist"));
833 assert_yaml_snapshot!(client.track(155999429).await.expect("failed to get track"));
834 assert_yaml_snapshot!(client
835 .track_url(64868955, Some(AudioQuality::HIFI96), None)
836 .await
837 .expect("failed to get track url"), { ".url" => "[url]" });
838
839 }