Compare commits

..

No commits in common. "6b4312a6a5dc8476851bf055f0aa4305cf3cd7fe" and "ea0f606f7557b4be03473ef145681eeda8ac7513" have entirely different histories.

15 changed files with 130 additions and 243 deletions

View file

@ -1,3 +1,2 @@
MUSIXMATCH_CLIENT=Android
MUSIXMATCH_EMAIL=mail@example.com MUSIXMATCH_EMAIL=mail@example.com
MUSIXMATCH_PASSWORD=super-secret MUSIXMATCH_PASSWORD=super-secret

View file

@ -10,5 +10,4 @@ pipeline:
- rustup component add rustfmt clippy - rustup component add rustfmt clippy
- cargo fmt --all --check - cargo fmt --all --check
- cargo clippy --all -- -D warnings - cargo clippy --all -- -D warnings
- MUSIXMATCH_CLIENT=Desktop cargo test --workspace - cargo test --workspace
- MUSIXMATCH_CLIENT=Android cargo test --workspace

View file

@ -28,7 +28,6 @@ reqwest = { version = "0.11.11", default-features = false, features = [
"json", "json",
"gzip", "gzip",
] } ] }
url = "2.0.0"
tokio = { version = "1.20.0" } tokio = { version = "1.20.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85" serde_json = "1.0.85"
@ -47,13 +46,12 @@ base64 = "0.21.0"
[dev-dependencies] [dev-dependencies]
ctor = "0.2.0" ctor = "0.2.0"
rstest = { version = "0.18.0", default-features = false } rstest = { version = "0.17.0", default-features = false }
env_logger = "0.11.0" env_logger = "0.10.0"
dotenvy = "0.15.5" dotenvy = "0.15.5"
tokio = { version = "1.20.0", features = ["macros"] } tokio = { version = "1.20.0", features = ["macros"] }
futures = "0.3.21" futures = "0.3.21"
path_macro = "1.0.0" path_macro = "1.0.0"
serde_plain = "1.0.2"
[profile.release] [profile.release]
strip = true strip = true

View file

@ -1,39 +1,35 @@
# Musixmatch-Inofficial # Musixmatch-Inofficial
This is an inofficial client for the Musixmatch API that uses the key embedded in the This is an inofficial client for the Musixmatch API that uses the
Musixmatch Android app or desktop client. key embedded in the Musixmatch Android app.
It allows you to obtain synchronized lyrics in different formats It allows you to obtain synchronized lyrics in different formats
([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>), ([LRC](https://en.wikipedia.org/wiki/LRC_(file_format)),
[DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song. [DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song.
If you use the Android client, you need a free Musixmatch account A free Musixmatch account is required for operation
([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can ([you can sign up here](https://www.musixmatch.com/de/sign-up)).
be used anonymously and is currently the default option, but since Musixmatch
discontinued that application, they may shut it down.
## ⚠️ Copyright disclaimer ## ⚠️ Copyright disclaimer
Song lyrics are copyrighted works (just like books, poems and the songs themselves). Song lyrics are copyrighted works (just like books, poems and the songs
themselves).
Musixmatch does allow its users to obtains song lyrics for private use (e.g. to enrich Musixmatch does allow its users to obtains song lyrics for private
their music collection). But it does not allow you to publish their lyrics or use them use (e.g. to enrich their music collection). But it does not allow you
commercially. to publish their lyrics or use them commercially.
You will get in trouble if you use this client to create a public lyrics site/app. If You will get in trouble if you use this client to create a public
you want to use Musixmatch data for this purpose, you will have to give them money (see lyrics site/app. If you want to use Musixmatch data for this purpose,
their [commercial plans](https://developer.musixmatch.com/plans)) and use their you will have to give them money (see their
[official API](https://developer.musixmatch.com/documentation). [commercial plans](https://developer.musixmatch.com/plans))
and use their [official API](https://developer.musixmatch.com/documentation).
## Development info ## Development info
You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment Running the tests requires Musixmatch credentials. The credentials are read
variable to either `Desktop` or `Android` (it defaults to Desktop). from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment variables.
Running the tests for the Android client requires Musixmatch credentials. The To make local development easier, I have included `dotenvy` to read the
credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment credentials from an `.env` file. Copy the `.env.example` file
variables. in the root directory, rename it to `.env` and fill in your credentials.
To make local development easier, I have included `dotenvy` to read the credentials from
an `.env` file. Copy the `.env.example` file in the root directory, rename it to `.env`
and fill in your credentials.

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/album-get> /// <https://developer.musixmatch.com/documentation/api-reference/album-get>
pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> { pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> {
let mut url = self.new_url("album.get")?; let mut url = self.new_url("album.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -43,7 +43,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Album>> { ) -> Result<Vec<Album>> {
let mut url = self.new_url("artist.albums.get")?; let mut url = self.new_url("artist.albums.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -80,7 +80,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Album>> { ) -> Result<Vec<Album>> {
let mut url = self.new_url("chart.albums.get")?; let mut url = self.new_url("chart.albums.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get> /// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> { pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> {
let mut url = self.new_url("artist.get")?; let mut url = self.new_url("artist.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -40,7 +40,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.related.get")?; let mut url = self.new_url("artist.related.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -74,7 +74,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.search")?; let mut url = self.new_url("artist.search");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -107,7 +107,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("chart.artists.get")?; let mut url = self.new_url("chart.artists.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

@ -14,7 +14,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get> /// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result<Lyrics> { pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result<Lyrics> {
let mut url = self.new_url("matcher.lyrics.get")?; let mut url = self.new_url("matcher.lyrics.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
if !q_track.is_empty() { if !q_track.is_empty() {
@ -39,7 +39,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get> /// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> { pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> {
let mut url = self.new_url("track.lyrics.get")?; let mut url = self.new_url("track.lyrics.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
let id_param = id.to_param(); let id_param = id.to_param();
@ -66,7 +66,7 @@ impl Musixmatch {
id: TrackId<'_>, id: TrackId<'_>,
selected_language: &str, selected_language: &str,
) -> Result<TranslationList> { ) -> Result<TranslationList> {
let mut url = self.new_url("crowd.track.translations.get")?; let mut url = self.new_url("crowd.track.translations.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
let id_param = id.to_param(); let id_param = id.to_param();

View file

@ -16,7 +16,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get> /// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> { pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> {
let mut url = self.new_url("track.snippet.get")?; let mut url = self.new_url("track.snippet.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

@ -25,7 +25,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>, f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>, f_subtitle_length_max_deviation: Option<f32>,
) -> Result<Subtitle> { ) -> Result<Subtitle> {
let mut url = self.new_url("matcher.subtitle.get")?; let mut url = self.new_url("matcher.subtitle.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
if !q_track.is_empty() { if !q_track.is_empty() {
@ -73,7 +73,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>, f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>, f_subtitle_length_max_deviation: Option<f32>,
) -> Result<Subtitle> { ) -> Result<Subtitle> {
let mut url = self.new_url("track.subtitle.get")?; let mut url = self.new_url("track.subtitle.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

@ -27,7 +27,7 @@ impl Musixmatch {
translation_status: bool, translation_status: bool,
lang_3c: bool, lang_3c: bool,
) -> Result<Track> { ) -> Result<Track> {
let mut url = self.new_url("matcher.track.get")?; let mut url = self.new_url("matcher.track.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -74,7 +74,7 @@ impl Musixmatch {
translation_status: bool, translation_status: bool,
lang_3c: bool, lang_3c: bool,
) -> Result<Track> { ) -> Result<Track> {
let mut url = self.new_url("track.get")?; let mut url = self.new_url("track.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -114,7 +114,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Track>> { ) -> Result<Vec<Track>> {
let mut url = self.new_url("album.tracks.get")?; let mut url = self.new_url("album.tracks.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -155,7 +155,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Track>> { ) -> Result<Vec<Track>> {
let mut url = self.new_url("chart.tracks.get")?; let mut url = self.new_url("chart.tracks.get");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -184,7 +184,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get> /// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
pub async fn genres(&self) -> Result<Vec<Genre>> { pub async fn genres(&self) -> Result<Vec<Genre>> {
let url = self.new_url("music.genres.get")?; let url = self.new_url("music.genres.get");
let genres = self.execute_get_request::<Genres>(&url).await?; let genres = self.execute_get_request::<Genres>(&url).await?;
Ok(genres.music_genre_list) Ok(genres.music_genre_list)
} }
@ -347,7 +347,7 @@ impl<'a> TrackSearchQuery<'a> {
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100. /// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
/// - `page`: Define the page number for paginated results, starting from 1. /// - `page`: Define the page number for paginated results, starting from 1.
pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> { pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> {
let mut url = self.mxm.new_url("track.search")?; let mut url = self.mxm.new_url("track.search");
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

@ -1,5 +1,3 @@
use std::borrow::Cow;
pub(crate) type Result<T> = core::result::Result<T, Error>; pub(crate) type Result<T> = core::result::Result<T, Error>;
/// Custom error type for the Musixmatch client /// Custom error type for the Musixmatch client
@ -38,9 +36,6 @@ pub enum Error {
/// Error from the HTTP client /// Error from the HTTP client
#[error("http error: {0}")] #[error("http error: {0}")]
Http(reqwest::Error), Http(reqwest::Error),
/// Unspecified error
#[error("{0}")]
Other(Cow<'static, str>),
} }
impl From<reqwest::Error> for Error { impl From<reqwest::Error> for Error {
@ -49,9 +44,3 @@ impl From<reqwest::Error> for Error {
Self::Http(value.without_url()) Self::Http(value.without_url())
} }
} }
impl From<url::ParseError> for Error {
fn from(value: url::ParseError) -> Self {
Self::Other(format!("url parse error: {value}").into())
}
}

View file

@ -35,30 +35,11 @@ use crate::error::Result;
const YMD_FORMAT: &[time::format_description::FormatItem] = const YMD_FORMAT: &[time::format_description::FormatItem] =
format_description!("[year][month][day]"); format_description!("[year][month][day]");
/// Hardcoded client configuration const APP_ID: &str = "android-player-v1.0";
struct ClientCfg { const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/";
app_id: &'static str, const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('";
api_url: &'static str,
signature_secret: &'static [u8; 20],
user_agent: &'static str,
login: bool,
}
const DESKTOP_CLIENT: ClientCfg = ClientCfg {
app_id: "web-desktop-app-v1.0",
api_url: "https://apic-desktop.musixmatch.com/ws/1.1/",
signature_secret: b"IEJ5E8XFaHQvIQNfs7IC",
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Musixmatch/0.19.4 Chrome/58.0.3029.110 Electron/1.7.6 Safari/537.36",
login: false
};
const ANDROID_CLIENT: ClientCfg = ClientCfg {
app_id: "android-player-v1.0",
api_url: "https://apic.musixmatch.com/ws/1.1/",
signature_secret: b"967Pn4)N3&R_GBg5$b('",
user_agent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)",
login: true,
};
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_BRAND: &str = "Google";
const DEFAULT_DEVICE: &str = "Pixel 6"; const DEFAULT_DEVICE: &str = "Pixel 6";
@ -76,7 +57,6 @@ pub struct Musixmatch {
/// Used to construct a new [`Musixmatch`] client.# /// Used to construct a new [`Musixmatch`] client.#
#[derive(Default)] #[derive(Default)]
pub struct MusixmatchBuilder { pub struct MusixmatchBuilder {
client_type: ClientType,
user_agent: Option<String>, user_agent: Option<String>,
brand: Option<String>, brand: Option<String>,
device: Option<String>, device: Option<String>,
@ -84,29 +64,6 @@ pub struct MusixmatchBuilder {
credentials: Option<Credentials>, credentials: Option<Credentials>,
} }
/// Musixmatch client type
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientType {
/// The desktop client is used with Musixmatch's electron-based Desktop application.
///
/// The client allows anonymous access and is currently the default option.
///
/// Since Musixmatch's desktop application is discontinued, the client may stop working in the future.
#[default]
Desktop,
/// The Android client requires a (free) Musixmatch account
Android,
}
impl From<ClientType> for ClientCfg {
fn from(value: ClientType) -> Self {
match value {
ClientType::Desktop => DESKTOP_CLIENT,
ClientType::Android => ANDROID_CLIENT,
}
}
}
#[derive(Default)] #[derive(Default)]
enum DefaultOpt<T> { enum DefaultOpt<T> {
Some(T), Some(T),
@ -129,8 +86,6 @@ struct MusixmatchRef {
http: Client, http: Client,
storage: Option<Box<dyn SessionStorage>>, storage: Option<Box<dyn SessionStorage>>,
credentials: RwLock<Option<Credentials>>, credentials: RwLock<Option<Credentials>>,
client_type: ClientType,
client_cfg: ClientCfg,
brand: String, brand: String,
device: String, device: String,
usertoken: Mutex<Option<String>>, usertoken: Mutex<Option<String>>,
@ -144,7 +99,6 @@ struct Credentials {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct StoredSession { struct StoredSession {
client_type: ClientType,
usertoken: String, usertoken: String,
} }
@ -213,12 +167,6 @@ impl MusixmatchBuilder {
self self
} }
/// Set the client type (Desktop, Android) of the Musixmatch client
pub fn client_type(mut self, client_type: ClientType) -> Self {
self.client_type = client_type;
self
}
/// Set the device brand of the Musixmatch client /// Set the device brand of the Musixmatch client
pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self { pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self {
self.brand = Some(device_brand.into()); self.brand = Some(device_brand.into());
@ -239,18 +187,13 @@ impl MusixmatchBuilder {
/// Returns a new, configured Musixmatch client using a Reqwest client builder /// Returns a new, configured Musixmatch client using a Reqwest client builder
pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> { pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> {
let storage = self.storage.or_default(|| Box::<FileStorage>::default()); let storage = self.storage.or_default(|| Box::<FileStorage>::default());
let stored_session = let stored_session = Musixmatch::retrieve_session(&storage);
Musixmatch::retrieve_session(&storage).filter(|s| s.client_type == self.client_type);
let client_cfg = ClientCfg::from(self.client_type);
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap()); headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap());
let http = client_builder let http = client_builder
.user_agent( .user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned()))
self.user_agent
.unwrap_or_else(|| client_cfg.user_agent.to_owned()),
)
.gzip(true) .gzip(true)
.default_headers(headers) .default_headers(headers)
.build()?; .build()?;
@ -260,8 +203,6 @@ impl MusixmatchBuilder {
http, http,
storage, storage,
credentials: RwLock::new(self.credentials), credentials: RwLock::new(self.credentials),
client_type: self.client_type,
client_cfg,
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()), brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()), device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)), usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
@ -295,66 +236,49 @@ impl Musixmatch {
} }
} }
let credentials = if self.inner.client_cfg.login { let credentials = {
let c = self.inner.credentials.read().unwrap(); let c = self.inner.credentials.read().unwrap();
match c.deref() { match c.deref() {
Some(c) => Some(c.clone()), Some(c) => c.clone(),
None => return Err(Error::MissingCredentials), None => return Err(Error::MissingCredentials),
} }
} else {
None
}; };
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let guid = random_guid();
let adv_id = random_uuid();
// Get user token // Get user token
// The get_token endpoint seems to be rate limited for 2 requests per minute // The get_token endpoint seems to be rate limited for 2 requests per minute
let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get"); let mut url = Url::parse_with_params(
let mut url = match self.inner.client_type { &format!("{}{}", API_URL, "token.get"),
ClientType::Desktop => Url::parse_with_params( &[
&base_url, ("adv_id", adv_id.as_str()),
&[ ("root", "0"),
("format", "json"), ("sideloaded", "0"),
("user_language", "en"), ("app_id", "android-player-v1.0"),
("app_id", self.inner.client_cfg.app_id), // App version (7.9.5)
], ("build_number", "2022090901"),
), ("guid", guid.as_str()),
ClientType::Android => { ("lang", "en_US"),
let guid = random_guid(); ("model", self.model_string().as_str()),
let adv_id = random_uuid(); (
Url::parse_with_params( "timestamp",
&base_url, now.format(&Rfc3339).unwrap_or_default().as_str(),
&[ ),
("adv_id", adv_id.as_str()), ("format", "json"),
("root", "0"), ],
("sideloaded", "0"), )
("app_id", self.inner.client_cfg.app_id), .unwrap();
// App version (7.9.5) sign_url_with_date(&mut url, now);
("build_number", "2022090901"),
("guid", guid.as_str()),
("lang", "en_US"),
("model", self.model_string().as_str()),
(
"timestamp",
now.format(&Rfc3339).unwrap_or_default().as_str(),
),
("format", "json"),
("user_language", "en"),
],
)
}
}?;
self.sign_url_with_date(&mut url, now);
let resp = self.inner.http.get(url).send().await?.error_for_status()?; let resp = self.inner.http.get(url).send().await?.error_for_status()?;
let tdata = resp.json::<Resp<api_model::GetToken>>().await?; let tdata = resp.json::<Resp<api_model::GetToken>>().await?;
let usertoken = tdata.body_or_err()?.user_token; let usertoken = tdata.body_or_err()?.user_token;
info!("Received new usertoken: {}****", &usertoken[0..8]); info!("Received new usertoken: {}****", &usertoken[0..8]);
if let Some(credentials) = credentials { let account = self.post_credentials(&usertoken, &credentials).await?;
let account = self.post_credentials(&usertoken, &credentials).await?; info!("Logged in as {} ", account.name);
info!("Logged in as {} ", account.name);
}
*stored_usertoken = Some(usertoken.to_owned()); *stored_usertoken = Some(usertoken.to_owned());
self.store_session(&usertoken); self.store_session(&usertoken);
@ -366,8 +290,8 @@ impl Musixmatch {
usertoken: &str, usertoken: &str,
credentials: &Credentials, credentials: &Credentials,
) -> Result<api_model::Account> { ) -> Result<api_model::Account> {
let mut url = self.new_url_from_token("credential.post", usertoken)?; let mut url = new_url_from_token("credential.post", usertoken);
self.sign_url_with_date(&mut url, OffsetDateTime::now_utc()); sign_url_with_date(&mut url, OffsetDateTime::now_utc());
let api_credentials = api_model::Credentials { let api_credentials = api_model::Credentials {
credential_list: &[api_model::CredentialWrap { credential_list: &[api_model::CredentialWrap {
@ -410,7 +334,6 @@ impl Musixmatch {
fn store_session(&self, usertoken: &str) { fn store_session(&self, usertoken: &str) {
if let Some(storage) = &self.inner.storage { if let Some(storage) = &self.inner.storage {
let to_store = StoredSession { let to_store = StoredSession {
client_type: self.inner.client_type,
usertoken: usertoken.to_owned(), usertoken: usertoken.to_owned(),
}; };
@ -445,12 +368,12 @@ impl Musixmatch {
) )
} }
fn new_url(&self, endpoint: &str) -> Result<reqwest::Url> { fn new_url(&self, endpoint: &str) -> reqwest::Url {
Url::parse_with_params( Url::parse_with_params(
&format!("{}{}", self.inner.client_cfg.api_url, endpoint), &format!("{}{}", API_URL, endpoint),
&[("app_id", self.inner.client_cfg.app_id), ("format", "json")], &[("app_id", APP_ID), ("format", "json")],
) )
.map_err(Error::from) .unwrap()
} }
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> { async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
@ -459,7 +382,7 @@ impl Musixmatch {
.append_pair("usertoken", &usertoken) .append_pair("usertoken", &usertoken)
.finish(); .finish();
self.sign_url_with_date(url, OffsetDateTime::now_utc()); sign_url_with_date(url, OffsetDateTime::now_utc());
Ok(()) Ok(())
} }
@ -518,33 +441,6 @@ impl Musixmatch {
password: password.into(), password: password.into(),
}); });
} }
fn new_url_from_token(&self, endpoint: &str, usertoken: &str) -> Result<reqwest::Url> {
Url::parse_with_params(
&format!("{}{}", self.inner.client_cfg.api_url, endpoint),
&[
("app_id", self.inner.client_cfg.app_id),
("usertoken", usertoken),
("format", "json"),
],
)
.map_err(Error::from)
}
fn sign_url_with_date(&self, url: &mut Url, date: OffsetDateTime) {
let mut mac = Hmac::<Sha1>::new_from_slice(self.inner.client_cfg.signature_secret).unwrap();
mac.update(url.as_str().as_bytes());
mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n";
url.query_pairs_mut()
.append_pair("signature", &sig_b64)
.append_pair("signature_protocol", "sha1")
.finish();
}
} }
fn random_guid() -> String { fn random_guid() -> String {
@ -565,6 +461,33 @@ fn random_uuid() -> String {
) )
} }
fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url {
Url::parse_with_params(
&format!("{}{}", API_URL, endpoint),
&[
("app_id", APP_ID),
("usertoken", usertoken),
("format", "json"),
],
)
.unwrap()
}
fn sign_url_with_date(url: &mut Url, date: OffsetDateTime) {
let mut mac = Hmac::<Sha1>::new_from_slice(SIGNATURE_SECRET).unwrap();
mac.update(url.as_str().as_bytes());
mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n";
url.query_pairs_mut()
.append_pair("signature", &sig_b64)
.append_pair("signature_protocol", "sha1")
.finish();
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use time::macros::datetime; use time::macros::datetime;
@ -573,12 +496,8 @@ mod tests {
#[test] #[test]
fn t_sign_url() { fn t_sign_url() {
let mxm = Musixmatch::builder()
.client_type(ClientType::Android)
.build()
.unwrap();
let mut url = Url::parse("https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm").unwrap(); let mut url = Url::parse("https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm").unwrap();
mxm.sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC)); sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=cvXbedVvGneT7o4k8QG6jfk9pAM%3D%0A&signature_protocol=sha1") assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=cvXbedVvGneT7o4k8QG6jfk9pAM%3D%0A&signature_protocol=sha1")
} }
} }

View file

@ -88,7 +88,6 @@ pub struct Album {
pub enum AlbumType { pub enum AlbumType {
#[default] #[default]
Album, Album,
Ep,
Single, Single,
Compilation, Compilation,
Remix, Remix,

View file

@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Write;
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::{error::Result, Error}; use crate::{error::Result, Error};
@ -261,11 +260,12 @@ impl SubtitleLines {
/// Convert subtitles into the [LRC](SubtitleFormat::Lrc) format /// Convert subtitles into the [LRC](SubtitleFormat::Lrc) format
pub fn to_lrc(&self) -> String { pub fn to_lrc(&self) -> String {
let mut t = self.lines.iter().fold(String::new(), |mut acc, line| { let mut t = self
_ = writeln!(acc, "[{}] {}", line.time.format_lrc(), line.text); .lines
acc .iter()
}); .map(|line| format!("[{}] {}\n", line.time.format_lrc(), line.text))
t.pop(); // trailing newline .collect::<String>();
t.pop();
t t
} }

View file

@ -6,7 +6,7 @@ use time::macros::{date, datetime};
use musixmatch_inofficial::{ use musixmatch_inofficial::{
models::{AlbumId, ArtistId, TrackId}, models::{AlbumId, ArtistId, TrackId},
ClientType, Error, Musixmatch, Error, Musixmatch,
}; };
#[ctor::ctor] #[ctor::ctor]
@ -22,20 +22,13 @@ fn init() {
} }
fn new_mxm() -> Musixmatch { fn new_mxm() -> Musixmatch {
let client_type = std::env::var("MUSIXMATCH_CLIENT") Musixmatch::builder()
.map(|ctype| serde_plain::from_str::<ClientType>(&ctype).expect("valid client type")) .credentials(
.unwrap_or_default(); std::env::var("MUSIXMATCH_EMAIL").unwrap(),
std::env::var("MUSIXMATCH_PASSWORD").unwrap(),
let mut mxm = Musixmatch::builder().client_type(client_type); )
.build()
if let (Ok(email), Ok(password)) = ( .unwrap()
std::env::var("MUSIXMATCH_EMAIL"),
std::env::var("MUSIXMATCH_PASSWORD"),
) {
mxm = mxm.credentials(email, password);
}
mxm.build().unwrap()
} }
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf { fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
@ -108,11 +101,10 @@ mod album {
} }
#[tokio::test] #[tokio::test]
async fn album_ep() { async fn album_tst() {
let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap(); let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap();
assert_eq!(album.album_name, "Waldbrand EP"); println!("type: {:?}", album.album_release_type);
assert_eq!(album.album_release_type, AlbumType::Ep); println!("date: {:?}", album.album_release_date);
assert_eq!(album.album_release_date, Some(date!(2016 - 09 - 30)));
} }
#[tokio::test] #[tokio::test]
@ -415,7 +407,7 @@ mod track {
assert!(track.lyrics_id.is_some()); assert!(track.lyrics_id.is_some());
assert_eq!(track.subtitle_id.unwrap(), 36476905); assert_eq!(track.subtitle_id.unwrap(), 36476905);
assert_eq!(track.album_id, 41035954); assert_eq!(track.album_id, 41035954);
assert_eq!(track.album_name, "Black Mamba"); assert_eq!(track.album_name, "Black Mamba - Single");
assert_eq!(track.artist_id, 46970441); assert_eq!(track.artist_id, 46970441);
assert_eq!(track.artist_name, "aespa"); assert_eq!(track.artist_name, "aespa");
assert_eq!( assert_eq!(
@ -673,7 +665,7 @@ mod track {
async fn genres() { async fn genres() {
let genres = new_mxm().genres().await.unwrap(); let genres = new_mxm().genres().await.unwrap();
assert!(genres.len() > 360); assert!(genres.len() > 360);
// dbg!(&genres); dbg!(&genres);
} }
#[tokio::test] #[tokio::test]
@ -1000,11 +992,7 @@ mod translation {
#[tokio::test] #[tokio::test]
async fn no_credentials() { async fn no_credentials() {
let mxm = Musixmatch::builder() let mxm = Musixmatch::builder().no_storage().build().unwrap();
.client_type(ClientType::Android)
.no_storage()
.build()
.unwrap();
let err = mxm let err = mxm
.track_lyrics(TrackId::TrackId(205688271)) .track_lyrics(TrackId::TrackId(205688271))
.await .await