Compare commits

...

3 commits

Author SHA1 Message Date
6b4312a6a5
feat: add Musixmatch Desktop client
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
2024-02-03 16:26:21 +01:00
d4eb894648
fix: add album type 'EP' 2024-02-03 13:35:40 +01:00
2a03bb6d77
chore: update dev dependencies 2024-02-03 12:59:10 +01:00
15 changed files with 243 additions and 130 deletions

View file

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

View file

@ -10,4 +10,5 @@ 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
- cargo test --workspace - MUSIXMATCH_CLIENT=Desktop cargo test --workspace
- MUSIXMATCH_CLIENT=Android cargo test --workspace

View file

@ -28,6 +28,7 @@ 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"
@ -46,12 +47,13 @@ base64 = "0.21.0"
[dev-dependencies] [dev-dependencies]
ctor = "0.2.0" ctor = "0.2.0"
rstest = { version = "0.17.0", default-features = false } rstest = { version = "0.18.0", default-features = false }
env_logger = "0.10.0" env_logger = "0.11.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,35 +1,39 @@
# Musixmatch-Inofficial # Musixmatch-Inofficial
This is an inofficial client for the Musixmatch API that uses the This is an inofficial client for the Musixmatch API that uses the key embedded in the
key embedded in the Musixmatch Android app. Musixmatch Android app or desktop client.
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.
A free Musixmatch account is required for operation If you use the Android client, you need a free Musixmatch account
([you can sign up here](https://www.musixmatch.com/de/sign-up)). ([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can
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 Song lyrics are copyrighted works (just like books, poems and the songs themselves).
themselves).
Musixmatch does allow its users to obtains song lyrics for private Musixmatch does allow its users to obtains song lyrics for private use (e.g. to enrich
use (e.g. to enrich their music collection). But it does not allow you their music collection). But it does not allow you to publish their lyrics or use them
to publish their lyrics or use them commercially. commercially.
You will get in trouble if you use this client to create a public You will get in trouble if you use this client to create a public lyrics site/app. If
lyrics site/app. If you want to use Musixmatch data for this purpose, you want to use Musixmatch data for this purpose, you will have to give them money (see
you will have to give them money (see their their [commercial plans](https://developer.musixmatch.com/plans)) and use their
[commercial plans](https://developer.musixmatch.com/plans)) [official API](https://developer.musixmatch.com/documentation).
and use their [official API](https://developer.musixmatch.com/documentation).
## Development info ## Development info
Running the tests requires Musixmatch credentials. The credentials are read You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment
from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment variables. variable to either `Desktop` or `Android` (it defaults to Desktop).
To make local development easier, I have included `dotenvy` to read the Running the tests for the Android client requires Musixmatch credentials. The
credentials from an `.env` file. Copy the `.env.example` file credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment
in the root directory, rename it to `.env` and fill in your credentials. variables.
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,3 +1,5 @@
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
@ -36,6 +38,9 @@ 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 {
@ -44,3 +49,9 @@ 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,11 +35,30 @@ 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]");
const APP_ID: &str = "android-player-v1.0"; /// Hardcoded client configuration
const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/"; struct ClientCfg {
const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('"; app_id: &'static str,
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";
@ -57,6 +76,7 @@ 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>,
@ -64,6 +84,29 @@ 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),
@ -86,6 +129,8 @@ 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>>,
@ -99,6 +144,7 @@ struct Credentials {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct StoredSession { struct StoredSession {
client_type: ClientType,
usertoken: String, usertoken: String,
} }
@ -167,6 +213,12 @@ 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());
@ -187,13 +239,18 @@ 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 = Musixmatch::retrieve_session(&storage); let stored_session =
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(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned())) .user_agent(
self.user_agent
.unwrap_or_else(|| client_cfg.user_agent.to_owned()),
)
.gzip(true) .gzip(true)
.default_headers(headers) .default_headers(headers)
.build()?; .build()?;
@ -203,6 +260,8 @@ 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)),
@ -236,49 +295,66 @@ impl Musixmatch {
} }
} }
let credentials = { let credentials = if self.inner.client_cfg.login {
let c = self.inner.credentials.read().unwrap(); let c = self.inner.credentials.read().unwrap();
match c.deref() { match c.deref() {
Some(c) => c.clone(), Some(c) => Some(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 mut url = Url::parse_with_params( let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get");
&format!("{}{}", API_URL, "token.get"), let mut url = match self.inner.client_type {
&[ ClientType::Desktop => Url::parse_with_params(
("adv_id", adv_id.as_str()), &base_url,
("root", "0"), &[
("sideloaded", "0"), ("format", "json"),
("app_id", "android-player-v1.0"), ("user_language", "en"),
// App version (7.9.5) ("app_id", self.inner.client_cfg.app_id),
("build_number", "2022090901"), ],
("guid", guid.as_str()), ),
("lang", "en_US"), ClientType::Android => {
("model", self.model_string().as_str()), let guid = random_guid();
( let adv_id = random_uuid();
"timestamp", Url::parse_with_params(
now.format(&Rfc3339).unwrap_or_default().as_str(), &base_url,
), &[
("format", "json"), ("adv_id", adv_id.as_str()),
], ("root", "0"),
) ("sideloaded", "0"),
.unwrap(); ("app_id", self.inner.client_cfg.app_id),
sign_url_with_date(&mut url, now); // App version (7.9.5)
("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]);
let account = self.post_credentials(&usertoken, &credentials).await?; if let Some(credentials) = credentials {
info!("Logged in as {} ", account.name); let account = self.post_credentials(&usertoken, &credentials).await?;
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);
@ -290,8 +366,8 @@ impl Musixmatch {
usertoken: &str, usertoken: &str,
credentials: &Credentials, credentials: &Credentials,
) -> Result<api_model::Account> { ) -> Result<api_model::Account> {
let mut url = new_url_from_token("credential.post", usertoken); let mut url = self.new_url_from_token("credential.post", usertoken)?;
sign_url_with_date(&mut url, OffsetDateTime::now_utc()); self.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 {
@ -334,6 +410,7 @@ 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(),
}; };
@ -368,12 +445,12 @@ impl Musixmatch {
) )
} }
fn new_url(&self, endpoint: &str) -> reqwest::Url { fn new_url(&self, endpoint: &str) -> Result<reqwest::Url> {
Url::parse_with_params( Url::parse_with_params(
&format!("{}{}", API_URL, endpoint), &format!("{}{}", self.inner.client_cfg.api_url, endpoint),
&[("app_id", APP_ID), ("format", "json")], &[("app_id", self.inner.client_cfg.app_id), ("format", "json")],
) )
.unwrap() .map_err(Error::from)
} }
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<()> {
@ -382,7 +459,7 @@ impl Musixmatch {
.append_pair("usertoken", &usertoken) .append_pair("usertoken", &usertoken)
.finish(); .finish();
sign_url_with_date(url, OffsetDateTime::now_utc()); self.sign_url_with_date(url, OffsetDateTime::now_utc());
Ok(()) Ok(())
} }
@ -441,6 +518,33 @@ 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 {
@ -461,33 +565,6 @@ 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;
@ -496,8 +573,12 @@ 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();
sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC)); mxm.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,6 +88,7 @@ pub struct Album {
pub enum AlbumType { pub enum AlbumType {
#[default] #[default]
Album, Album,
Ep,
Single, Single,
Compilation, Compilation,
Remix, Remix,

View file

@ -1,4 +1,5 @@
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};
@ -260,12 +261,11 @@ 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 let mut t = self.lines.iter().fold(String::new(), |mut acc, line| {
.lines _ = writeln!(acc, "[{}] {}", line.time.format_lrc(), line.text);
.iter() acc
.map(|line| format!("[{}] {}\n", line.time.format_lrc(), line.text)) });
.collect::<String>(); t.pop(); // trailing newline
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},
Error, Musixmatch, ClientType, Error, Musixmatch,
}; };
#[ctor::ctor] #[ctor::ctor]
@ -22,13 +22,20 @@ fn init() {
} }
fn new_mxm() -> Musixmatch { fn new_mxm() -> Musixmatch {
Musixmatch::builder() let client_type = std::env::var("MUSIXMATCH_CLIENT")
.credentials( .map(|ctype| serde_plain::from_str::<ClientType>(&ctype).expect("valid client type"))
std::env::var("MUSIXMATCH_EMAIL").unwrap(), .unwrap_or_default();
std::env::var("MUSIXMATCH_PASSWORD").unwrap(),
) let mut mxm = Musixmatch::builder().client_type(client_type);
.build()
.unwrap() if let (Ok(email), Ok(password)) = (
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 {
@ -101,10 +108,11 @@ mod album {
} }
#[tokio::test] #[tokio::test]
async fn album_tst() { async fn album_ep() {
let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap(); let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap();
println!("type: {:?}", album.album_release_type); assert_eq!(album.album_name, "Waldbrand EP");
println!("date: {:?}", album.album_release_date); assert_eq!(album.album_release_type, AlbumType::Ep);
assert_eq!(album.album_release_date, Some(date!(2016 - 09 - 30)));
} }
#[tokio::test] #[tokio::test]
@ -407,7 +415,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 - Single"); assert_eq!(track.album_name, "Black Mamba");
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!(
@ -665,7 +673,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]
@ -992,7 +1000,11 @@ mod translation {
#[tokio::test] #[tokio::test]
async fn no_credentials() { async fn no_credentials() {
let mxm = Musixmatch::builder().no_storage().build().unwrap(); let mxm = Musixmatch::builder()
.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