Compare commits

...
Sign in to create a new pull request.

7 commits

Author SHA1 Message Date
1d5c849b5f ci: speed up testing 2026-06-01 01:29:37 +02:00
dbbbfcd85b fix: create cache file 2026-06-01 01:24:43 +02:00
5942f99b28 ci: use default testing 2026-06-01 01:19:34 +02:00
1d23a3bec6 feat: solve captcha when getting usertoken 2026-06-01 01:14:43 +02:00
930b0c21e6 feat: solve captcha 2026-05-31 17:46:58 +02:00
c75b6358ec test: fix delay 2026-05-30 21:51:50 +02:00
a79d0abce2 ci: add musixmatch credentials 2026-05-30 21:14:27 +02:00
7 changed files with 411 additions and 77 deletions

View file

@ -27,25 +27,30 @@ jobs:
run: cargo clippy --all -- -D warnings
- name: 🧪 Test
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 -j 1 --workspace
# run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 -j 1 --workspace
run: cargo test --workspace -- --nocapture --test-threads 1
env:
RUST_LOG: debug
ALL_PROXY: "http://warpproxy:8124"
MUSIXMATCH_EMAIL: ${{ secrets.MUSIXMATCH_EMAIL }}
MUSIXMATCH_PASSWORD: ${{ secrets.MUSIXMATCH_PASSWORD }}
CAPMONSTER_KEY: ${{ secrets.CAPMONSTER_KEY }}
- name: Move test report
if: always()
run: mv target/nextest/ci/junit.xml junit.xml || true
# - name: Move test report
# if: always()
# run: mv target/nextest/ci/junit.xml junit.xml || true
- name: 💌 Upload test report
if: always()
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: test
path: |
junit.xml
# - name: 💌 Upload test report
# if: always()
# uses: https://code.forgejo.org/forgejo/upload-artifact@v4
# with:
# name: test
# path: |
# junit.xml
- name: 🔗 Artifactview PR comment
if: ${{ always() && github.event_name == 'pull_request' }}
run: |
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}'
# - name: 🔗 Artifactview PR comment
# if: ${{ always() && github.event_name == 'pull_request' }}
# run: |
# if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
# curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
# --data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}'

View file

@ -64,5 +64,4 @@ dotenvy = "0.15.5"
tokio = { version = "1.20.4", features = ["macros"] }
futures = "0.3.21"
path_macro = "1.0.0"
governor = "0.10.0"
test-log = "0.2.16"

213
src/captcha.rs Normal file
View file

