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_PASSWORD=super-secret

View file

@ -10,4 +10,5 @@ pipeline:
- rustup component add rustfmt clippy
- cargo fmt --all --check
- 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",
"gzip",
] }
url = "2.0.0"
tokio = { version = "1.20.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85"
@ -46,12 +47,13 @@ base64 = "0.21.0"
[dev-dependencies]
ctor = "0.2.0"
rstest = { version = "0.17.0", default-features = false }
env_logger = "0.10.0"
rstest = { version = "0.18.0", default-features = false }
env_logger = "0.11.0"
dotenvy = "0.15.5"
tokio = { version = "1.20.0", features = ["macros"] }
futures = "0.3.21"
path_macro = "1.0.0"
serde_plain = "1.0.2"
[profile.release]
strip = true

View file

@ -1,35 +1,39 @@
# Musixmatch-Inofficial
This is an inofficial client for the Musixmatch API that uses the
key embedded in the Musixmatch Android app.
This is an inofficial client for the Musixmatch API that uses the key embedded in the
Musixmatch Android app or desktop client.
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.
A free Musixmatch account is required for operation
([you can sign up here](https://www.musixmatch.com/de/sign-up)).
If you use the Android client, you need a free Musixmatch account
([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
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 their music collection). But it does not allow you
to publish their lyrics or use them commercially.
Musixmatch does allow its users to obtains song lyrics for private use (e.g. to enrich
their music collection). But it does not allow you 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 want to use Musixmatch data for this purpose,
you will have to give them money (see their
[commercial plans](https://developer.musixmatch.com/plans))
and use their [official API](https://developer.musixmatch.com/documentation).
You will get in trouble if you use this client to create a public lyrics site/app. If
you want to use Musixmatch data for this purpose, you will have to give them money (see
their [commercial plans](https://developer.musixmatch.com/plans)) and use their
[official API](https://developer.musixmatch.com/documentation).
## Development info
Running the tests requires Musixmatch credentials. The credentials are read
from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment variables.
You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment
variable to either `Desktop` or `Android` (it defaults to Desktop).
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.
Running the tests for the Android client requires Musixmatch credentials. The
credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment
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
/// <https://developer.musixmatch.com/documentation/api-reference/album-get>
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();
@ -43,7 +43,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -80,7 +80,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
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();
@ -40,7 +40,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -74,7 +74,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -107,7 +107,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();

View file

@ -14,7 +14,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
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();
if !q_track.is_empty() {
@ -39,7 +39,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
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 id_param = id.to_param();
@ -66,7 +66,7 @@ impl Musixmatch {
id: TrackId<'_>,
selected_language: &str,
) -> 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 id_param = id.to_param();

View file

@ -16,7 +16,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
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();

View file

@ -25,7 +25,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>,
) -> 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();
if !q_track.is_empty() {
@ -73,7 +73,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>,
) -> 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();

View file

@ -27,7 +27,7 @@ impl Musixmatch {
translation_status: bool,
lang_3c: bool,
) -> 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();
@ -74,7 +74,7 @@ impl Musixmatch {
translation_status: bool,
lang_3c: bool,
) -> 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();
@ -114,7 +114,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -155,7 +155,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -184,7 +184,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
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?;
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`: Define the page number for paginated results, starting from 1.
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();

View file

@ -1,3 +1,5 @@
use std::borrow::Cow;
pub(crate) type Result<T> = core::result::Result<T, Error>;
/// Custom error type for the Musixmatch client
@ -36,6 +38,9 @@ pub enum Error {
/// Error from the HTTP client
#[error("http error: {0}")]
Http(reqwest::Error),
/// Unspecified error
#[error("{0}")]
Other(Cow<'static, str>),
}
impl From<reqwest::Error> for Error {
@ -44,3 +49,9 @@ impl From<reqwest::Error> for Error {
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] =
format_description!("[year][month][day]");
const APP_ID: &str = "android-player-v1.0";
const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/";
const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('";
/// Hardcoded client configuration
struct ClientCfg {
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_DEVICE: &str = "Pixel 6";
@ -57,6 +76,7 @@ pub struct Musixmatch {
/// Used to construct a new [`Musixmatch`] client.#
#[derive(Default)]
pub struct MusixmatchBuilder {
client_type: ClientType,
user_agent: Option<String>,
brand: Option<String>,
device: Option<String>,
@ -64,6 +84,29 @@ pub struct MusixmatchBuilder {
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)]
enum DefaultOpt<T> {
Some(T),
@ -86,6 +129,8 @@ struct MusixmatchRef {
http: Client,
storage: Option<Box<dyn SessionStorage>>,
credentials: RwLock<Option<Credentials>>,
client_type: ClientType,
client_cfg: ClientCfg,
brand: String,
device: String,
usertoken: Mutex<Option<String>>,
@ -99,6 +144,7 @@ struct Credentials {
#[derive(Debug, Serialize, Deserialize)]
struct StoredSession {
client_type: ClientType,
usertoken: String,
}
@ -167,6 +213,12 @@ impl MusixmatchBuilder {
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
pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self {
self.brand = Some(device_brand.into());
@ -187,13 +239,18 @@ impl MusixmatchBuilder {
/// Returns a new, configured Musixmatch client using a Reqwest client builder
pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> {
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();
headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap());
let http = client_builder
.user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned()))
.user_agent(
self.user_agent
.unwrap_or_else(|| client_cfg.user_agent.to_owned()),
)
.gzip(true)
.default_headers(headers)
.build()?;
@ -203,6 +260,8 @@ impl MusixmatchBuilder {
http,
storage,
credentials: RwLock::new(self.credentials),
client_type: self.client_type,
client_cfg,
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
@ -236,27 +295,40 @@ impl Musixmatch {
}
}
let credentials = {
let credentials = if self.inner.client_cfg.login {
let c = self.inner.credentials.read().unwrap();
match c.deref() {
Some(c) => c.clone(),
Some(c) => Some(c.clone()),
None => return Err(Error::MissingCredentials),
}
} else {
None
};
let now = OffsetDateTime::now_utc();
let guid = random_guid();
let adv_id = random_uuid();
// Get user token
// The get_token endpoint seems to be rate limited for 2 requests per minute
let mut url = Url::parse_with_params(
&format!("{}{}", API_URL, "token.get"),
let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get");
let mut url = match self.inner.client_type {
ClientType::Desktop => Url::parse_with_params(
&base_url,
&[
("format", "json"),
("user_language", "en"),
("app_id", self.inner.client_cfg.app_id),
],
),
ClientType::Android => {
let guid = random_guid();
let adv_id = random_uuid();
Url::parse_with_params(
&base_url,
&[
("adv_id", adv_id.as_str()),
("root", "0"),
("sideloaded", "0"),
("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()),
@ -267,18 +339,22 @@ impl Musixmatch {
now.format(&Rfc3339).unwrap_or_default().as_str(),
),
("format", "json"),
("user_language", "en"),
],
)
.unwrap();
sign_url_with_date(&mut url, now);
}
}?;
self.sign_url_with_date(&mut url, now);
let resp = self.inner.http.get(url).send().await?.error_for_status()?;
let tdata = resp.json::<Resp<api_model::GetToken>>().await?;
let usertoken = tdata.body_or_err()?.user_token;
info!("Received new usertoken: {}****", &usertoken[0..8]);
if let Some(credentials) = credentials {
let account = self.post_credentials(&usertoken, &credentials).await?;
info!("Logged in as {} ", account.name);
}
*stored_usertoken = Some(usertoken.to_owned());
self.store_session(&usertoken);
@ -290,8 +366,8 @@ impl Musixmatch {
usertoken: &str,
credentials: &Credentials,
) -> Result<api_model::Account> {
let mut url = new_url_from_token("credential.post", usertoken);
sign_url_with_date(&mut url, OffsetDateTime::now_utc());
let mut url = self.new_url_from_token("credential.post", usertoken)?;
self.sign_url_with_date(&mut url, OffsetDateTime::now_utc());
let api_credentials = api_model::Credentials {
credential_list: &[api_model::CredentialWrap {
@ -334,6 +410,7 @@ impl Musixmatch {
fn store_session(&self, usertoken: &str) {
if let Some(storage) = &self.inner.storage {
let to_store = StoredSession {
client_type: self.inner.client_type,
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(
&format!("{}{}", API_URL, endpoint),
&[("app_id", APP_ID), ("format", "json")],
&format!("{}{}", self.inner.client_cfg.api_url, endpoint),
&[("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<()> {
@ -382,7 +459,7 @@ impl Musixmatch {
.append_pair("usertoken", &usertoken)
.finish();
sign_url_with_date(url, OffsetDateTime::now_utc());
self.sign_url_with_date(url, OffsetDateTime::now_utc());
Ok(())
}
@ -441,6 +518,33 @@ impl Musixmatch {
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 {
@ -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)]
mod tests {
use time::macros::datetime;
@ -496,8 +573,12 @@ mod tests {
#[test]
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();
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")
}
}

View file

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

View file

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

View file

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