diff --git a/Cargo.toml b/Cargo.toml index 34fa3a5..525922b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ 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 4fd725b..a4cf8c1 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::builder().storage_file(storage_file).build(); + let mxm = Musixmatch::with_file_storage("", "", storage_file); match mxm.login().await { Ok(_) => {} diff --git a/src/lib.rs b/src/lib.rs index 9c0949f..e8b014c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,6 @@ pub mod models; pub mod storage; use std::fmt::Debug; -use std::ops::Deref; use std::path::Path; use std::sync::{Arc, RwLock}; @@ -17,8 +16,7 @@ pub use error::Error; use base64::Engine; use hmac::{Hmac, Mac}; use log::{error, info, warn}; -use rand::Rng; -use reqwest::header::{self, HeaderMap}; +use rand::{seq::SliceRandom, Rng}; use reqwest::{Client, Url}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -44,29 +42,17 @@ 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(Default, Clone)] +#[derive(Clone)] struct Credentials { email: String, password: String, @@ -74,6 +60,9 @@ struct Credentials { #[derive(Debug, Serialize, Deserialize)] struct StoredSession { + brand: String, + device: String, + ua: String, usertoken: String, } @@ -81,135 +70,74 @@ 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 { - MusixmatchBuilder::new().build() + Musixmatch::with_file_storage("", "", storage::DEFAULT_PATH) } } impl Musixmatch { - /// Create a new Musixmatch client builder - pub fn builder() -> MusixmatchBuilder { - MusixmatchBuilder::default() + /// 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)))) } async fn get_usertoken(&self, force_new: bool) -> Result { @@ -224,10 +152,10 @@ impl Musixmatch { let credentials = { let c = self.inner.credentials.read().unwrap(); - match c.deref() { - Some(c) => c.clone(), - None => return Err(Error::MissingCredentials), + if c.email.is_empty() || c.password.is_empty() { + return Err(Error::MissingCredentials); } + c.clone() }; let now = OffsetDateTime::now_utc(); @@ -320,6 +248,9 @@ 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(), }; @@ -420,12 +351,10 @@ impl Musixmatch { } /// Change the Musixmatch credentials - pub fn set_credentials, S2: Into>(&self, email: S, password: S2) { + pub fn set_credentials(&self, email: &str, password: &str) { let mut c = self.inner.credentials.write().unwrap(); - *c = Some(Credentials { - email: email.into(), - password: password.into(), - }); + c.email = email.to_owned(); + c.password = password.to_owned() } } @@ -447,6 +376,37 @@ 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 6051b58..2159cfb 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -6,6 +6,7 @@ use time::macros::{date, datetime}; use musixmatch_inofficial::{ models::{AlbumId, ArtistId, TrackId}, + storage::FileStorage, Error, Musixmatch, }; @@ -22,12 +23,11 @@ fn init() { } fn new_mxm() -> Musixmatch { - Musixmatch::builder() - .credentials( - std::env::var("MUSIXMATCH_EMAIL").unwrap(), - std::env::var("MUSIXMATCH_PASSWORD").unwrap(), - ) - .build() + Musixmatch::new( + &std::env::var("MUSIXMATCH_EMAIL").unwrap(), + &std::env::var("MUSIXMATCH_PASSWORD").unwrap(), + Some(Box::::default()), + ) } fn testfile>(name: P) -> PathBuf { @@ -987,13 +987,3 @@ 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}"); -}