@ -0,0 +1,213 @@
use std::time::Duration;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::api_model::{BodyMsg, Resp};
use crate::error::Result;
use crate::{Captcha, Error, Musixmatch};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateTaskRequest<'a> {
client_key: &'a str,
task: CaptchaTask<'a>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateTaskResponse {
error_id: u8,
task_id: u32,
error_code: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CaptchaTask<'a> {
#[serde(rename = "type")]
ttype: &'a str,
website_url: &'a str,
website_key: &'a str,
min_score: f32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct TaskResultRequest<'a> {
client_key: &'a str,
task_id: u32,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
enum TaskStatus {
Processing,
Ready,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskResultResponse {
error_id: u8,
status: TaskStatus,
error_code: Option<String>,
solution: Option<CaptchaSolution>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CaptchaSolution {
g_recaptcha_response: String,
}
#[derive(Serialize)]
struct PostCaptchaRequest<'a> {
response: &'a str,
}
#[derive(Debug, Deserialize)]
struct PostCaptchaResponse {
success: bool,
#[serde(default, rename = "error-codes")]
error_codes: Vec<String>,
captcha_id: Option<String>,
}
impl Musixmatch {
async fn solve_captcha(&self) -> Result<String> {
let ck = self
.inner
.capmonster_key
.as_deref()
.ok_or(Error::Capmonster("no API key set".into()))?;
let req = CreateTaskRequest {
client_key: ck,
task: CaptchaTask {
ttype: "RecaptchaV2Task",
website_url: "https://apic.musixmatch.com/captcha.html?callback_url=mxm://captcha",
website_key: "6LcJjQoTAAAAAKd6014UF3baDSR0rkTthD1Dev1j",
min_score: 0.8,
},
};
let resp = self
.inner
.http
.post("https://api.capmonster.cloud/createTask")
.json(&req)
.send()
.await?;
let created_task = resp.json::<CreateTaskResponse>().await?;
if created_task.error_id != 0 {
return Err(Error::Capmonster(
created_task.error_code.unwrap_or_default().into(),
));
}
let task_id = created_task.task_id;
for i in 1..=60 {
tokio::time::sleep(Duration::from_secs(2)).await;
let get_req = TaskResultRequest {
client_key: ck,
task_id,
};
let resp = self
.inner
.http
.post("https://api.capmonster.cloud/getTaskResult")
.json(&get_req)
.send()
.await?;
let task_res = resp.json::<TaskResultResponse>().await?;
if task_res.error_id == 0 {
if task_res.status == TaskStatus::Ready {
if let Some(solution) = task_res.solution {
log::debug!("got captcha solution; task {task_id}");
return Ok(solution.g_recaptcha_response);
}
} else {
log::debug!("waiting for captcha solution; task {task_id}; #{i}");
}
} else {
return Err(Error::Capmonster(
task_res.error_code.unwrap_or_default().into(),
));
}
}
Err(Error::Capmonster("timeout".into()))
}
async fn post_captcha_solution(&self, solution: &str) -> Result<String> {
let req = PostCaptchaRequest { response: solution };
let mut url = self.new_url("captcha.post");
if let Ok(usertoken) = self.inner.usertoken.try_read() {
if let Some(usertoken) = usertoken.as_ref() {
url.query_pairs_mut()
.append_pair("usertoken", usertoken)
.finish();
}
}
Self::sign_url(&mut url);
let resp = self.inner.http.post(url).json(&req).send().await?;
let data = resp
.json::<Resp<BodyMsg<PostCaptchaResponse>>>()
.await?
.message
.body;
if data.success {
if let Some(c) = data.captcha_id {
Ok(c)
} else {
Err(Error::InvalidCaptcha("no captcha_id".into()))
}
} else {
Err(Error::InvalidCaptcha(data.error_codes.join(";").into()))
}
}
async fn _obtain_captcha_id_attempt(&self) -> Result<String> {
let solution = self.solve_captcha().await?;
self.post_captcha_solution(&solution).await
}
pub(crate) async fn obtain_captcha_id(&self) -> Result<()> {
// Lock the captcha ID here to prevent concurrent tasks from obtaining captchas
let mut captcha = self.inner.captcha.write().await;
if captcha
.as_ref()
.is_some_and(|c| c.solved_at > OffsetDateTime::now_utc() - time::Duration::minutes(15))
{
log::warn!("captcha already solved; still got ratelimit error");
return Err(Error::Ratelimit);
}
for att in 1..=3 {
log::info!("Getting recaptcha token; attempt {att}");
match self._obtain_captcha_id_attempt().await {
Ok(captcha_id) => {
*captcha = Some(Captcha {
captcha_id,
solved_at: OffsetDateTime::now_utc(),
});
return Ok(());
}
Err(Error::InvalidCaptcha(e)) => {
if e != "incorrect-captcha-sol" {
return Err(Error::InvalidCaptcha(e));
}
}
Err(e) => return Err(e),
}
}
Err(Error::InvalidCaptcha(
"incorrect-captcha-sol; retried 3 times".into(),
))
}
}

View file

@ -35,6 +35,12 @@ pub enum Error {
/// Musixmatch returned no data or the data that could not be deserialized
#[error("JSON parsing error: {0}")]
InvalidData(Cow<'static, str>),
/// Error solving captcha
#[error("Capmonster error: {0}]")]
Capmonster(Cow<'static, str>),
/// Error submitting captcha solution
#[error("Invalid captcha solution: {0}")]
InvalidCaptcha(Cow<'static, str>),
/// Error from the HTTP client
#[error("http error: {0}")]
Http(reqwest::Error),

View file

@ -3,6 +3,7 @@
mod api_model;
mod apis;
mod captcha;
mod error;
pub mod models;
pub mod storage;
@ -26,7 +27,7 @@ use storage::{FileStorage, SessionStorage};
use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use tokio::sync::RwLock as AsyncRwLock;
use crate::api_model::parse_body;
use crate::error::Result;
@ -61,6 +62,7 @@ pub struct MusixmatchBuilder {
device: Option<String>,
storage: DefaultOpt<Box<dyn SessionStorage>>,
credentials: Option<Credentials>,
capmonster_key: Option<String>,
}
#[derive(Default)]
@ -87,7 +89,9 @@ struct MusixmatchRef {
credentials: RwLock<Option<Credentials>>,
brand: String,
device: String,
usertoken: Mutex<Option<String>>,
usertoken: AsyncRwLock<Option<String>>,
capmonster_key: Option<String>,
captcha: AsyncRwLock<Option<Captcha>>,
}
#[derive(Default, Clone)]
@ -96,9 +100,17 @@ struct Credentials {
password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Captcha {
captcha_id: String,
solved_at: OffsetDateTime,
}
#[derive(Debug, Serialize, Deserialize)]
struct StoredSession {
usertoken: String,
#[serde(skip_serializing_if = "Option::is_none")]
captcha: Option<Captcha>,
}
impl MusixmatchBuilder {
@ -178,6 +190,14 @@ impl MusixmatchBuilder {
self
}
/// Set the CapMonster API key for solving captchas
///
/// <https://capmonster.cloud>
pub fn capmonster_key<S: Into<String>>(mut self, capmonster_key: S) -> Self {
self.capmonster_key = Some(capmonster_key.into());
self
}
/// Returns a new, configured Musixmatch client
pub fn build(self) -> Result<Musixmatch> {
self.build_with_client(ClientBuilder::new())
@ -197,6 +217,10 @@ impl MusixmatchBuilder {
.default_headers(headers)
.build()?;
let (usertoken, captcha) = stored_session
.map(|s| (Some(s.usertoken), s.captcha))
.unwrap_or_default();
Ok(Musixmatch {
inner: MusixmatchRef {
http,
@ -204,7 +228,9 @@ impl MusixmatchBuilder {
credentials: RwLock::new(self.credentials),
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
usertoken: AsyncRwLock::new(usertoken),
capmonster_key: self.capmonster_key,
captcha: AsyncRwLock::new(captcha),
}
.into(),
})
@ -227,7 +253,7 @@ impl Musixmatch {
async fn get_usertoken(&self, force_new: bool) -> Result<String> {
// Lock the session here to prevent concurrent tasks from obtaining sessions
let mut stored_usertoken = self.inner.usertoken.lock().await;
let mut stored_usertoken = self.inner.usertoken.write().await;
if !force_new {
if let Some(usertoken) = &mut *stored_usertoken {
@ -279,17 +305,36 @@ impl Musixmatch {
}
*stored_usertoken = Some(usertoken.to_owned());
self.store_session(&usertoken);
drop(stored_usertoken);
self.store_session().await;
Ok(usertoken)
}
async fn _post_credentials_request_attempt(
&self,
url: &Url,
credentials: &api_model::Credentials<'_>,
) -> Result<api_model::Login> {
let resp = self
.inner
.http
.post(url.clone())
.json(credentials)
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
parse_body::<api_model::Login>(&resp_txt)
}
async fn post_credentials(
&self,
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());
Self::sign_url(&mut url);
let api_credentials = api_model::Credentials {
credential_list: &[api_model::CredentialWrap {
@ -302,17 +347,19 @@ impl Musixmatch {
}],
};
let resp = self
.inner
.http
.post(url)
.json(&api_credentials)
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
let login = parse_body::<api_model::Login>(&resp_txt)?;
let login = match self
._post_credentials_request_attempt(&url, &api_credentials)
.await
{
Ok(login) => login,
Err(Error::Ratelimit) => {
info!("ratelimit, attempting to solve captcha");
self.obtain_captcha_id().await?;
self._post_credentials_request_attempt(&url, &api_credentials)
.await?
}
Err(e) => return Err(e),
};
let credential = login
.0
.into_iter()
@ -335,15 +382,18 @@ impl Musixmatch {
}
}
fn store_session(&self, usertoken: &str) {
async fn store_session(&self) {
if let Some(storage) = &self.inner.storage {
let to_store = StoredSession {
usertoken: usertoken.to_owned(),
};
let usertoken = { self.inner.usertoken.read().await.to_owned() };
if let Some(usertoken) = usertoken {
let captcha = { self.inner.captcha.read().await.to_owned() };
match serde_json::to_string(&to_store) {
Ok(json) => storage.write(&json),
Err(e) => error!("Could not serialize session. Error: {e}"),
let to_store = StoredSession { usertoken, captcha };
match serde_json::to_string(&to_store) {
Ok(json) => storage.write(&json),
Err(e) => error!("Could not serialize session. Error: {e}"),
}
}
}
}
@ -380,22 +430,35 @@ impl Musixmatch {
.unwrap()
}
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
let usertoken = self.get_usertoken(force_new_session).await?;
url.query_pairs_mut()
.append_pair("usertoken", &usertoken)
.finish();
fn sign_url(url: &mut Url) {
sign_url_with_date(url, OffsetDateTime::now_utc());
}
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
{
let mut query = url.query_pairs_mut();
let usertoken = self.get_usertoken(force_new_session).await?;
query.append_pair("usertoken", &usertoken);
let captcha = self.inner.captcha.read().await;
if let Some(captcha) = captcha.as_ref() {
if captcha.solved_at > OffsetDateTime::now_utc() - time::Duration::days(1) {
query.append_pair("captcha_id", &captcha.captcha_id);
}
}
query.finish();
}
Self::sign_url(url);
Ok(())
}
async fn execute_get_request<T>(&self, url: &Url) -> Result<T>
async fn _get_request_attempt<T>(&self, url: &Url, force_new_session: bool) -> Result<T>
where
T: DeserializeOwned,
{
let mut req_url = url.clone();
self.finish_url(&mut req_url, false).await?;
self.finish_url(&mut req_url, force_new_session).await?;
let resp = self
.inner
@ -406,27 +469,25 @@ impl Musixmatch {
.error_for_status()?;
let resp_txt = resp.text().await?;
match parse_body(&resp_txt) {
Ok(body) => Ok(body),
parse_body(&resp_txt)
}
async fn execute_get_request<T>(&self, url: &Url) -> Result<T>
where
T: DeserializeOwned,
{
match self._get_request_attempt(url, false).await {
Ok(resp) => return Ok(resp),
Err(Error::TokenExpired) => {
info!("Usertoken expired, getting a new one");
let mut req_url = url.clone();
self.finish_url(&mut req_url, true).await?;
let resp = self
.inner
.http
.get(req_url)
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
parse_body(&resp_txt)
}
Err(e) => Err(e),
Err(Error::Ratelimit) => {
info!("ratelimit, attempting to solve captcha");
self.obtain_captcha_id().await?;
}
Err(e) => return Err(e),
}
self._get_request_attempt(url, true).await
}
/// Ensure the client has a login token

View file

@ -55,7 +55,11 @@ fn read_file(path: &Path) -> Result<String, std::io::Error> {
}
fn write_file(path: &Path, data: &str) -> Result<(), std::io::Error> {
let mut f = File::options().read(true).append(true).open(path)?;
let mut f = File::options()
.create(true)
.read(true)
.append(true)
.open(path)?;
f.lock()?;
f.set_len(0)?;
f.write_all(data.as_bytes())?;

View file

@ -1,10 +1,8 @@
use std::{
path::{Path, PathBuf},
sync::LazyLock,
time::Duration,
};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use path_macro::path;
use rstest::{fixture, rstest};
use time::macros::{date, datetime};
@ -20,16 +18,19 @@ fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
#[fixture]
async fn mxm() -> Musixmatch {
static SETUP_LOCK: std::sync::OnceLock<()> = std::sync::OnceLock::new();
static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
static MXM_LIMITER: LazyLock<DefaultDirectRateLimiter> = LazyLock::new(|| {
RateLimiter::direct(if std::env::var("CI").is_ok() {
Quota::with_period(Duration::from_millis(2000)).unwrap()
} else {
Quota::with_period(Duration::from_millis(500)).unwrap()
})
SETUP_LOCK.get_or_init(|| {
_ = dotenvy::dotenv();
});
MXM_LIMITER.until_ready().await;
// let d = if std::env::var("CI").is_ok() {
// 2000
// } else {
// 500
// };
// tokio::time::sleep(Duration::from_millis(d)).await;
let mut mxm = Musixmatch::builder();
@ -40,8 +41,11 @@ async fn mxm() -> Musixmatch {
mxm = mxm.credentials(email, password);
}
let mxm = mxm.build().unwrap();
if let Ok(capmonster_key) = std::env::var("CAPMONSTER_KEY") {
mxm = mxm.capmonster_key(capmonster_key);
}
let mxm = mxm.build().unwrap();
LOGIN_LOCK.get_or_try_init(|| mxm.login()).await.unwrap();
mxm
}
@ -54,6 +58,7 @@ mod album {
#[case::id(AlbumId::AlbumId(14248253))]
// #[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))]
#[tokio::test]
#[test_log::test]
async fn by_id(#[case] album_id: AlbumId<'_>, #[future] mxm: Musixmatch) {
let album = mxm.await.album(album_id).await.unwrap();
// dbg!(&album);
@ -105,6 +110,7 @@ mod album {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn album_ep(#[future] mxm: Musixmatch) {
let album = mxm.await.album(AlbumId::AlbumId(23976123)).await.unwrap();
assert_eq!(album.album_name, "Waldbrand EP");
@ -114,6 +120,7 @@ mod album {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn by_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -126,6 +133,7 @@ mod album {
#[rstest]
#[tokio::test]
#[test_log::test]
#[ignore]
async fn artist_albums(#[future] mxm: Musixmatch) {
let albums = mxm
@ -139,6 +147,7 @@ mod album {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn artist_albums_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -151,6 +160,7 @@ mod album {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn charts(#[future] mxm: Musixmatch) {
let albums = mxm.await.chart_albums("US", 10, 1).await.unwrap();
@ -165,6 +175,7 @@ mod artist {
#[case::id(ArtistId::ArtistId(410698))]
// #[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))]
#[tokio::test]
#[test_log::test]
async fn by_id(#[case] artist_id: ArtistId<'_>, #[future] mxm: Musixmatch) {
let artist = mxm.await.artist(artist_id).await.unwrap();
@ -229,6 +240,7 @@ mod artist {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn by_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -241,6 +253,7 @@ mod artist {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn search(#[future] mxm: Musixmatch) {
let artists = mxm
.await
@ -258,6 +271,7 @@ mod artist {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn search_empty(#[future] mxm: Musixmatch) {
let artists = mxm
.await
@ -274,6 +288,7 @@ mod artist {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn charts(#[future] mxm: Musixmatch) {
let artists = mxm.await.chart_artists("US", 10, 1).await.unwrap();
@ -282,6 +297,7 @@ mod artist {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn charts_no_country(#[future] mxm: Musixmatch) {
let artists = mxm.await.chart_artists("XY", 10, 1).await.unwrap();
@ -300,6 +316,7 @@ mod track {
#[case::translation_2c(true, false)]
#[case::translation_3c(true, true)]
#[tokio::test]
#[test_log::test]
async fn from_match(
#[case] translation_status: bool,
#[case] lang_3c: bool,
@ -400,6 +417,7 @@ mod track {
#[case::isrc(TrackId::Isrc("QZDA41918667".into()))]
#[case::spotify(TrackId::Spotify("2roGy5AYlaJpmL9CuXj6tT".into()))]
#[tokio::test]
#[test_log::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let track = mxm.await.track(track_id, true, false, false).await.unwrap();
@ -479,6 +497,7 @@ mod track {
#[case::translation_2c(true, false)]
#[case::translation_3c(true, true)]
#[tokio::test]
#[test_log::test]
async fn from_id_translations(
#[case] translation_status: bool,
#[case] lang_3c: bool,
@ -584,6 +603,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn from_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -596,6 +616,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn album_tracks(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
@ -637,6 +658,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn album_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -653,6 +675,7 @@ mod track {
#[case::weekly(ChartName::MxmWeekly)]
#[case::weekly_new(ChartName::MxmWeeklyNew)]
#[tokio::test]
#[test_log::test]
async fn charts(#[case] chart_name: ChartName, #[future] mxm: Musixmatch) {
let tracks = mxm
.await
@ -665,6 +688,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn search(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
@ -688,6 +712,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn search_lyrics(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
@ -707,6 +732,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn search_empty(#[future] mxm: Musixmatch) {
let artists = mxm
.await
@ -721,6 +747,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn genres(#[future] mxm: Musixmatch) {
let genres = mxm.await.genres().await.unwrap();
assert!(genres.len() > 360);
@ -729,6 +756,7 @@ mod track {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn snippet(#[future] mxm: Musixmatch) {
let snippet = mxm
.await
@ -755,6 +783,7 @@ mod lyrics {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn from_match(#[future] mxm: Musixmatch) {
let lyrics = mxm.await.matcher_lyrics("Shine", "Spektrem").await.unwrap();
@ -784,6 +813,7 @@ mod lyrics {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
#[test_log::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let lyrics = mxm.await.track_lyrics(track_id).await.unwrap();
@ -803,6 +833,7 @@ mod lyrics {
/// This track has no lyrics
#[rstest]
#[tokio::test]
#[test_log::test]
async fn instrumental(#[future] mxm: Musixmatch) {
let lyrics = mxm
.await
@ -823,6 +854,7 @@ mod lyrics {
/// This track does not exist
#[rstest]
#[tokio::test]
#[test_log::test]
async fn missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -835,6 +867,7 @@ mod lyrics {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn download_testdata(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
let json_path = testfile("lyrics.json");
@ -853,6 +886,7 @@ mod lyrics {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn download_testdata_translation(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
let json_path = testfile("translation.json");
@ -871,6 +905,7 @@ mod lyrics {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn concurrency(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
@ -909,6 +944,7 @@ mod subtitles {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn from_match(#[future] mxm: Musixmatch) {
let subtitle = mxm
.await
@ -940,6 +976,7 @@ mod subtitles {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
#[test_log::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let subtitle = mxm
.await
@ -964,6 +1001,7 @@ mod subtitles {
/// This track has no lyrics
#[rstest]
#[tokio::test]
#[test_log::test]
async fn instrumental(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -983,6 +1021,7 @@ mod subtitles {
/// This track has not been synced
#[rstest]
#[tokio::test]
#[test_log::test]
async fn unsynced(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -1001,6 +1040,7 @@ mod subtitles {
/// Try to get subtitles with wrong length parameter
#[rstest]
#[tokio::test]
#[test_log::test]
async fn wrong_length(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -1018,6 +1058,7 @@ mod subtitles {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn download_testdata(#[future] mxm: Musixmatch) {
let json_path = testfile("subtitles.json");
if json_path.exists() {
@ -1088,6 +1129,7 @@ mod richsync {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
#[test_log::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let richsync = mxm
.await
@ -1116,6 +1158,7 @@ mod richsync {
/// This track has no lyrics
#[rstest]
#[tokio::test]
#[test_log::test]
async fn instrumental(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -1129,6 +1172,7 @@ mod richsync {
/// This track has not been synced
#[rstest]
#[tokio::test]
#[test_log::test]
async fn unsynced(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -1146,6 +1190,7 @@ mod richsync {
/// Try to get subtitles with wrong length parameter
#[rstest]
#[tokio::test]
#[test_log::test]
async fn wrong_length(#[future] mxm: Musixmatch) {
let err = mxm
.await
@ -1158,6 +1203,7 @@ mod richsync {
#[rstest]
#[tokio::test]
#[test_log::test]
async fn download_testdata(#[future] mxm: Musixmatch) {
let json_path = testfile("richsync.json");
if json_path.exists() {