Compare commits
	
		
			2 commits
		
	
	
		
			
				7589ec5051
			
			...
			
				815a64c8e7
			
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 815a64c8e7 | |||
| 285f6ea65f | 
					 4 changed files with 166 additions and 117 deletions
				
			
		| 
						 | 
				
			
			@ -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"] }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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(_) => {}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										258
									
								
								src/lib.rs
									
										
									
									
									
								
							
							
						
						
									
										258
									
								
								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<MusixmatchRef>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Musixmatch API client builder
 | 
			
		||||
///
 | 
			
		||||
/// Used to construct a new [`Musixmatch`] client.#
 | 
			
		||||
#[derive(Default)]
 | 
			
		||||
pub struct MusixmatchBuilder {
 | 
			
		||||
    user_agent: Option<String>,
 | 
			
		||||
    brand: Option<String>,
 | 
			
		||||
    device: Option<String>,
 | 
			
		||||
    no_storage: bool,
 | 
			
		||||
    storage: Option<Box<dyn SessionStorage>>,
 | 
			
		||||
    credentials: Option<Credentials>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
struct MusixmatchRef {
 | 
			
		||||
    http: Client,
 | 
			
		||||
    storage: Option<Box<dyn SessionStorage>>,
 | 
			
		||||
    credentials: RwLock<Credentials>,
 | 
			
		||||
    credentials: RwLock<Option<Credentials>>,
 | 
			
		||||
    brand: String,
 | 
			
		||||
    device: String,
 | 
			
		||||
    ua: String,
 | 
			
		||||
    usertoken: Mutex<Option<String>>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[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 <https://www.musixmatch.com> 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<S: Into<String>, S2: Into<String>>(
 | 
			
		||||
        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<dyn SessionStorage>) -> 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<P: AsRef<Path>>(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<S: Into<String>>(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<S: Into<String>>(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<S: Into<String>>(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::<FileStorage>::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<Box<dyn SessionStorage>>) -> 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<P: AsRef<Path>>(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<String> {
 | 
			
		||||
| 
						 | 
				
			
			@ -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<S: Into<String>, S2: Into<String>>(&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::<String>()
 | 
			
		||||
        + " "
 | 
			
		||||
        + &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),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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::<FileStorage>::default()),
 | 
			
		||||
    )
 | 
			
		||||
    Musixmatch::builder()
 | 
			
		||||
        .credentials(
 | 
			
		||||
            std::env::var("MUSIXMATCH_EMAIL").unwrap(),
 | 
			
		||||
            std::env::var("MUSIXMATCH_PASSWORD").unwrap(),
 | 
			
		||||
        )
 | 
			
		||||
        .build()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn testfile<P: AsRef<Path>>(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}");
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue