hifirs_qobuz_api/client/
api.rs

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    /// Setup app_id, secret and user credentials for authentication
183    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    /// Login a user
204    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    /// Retrieve a list of the user's playlists
242    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    /// Retrieve a playlist
250    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    /// Add new track to playlist
366    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    /// Add new track to playlist
384    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    /// Update track position in playlist
405    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    /// Retrieve track information
428    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    /// Retrieve url information for a track's audio file
437    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    // Retrieve information about an album
490    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    // Search the database for albums
498    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    // Retrieve information about an artist
515    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    // Search the database for artists
537    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    // Set a user access token for authentication
554    pub fn set_token(&mut self, token: String) {
555        self.user_token = Some(token);
556    }
557
558    // Set a username for authentication
559    pub fn set_credentials(&mut self, credentials: Credentials) {
560        self.credentials = Some(credentials);
561    }
562
563    // Set an app_id for authentication
564    pub fn set_app_id(&mut self, app_id: String) {
565        self.app_id = app_id;
566    }
567
568    // Set an app secret for authentication
569    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    // Make a GET call to the API with the provided parameters
614    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    // Make a POST call to the API with form data
634    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(&params)
647            .send()
648            .await?;
649
650        self.handle_response(response).await
651    }
652
653    // Handle a response retrieved from the api
654    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    // ported from https://github.com/vitiko98/qobuz-dl/blob/master/qobuz_dl/bundle.py
666    // Retrieve the app_id and generate the secrets needed to authenticate
667    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    // Check the retrieved secrets to see which one works.
742    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    // let new_playlist: Playlist = assert_ok!(
840    //     client
841    //         .create_playlist(
842    //             "test".to_string(),
843    //             false,
844    //             Some("This is a description".to_string()),
845    //             Some(false)
846    //         )
847    //         .await,
848    //     "creating a new playlist"
849    // );
850    //
851    // assert_ok!(
852    //     client
853    //         .playlist_add_track(new_playlist.id.to_string(), vec![155999429.to_string()])
854    //         .await,
855    //     "adding a track to newly created playlist"
856    // );
857    //
858    // assert_ok!(
859    //     client
860    //         .playlist_delete_track(new_playlist.id.to_string(), vec![155999429.to_string()])
861    //         .await,
862    //     "deleting track from the newly created playlist"
863    // );
864    //
865    // assert_ok!(
866    //     client.delete_playlist(new_playlist.id.to_string()).await,
867    //     "deleting the newly created playlist"
868    // );
869}