Compare commits
3 commits
ea0f606f75
...
6b4312a6a5
Author | SHA1 | Date | |
---|---|---|---|
6b4312a6a5 | |||
d4eb894648 | |||
2a03bb6d77 |
15 changed files with 243 additions and 130 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
44
README.md
44
README.md
|
@ -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.
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
11
src/error.rs
11
src/error.rs
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
217
src/lib.rs
217
src/lib.rs
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,6 +88,7 @@ pub struct Album {
|
||||||
pub enum AlbumType {
|
pub enum AlbumType {
|
||||||
#[default]
|
#[default]
|
||||||
Album,
|
Album,
|
||||||
|
Ep,
|
||||||
Single,
|
Single,
|
||||||
Compilation,
|
Compilation,
|
||||||
Remix,
|
Remix,
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue