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/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..9c0949f 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,8 @@ 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::header::{self, HeaderMap}; use reqwest::{Client, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -42,17 +44,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 +74,6 @@ struct Credentials { #[derive(Debug, Serialize, Deserialize)] struct StoredSession { - brand: String, - device: String, - ua: String, usertoken: String, } @@ -70,74 +81,135 @@ 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('"; +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() + } + + /// Set the Musixmatch credentials + /// + /// You have to create a free account on to use + /// the API. + /// + /// 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 + } + + /// 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 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) + .default_headers(headers) + .build() + .expect("http client could not be constructed"); + + Musixmatch { + inner: MusixmatchRef { + http, + storage, + 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(), + } + } +} + impl Default for Musixmatch { fn default() -> Self { - Musixmatch::with_file_storage("", "", storage::DEFAULT_PATH) + MusixmatchBuilder::new().build() } } impl Musixmatch { - /// Create a new Musixmatch client - /// - /// # Parameters - /// - `email`: Musixmatch account email - /// - `password`: Musixmatch account password - /// - `storage`: Session storage backend - /// - /// **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); - - 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) - } - }; - - let http = Client::builder() - .user_agent(&ua) - .gzip(true) - .cookie_store(true) - .build() - .expect("http client could not be constructed"); - - Self { - inner: Arc::new(MusixmatchRef { - http, - storage, - credentials: RwLock::new(Credentials { - email: email.to_owned(), - password: password.to_owned(), - }), - usertoken: Mutex::new(usertoken), - brand, - device, - ua, - }), - } - } - - /// 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)))) + /// Create a new Musixmatch client builder + pub fn builder() -> MusixmatchBuilder { + MusixmatchBuilder::default() } async fn get_usertoken(&self, force_new: bool) -> Result { @@ -152,10 +224,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 +320,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 +420,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 +447,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}"); +}