From 285f6ea65f41d6026df7955c08e79605ec1941ce Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 23 Apr 2023 18:34:59 +0200 Subject: [PATCH 1/2] feat!: use builder to construct mxm client --- cli/src/main.rs | 2 +- src/lib.rs | 230 ++++++++++++++++++++++++++++-------------------- tests/tests.rs | 22 +++-- 3 files changed, 150 insertions(+), 104 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index a4cf8c1..4fd725b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -174,7 +174,7 @@ async fn run(cli: Cli) -> Result<()> { std::fs::create_dir_all(&storage_file)?; storage_file.push("musixmatch_session.json"); - let mxm = Musixmatch::with_file_storage("", "", storage_file); + let mxm = Musixmatch::builder().storage_file(storage_file).build(); match mxm.login().await { Ok(_) => {} diff --git a/src/lib.rs b/src/lib.rs index e8b014c..6bc4eac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod models; pub mod storage; use std::fmt::Debug; +use std::ops::Deref; use std::path::Path; use std::sync::{Arc, RwLock}; @@ -16,7 +17,7 @@ pub use error::Error; use base64::Engine; use hmac::{Hmac, Mac}; use log::{error, info, warn}; -use rand::{seq::SliceRandom, Rng}; +use rand::Rng; use reqwest::{Client, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -42,17 +43,29 @@ pub struct Musixmatch { inner: Arc, } +/// Musixmatch API client builder +/// +/// Used to construct a new [`Musixmatch`] client.# +#[derive(Default)] +pub struct MusixmatchBuilder { + user_agent: Option, + brand: Option, + device: Option, + no_storage: bool, + storage: Option>, + credentials: Option, +} + struct MusixmatchRef { http: Client, storage: Option>, - credentials: RwLock, + credentials: RwLock>, brand: String, device: String, - ua: String, usertoken: Mutex>, } -#[derive(Clone)] +#[derive(Default, Clone)] struct Credentials { email: String, password: String, @@ -60,9 +73,6 @@ struct Credentials { #[derive(Debug, Serialize, Deserialize)] struct StoredSession { - brand: String, - device: String, - ua: String, usertoken: String, } @@ -70,74 +80,132 @@ const APP_ID: &str = "android-player-v1.0"; const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/"; const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('"; -impl Default for Musixmatch { - fn default() -> Self { - Musixmatch::with_file_storage("", "", storage::DEFAULT_PATH) +const DEFAULT_UA: &str = "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)"; +const DEFAULT_BRAND: &str = "Google"; +const DEFAULT_DEVICE: &str = "Pixel 6"; + +impl MusixmatchBuilder { + /// Create a new Musixmatch client builder. + /// + /// This is the same as `Musixmatch::builder()` + pub fn new() -> Self { + Self::default() } -} -impl Musixmatch { - /// Create a new Musixmatch client + /// Set the Musixmatch credentials /// - /// # Parameters - /// - `email`: Musixmatch account email - /// - `password`: Musixmatch account password - /// - `storage`: Session storage backend + /// You have to create a free account on to use + /// the API. /// - /// **Note:** You can set the username/password to an empty string if - /// you don't want to store credentials in your application. If the client - /// can not obtain a valid session from the storage, it will return [`Error::MissingCredentials`] - /// when trying to execute a request. In this case you can prompt the user to enter credentials, add them to the client - /// with the [`Musixmatch::set_credentials`] method and attempt another request. - pub fn new(email: &str, password: &str, storage: Option>) -> Self { - let stored_session = Self::retrieve_session(&storage); + /// The Musixmatch client can be constructed without any credentials. + /// In this case you rely on the stored session token to authenticate + /// yourself. If the token is missing or invalid, [`Error::MissingCredentials`] + /// is returned when attempting an API request. + /// + /// In this case you can prompt the user to enter credentials, add them to the client + /// with the [`Musixmatch::set_credentials`] function and attempt another request. + /// + /// This mode of operation is preferred for interactive applications since + /// it does not require storing credentials. + pub fn credentials, S2: Into>( + mut self, + email: S, + password: S2, + ) -> Self { + self.credentials = Some(Credentials { + email: email.into(), + password: password.into(), + }); + self + } - let (brand, device, ua, usertoken) = match stored_session { - Some(session) => ( - session.brand, - session.device, - session.ua, - Some(session.usertoken), - ), - None => { - let brand = random_brand(); - let device = random_device(); - let ua = random_user_agent(&brand, &device); - (brand, device, ua, None) - } + /// Add a [`SessionStorage`] backend for persisting the Musixmatch session + /// token between requests. + /// + /// **Default value**: [`FileStorage`] in `musixmatch_session.json` + pub fn storage(mut self, storage: Box) -> Self { + self.storage = Some(storage); + self.no_storage = false; + self + } + + /// Add a [`FileStorage`] backend for persisting the Musixmatch session + /// token between requests. + pub fn storage_file>(self, path: P) -> Self { + self.storage(Box::from(FileStorage::new(path))) + } + + /// Disable session token storage + /// + /// This is not recommended, since the endpoint to generate new + /// session tokens is rate limited to 2 requests per minute. + pub fn no_storage(mut self) -> Self { + self.storage = None; + self.no_storage = true; + self + } + + /// Set the user agent of the Musixmatch client + pub fn user_agent>(mut self, user_agent: S) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Set the device brand of the Musixmatch client + pub fn device_brand>(mut self, device_brand: S) -> Self { + self.brand = Some(device_brand.into()); + self + } + + /// Set the device model of the Musixmatch client + pub fn device_model>(mut self, device_model: S) -> Self { + self.device = Some(device_model.into()); + self + } + + /// Returns a new, configured Musixmatch client + pub fn build(self) -> Musixmatch { + let storage = if self.no_storage { + None + } else { + Some( + self.storage + .unwrap_or_else(|| Box::::default()), + ) }; + let stored_session = Musixmatch::retrieve_session(&storage); let http = Client::builder() - .user_agent(&ua) + .user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned())) .gzip(true) .cookie_store(true) .build() .expect("http client could not be constructed"); - Self { - inner: Arc::new(MusixmatchRef { + Musixmatch { + inner: MusixmatchRef { http, storage, - credentials: RwLock::new(Credentials { - email: email.to_owned(), - password: password.to_owned(), - }), - usertoken: Mutex::new(usertoken), - brand, - device, - ua, - }), + credentials: RwLock::new(self.credentials), + brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()), + device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()), + usertoken: Mutex::new(stored_session.map(|s| s.usertoken)), + } + .into(), } } +} - /// Create a new Musixmatch client with a json-file-based session storage - /// - /// # Parameters - /// - `email`: Musixmatch account email - /// - `password`: Musixmatch account password - /// - `path`: Path of the session file - pub fn with_file_storage>(email: &str, password: &str, path: P) -> Self { - Self::new(email, password, Some(Box::new(FileStorage::new(path)))) +impl Default for Musixmatch { + fn default() -> Self { + MusixmatchBuilder::new().build() + } +} + +impl Musixmatch { + /// Create a new Musixmatch client builder + pub fn builder() -> MusixmatchBuilder { + MusixmatchBuilder::default() } async fn get_usertoken(&self, force_new: bool) -> Result { @@ -152,10 +220,10 @@ impl Musixmatch { let credentials = { let c = self.inner.credentials.read().unwrap(); - if c.email.is_empty() || c.password.is_empty() { - return Err(Error::MissingCredentials); + match c.deref() { + Some(c) => c.clone(), + None => return Err(Error::MissingCredentials), } - c.clone() }; let now = OffsetDateTime::now_utc(); @@ -248,9 +316,6 @@ impl Musixmatch { fn store_session(&self, usertoken: &str) { if let Some(storage) = &self.inner.storage { let to_store = StoredSession { - brand: self.inner.brand.to_owned(), - device: self.inner.device.to_owned(), - ua: self.inner.ua.to_owned(), usertoken: usertoken.to_owned(), }; @@ -351,10 +416,12 @@ impl Musixmatch { } /// Change the Musixmatch credentials - pub fn set_credentials(&self, email: &str, password: &str) { + pub fn set_credentials, S2: Into>(&self, email: S, password: S2) { let mut c = self.inner.credentials.write().unwrap(); - c.email = email.to_owned(); - c.password = password.to_owned() + *c = Some(Credentials { + email: email.into(), + password: password.into(), + }); } } @@ -376,37 +443,6 @@ fn random_uuid() -> String { ) } -const PARTS: [&str; 23] = [ - "one", "sam", "mar", "lif", "zap", "win", "pul", "hik", "xyl", "real", "sung", "life", "core", - "heart", "flow", "power", "x", "q", "mi", "xi", "shi", "plus", "minus", -]; - -fn random_brand() -> String { - let mut rng = rand::thread_rng(); - let n = rng.gen_range(2..=3); - PARTS.choose_multiple(&mut rng, n).copied().collect() -} - -fn random_device() -> String { - let mut rng = rand::thread_rng(); - PARTS - .choose_multiple(&mut rng, 2) - .copied() - .collect::() - + " " - + &rng.gen_range(1..20).to_string() -} - -fn random_user_agent(brand: &str, device: &str) -> String { - let mut rng = rand::thread_rng(); - format!( - "Dalvik/2.1.0 (Linux; U; Android 12; {} {} Build/{:06})", - brand, - device, - rng.gen_range(10000..299999) - ) -} - fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url { Url::parse_with_params( &format!("{}{}", API_URL, endpoint), diff --git a/tests/tests.rs b/tests/tests.rs index 2159cfb..6051b58 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -6,7 +6,6 @@ use time::macros::{date, datetime}; use musixmatch_inofficial::{ models::{AlbumId, ArtistId, TrackId}, - storage::FileStorage, Error, Musixmatch, }; @@ -23,11 +22,12 @@ fn init() { } fn new_mxm() -> Musixmatch { - Musixmatch::new( - &std::env::var("MUSIXMATCH_EMAIL").unwrap(), - &std::env::var("MUSIXMATCH_PASSWORD").unwrap(), - Some(Box::::default()), - ) + Musixmatch::builder() + .credentials( + std::env::var("MUSIXMATCH_EMAIL").unwrap(), + std::env::var("MUSIXMATCH_PASSWORD").unwrap(), + ) + .build() } fn testfile>(name: P) -> PathBuf { @@ -987,3 +987,13 @@ mod translation { assert_eq!(subtitles_trans.to_ttml().trim(), expected_ttml.trim()); } } + +#[tokio::test] +async fn no_credentials() { + let mxm = Musixmatch::builder().no_storage().build(); + let err = mxm + .track_lyrics(TrackId::TrackId(205688271)) + .await + .unwrap_err(); + assert!(matches!(err, Error::MissingCredentials), "error: {err}"); +} From 815a64c8e751b8a0b9dfd831ec0a3f652181d74f Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Wed, 26 Apr 2023 20:06:42 +0200 Subject: [PATCH 2/2] fix: replace cookie store with fixed cookie --- Cargo.toml | 1 - src/lib.rs | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 525922b..34fa3a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,6 @@ rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] reqwest = { version = "0.11.11", default-features = false, features = [ "json", "gzip", - "cookies", ] } tokio = { version = "1.20.0" } serde = { version = "1.0", features = ["derive"] } diff --git a/src/lib.rs b/src/lib.rs index 6bc4eac..9c0949f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ use base64::Engine; use hmac::{Hmac, Mac}; use log::{error, info, warn}; use rand::Rng; +use reqwest::header::{self, HeaderMap}; use reqwest::{Client, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -175,10 +176,13 @@ impl MusixmatchBuilder { }; let stored_session = Musixmatch::retrieve_session(&storage); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap()); + let http = Client::builder() .user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned())) .gzip(true) - .cookie_store(true) + .default_headers(headers) .build() .expect("http client could not be constructed");