From a79d0abce29e40a62b34e1ccf5194923eedeabf1 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 30 May 2026 21:14:27 +0200 Subject: [PATCH 1/7] ci: add musixmatch credentials --- .gitea/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5ebadac..540bab3 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -30,6 +30,8 @@ jobs: run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 -j 1 --workspace env: ALL_PROXY: "http://warpproxy:8124" + MUSIXMATCH_EMAIL: ${{ secrets.MUSIXMATCH_EMAIL }} + MUSIXMATCH_PASSWORD: ${{ secrets.MUSIXMATCH_PASSWORD }} - name: Move test report if: always() From c75b6358ecc95f3f3fb2fd1347cdecfd5e846a64 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 30 May 2026 21:51:50 +0200 Subject: [PATCH 2/7] test: fix delay --- Cargo.toml | 1 - tests/tests.rs | 17 ++++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2797db2..c2696a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/tests/tests.rs b/tests/tests.rs index 9395e93..359bcdf 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -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}; @@ -21,15 +19,12 @@ fn testfile>(name: P) -> PathBuf { #[fixture] async fn mxm() -> Musixmatch { static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); - static MXM_LIMITER: LazyLock = 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() - }) - }); - - MXM_LIMITER.until_ready().await; + let d = if std::env::var("CI").is_ok() { + 1000 + } else { + 500 + }; + tokio::time::sleep(Duration::from_millis(d)).await; let mut mxm = Musixmatch::builder(); From 930b0c21e668aefd0ac84f2865d2c668b8be4151 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sun, 31 May 2026 17:46:58 +0200 Subject: [PATCH 3/7] feat: solve captcha --- .gitea/workflows/ci.yaml | 1 + src/captcha.rs | 204 +++++++++++++++++++++++++++++++++++++++ src/error.rs | 6 ++ src/lib.rs | 106 +++++++++++++------- tests/tests.rs | 13 ++- 5 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 src/captcha.rs diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 540bab3..7c19e83 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -32,6 +32,7 @@ jobs: 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() diff --git a/src/captcha.rs b/src/captcha.rs new file mode 100644 index 0000000..115336b --- /dev/null +++ b/src/captcha.rs @@ -0,0 +1,204 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::api_model::{BodyMsg, Resp}; +use crate::error::Result; +use crate::{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, +} + +#[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, + solution: Option, +} + +#[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, + captcha_id: Option, +} + +impl Musixmatch { + async fn solve_captcha(&self) -> Result { + 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::().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::().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 { + let req = PostCaptchaRequest { response: solution }; + let mut url = self.new_url("captcha.post"); + match self.get_usertoken(false).await { + Ok(usertoken) => { + url.query_pairs_mut() + .append_pair("usertoken", &usertoken) + .finish(); + } + Err(Error::Ratelimit) => { + log::debug!("could not get usertoken to post captcha solution; ratelimit") + } + Err(e) => return Err(e), + } + Self::sign_url(&mut url); + let resp = self.inner.http.post(url).json(&req).send().await?; + let data = resp + .json::>>() + .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 { + 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_id = self.inner.captcha_id.lock().await; + + for att in 1..=3 { + log::info!("Getting recaptcha token; attempt {att}"); + + match self._obtain_captcha_id_attempt().await { + Ok(cid) => { + *captcha_id = Some(cid); + 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(), + )) + } +} diff --git a/src/error.rs b/src/error.rs index f58312b..34cb853 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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), diff --git a/src/lib.rs b/src/lib.rs index a70c163..8128cec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod api_model; mod apis; +mod captcha; mod error; pub mod models; pub mod storage; @@ -61,6 +62,7 @@ pub struct MusixmatchBuilder { device: Option, storage: DefaultOpt>, credentials: Option, + capmonster_key: Option, } #[derive(Default)] @@ -88,6 +90,8 @@ struct MusixmatchRef { brand: String, device: String, usertoken: Mutex>, + capmonster_key: Option, + captcha_id: Mutex>, } #[derive(Default, Clone)] @@ -99,6 +103,8 @@ struct Credentials { #[derive(Debug, Serialize, Deserialize)] struct StoredSession { usertoken: String, + #[serde(skip_serializing_if = "Option::is_none")] + captcha_id: Option, } impl MusixmatchBuilder { @@ -178,6 +184,14 @@ impl MusixmatchBuilder { self } + /// Set the CapMonster API key for solving captchas + /// + /// + pub fn capmonster_key>(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 { self.build_with_client(ClientBuilder::new()) @@ -197,6 +211,10 @@ impl MusixmatchBuilder { .default_headers(headers) .build()?; + let (usertoken, captcha_id) = stored_session + .map(|s| (Some(s.usertoken), s.captcha_id)) + .unwrap_or_default(); + Ok(Musixmatch { inner: MusixmatchRef { http, @@ -204,7 +222,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: Mutex::new(usertoken), + capmonster_key: self.capmonster_key, + captcha_id: Mutex::new(captcha_id), } .into(), }) @@ -279,7 +299,8 @@ impl Musixmatch { } *stored_usertoken = Some(usertoken.to_owned()); - self.store_session(&usertoken); + drop(stored_usertoken); + self.store_session().await; Ok(usertoken) } @@ -289,7 +310,7 @@ impl Musixmatch { credentials: &Credentials, ) -> Result { 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 { @@ -335,15 +356,21 @@ 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.lock().await.to_owned() }; + if let Some(usertoken) = usertoken { + let captcha_id = { self.inner.captcha_id.lock().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_id, + }; + + match serde_json::to_string(&to_store) { + Ok(json) => storage.write(&json), + Err(e) => error!("Could not serialize session. Error: {e}"), + } } } } @@ -380,22 +407,33 @@ 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_id = self.inner.captcha_id.lock().await; + if let Some(captcha_id) = captcha_id.as_deref() { + query.append_pair("captcha_id", captcha_id); + } + query.finish(); + } + + Self::sign_url(url); Ok(()) } - async fn execute_get_request(&self, url: &Url) -> Result + async fn _get_request_attempt(&self, url: &Url, force_new_session: bool) -> Result 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 +444,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(&self, url: &Url) -> Result + 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 diff --git a/tests/tests.rs b/tests/tests.rs index 359bcdf..3aeaaa2 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -18,9 +18,15 @@ fn testfile>(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(); + + SETUP_LOCK.get_or_init(|| { + _ = dotenvy::dotenv(); + }); + let d = if std::env::var("CI").is_ok() { - 1000 + 2000 } else { 500 }; @@ -35,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 } From 1d23a3bec696f6f26fa1e80a6a5e67ddd8e87e46 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 1 Jun 2026 01:14:43 +0200 Subject: [PATCH 4/7] feat: solve captcha when getting usertoken --- src/captcha.rs | 31 ++++++++++++------- src/lib.rs | 83 ++++++++++++++++++++++++++++++++------------------ tests/tests.rs | 42 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 40 deletions(-) diff --git a/src/captcha.rs b/src/captcha.rs index 115336b..6e098e4 100644 --- a/src/captcha.rs +++ b/src/captcha.rs @@ -1,10 +1,11 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use crate::api_model::{BodyMsg, Resp}; use crate::error::Result; -use crate::{Error, Musixmatch}; +use crate::{Captcha, Error, Musixmatch}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -143,16 +144,13 @@ impl Musixmatch { async fn post_captcha_solution(&self, solution: &str) -> Result { let req = PostCaptchaRequest { response: solution }; let mut url = self.new_url("captcha.post"); - match self.get_usertoken(false).await { - Ok(usertoken) => { + + if let Ok(usertoken) = self.inner.usertoken.try_read() { + if let Some(usertoken) = usertoken.as_ref() { url.query_pairs_mut() - .append_pair("usertoken", &usertoken) + .append_pair("usertoken", usertoken) .finish(); } - Err(Error::Ratelimit) => { - log::debug!("could not get usertoken to post captcha solution; ratelimit") - } - Err(e) => return Err(e), } Self::sign_url(&mut url); let resp = self.inner.http.post(url).json(&req).send().await?; @@ -179,14 +177,25 @@ impl Musixmatch { pub(crate) async fn obtain_captcha_id(&self) -> Result<()> { // Lock the captcha ID here to prevent concurrent tasks from obtaining captchas - let mut captcha_id = self.inner.captcha_id.lock().await; + 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(cid) => { - *captcha_id = Some(cid); + Ok(captcha_id) => { + *captcha = Some(Captcha { + captcha_id, + solved_at: OffsetDateTime::now_utc(), + }); return Ok(()); } Err(Error::InvalidCaptcha(e)) => { diff --git a/src/lib.rs b/src/lib.rs index 8128cec..119b1c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,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; @@ -89,9 +89,9 @@ struct MusixmatchRef { credentials: RwLock>, brand: String, device: String, - usertoken: Mutex>, + usertoken: AsyncRwLock>, capmonster_key: Option, - captcha_id: Mutex>, + captcha: AsyncRwLock>, } #[derive(Default, Clone)] @@ -100,11 +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_id: Option, + captcha: Option, } impl MusixmatchBuilder { @@ -211,8 +217,8 @@ impl MusixmatchBuilder { .default_headers(headers) .build()?; - let (usertoken, captcha_id) = stored_session - .map(|s| (Some(s.usertoken), s.captcha_id)) + let (usertoken, captcha) = stored_session + .map(|s| (Some(s.usertoken), s.captcha)) .unwrap_or_default(); Ok(Musixmatch { @@ -222,9 +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(usertoken), + usertoken: AsyncRwLock::new(usertoken), capmonster_key: self.capmonster_key, - captcha_id: Mutex::new(captcha_id), + captcha: AsyncRwLock::new(captcha), } .into(), }) @@ -247,7 +253,7 @@ impl Musixmatch { async fn get_usertoken(&self, force_new: bool) -> Result { // 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 { @@ -304,6 +310,24 @@ impl Musixmatch { Ok(usertoken) } + async fn _post_credentials_request_attempt( + &self, + url: &Url, + credentials: &api_model::Credentials<'_>, + ) -> Result { + let resp = self + .inner + .http + .post(url.clone()) + .json(credentials) + .send() + .await? + .error_for_status()?; + + let resp_txt = resp.text().await?; + parse_body::(&resp_txt) + } + async fn post_credentials( &self, usertoken: &str, @@ -323,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::(&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() @@ -358,14 +384,11 @@ impl Musixmatch { async fn store_session(&self) { if let Some(storage) = &self.inner.storage { - let usertoken = { self.inner.usertoken.lock().await.to_owned() }; + let usertoken = { self.inner.usertoken.read().await.to_owned() }; if let Some(usertoken) = usertoken { - let captcha_id = { self.inner.captcha_id.lock().await.to_owned() }; + let captcha = { self.inner.captcha.read().await.to_owned() }; - let to_store = StoredSession { - usertoken, - captcha_id, - }; + let to_store = StoredSession { usertoken, captcha }; match serde_json::to_string(&to_store) { Ok(json) => storage.write(&json), @@ -417,9 +440,11 @@ impl Musixmatch { let usertoken = self.get_usertoken(force_new_session).await?; query.append_pair("usertoken", &usertoken); - let captcha_id = self.inner.captcha_id.lock().await; - if let Some(captcha_id) = captcha_id.as_deref() { - query.append_pair("captcha_id", captcha_id); + 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(); } diff --git a/tests/tests.rs b/tests/tests.rs index 3aeaaa2..bff5b1c 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -58,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); @@ -109,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"); @@ -118,6 +120,7 @@ mod album { #[rstest] #[tokio::test] + #[test_log::test] async fn by_id_missing(#[future] mxm: Musixmatch) { let err = mxm .await @@ -130,6 +133,7 @@ mod album { #[rstest] #[tokio::test] + #[test_log::test] #[ignore] async fn artist_albums(#[future] mxm: Musixmatch) { let albums = mxm @@ -143,6 +147,7 @@ mod album { #[rstest] #[tokio::test] + #[test_log::test] async fn artist_albums_missing(#[future] mxm: Musixmatch) { let err = mxm .await @@ -155,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(); @@ -169,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(); @@ -233,6 +240,7 @@ mod artist { #[rstest] #[tokio::test] + #[test_log::test] async fn by_id_missing(#[future] mxm: Musixmatch) { let err = mxm .await @@ -245,6 +253,7 @@ mod artist { #[rstest] #[tokio::test] + #[test_log::test] async fn search(#[future] mxm: Musixmatch) { let artists = mxm .await @@ -262,6 +271,7 @@ mod artist { #[rstest] #[tokio::test] + #[test_log::test] async fn search_empty(#[future] mxm: Musixmatch) { let artists = mxm .await @@ -278,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(); @@ -286,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(); @@ -304,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, @@ -404,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(); @@ -483,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, @@ -588,6 +603,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn from_id_missing(#[future] mxm: Musixmatch) { let err = mxm .await @@ -600,6 +616,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn album_tracks(#[future] mxm: Musixmatch) { let tracks = mxm .await @@ -641,6 +658,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn album_missing(#[future] mxm: Musixmatch) { let err = mxm .await @@ -657,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 @@ -669,6 +688,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn search(#[future] mxm: Musixmatch) { let tracks = mxm .await @@ -692,6 +712,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn search_lyrics(#[future] mxm: Musixmatch) { let tracks = mxm .await @@ -711,6 +732,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn search_empty(#[future] mxm: Musixmatch) { let artists = mxm .await @@ -725,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); @@ -733,6 +756,7 @@ mod track { #[rstest] #[tokio::test] + #[test_log::test] async fn snippet(#[future] mxm: Musixmatch) { let snippet = mxm .await @@ -759,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(); @@ -788,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(); @@ -807,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 @@ -827,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 @@ -839,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"); @@ -857,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"); @@ -875,6 +905,7 @@ mod lyrics { #[rstest] #[tokio::test] + #[test_log::test] async fn concurrency(#[future] mxm: Musixmatch) { let mxm = mxm.await; @@ -913,6 +944,7 @@ mod subtitles { #[rstest] #[tokio::test] + #[test_log::test] async fn from_match(#[future] mxm: Musixmatch) { let subtitle = mxm .await @@ -944,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 @@ -968,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 @@ -987,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 @@ -1005,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 @@ -1022,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() { @@ -1092,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 @@ -1120,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 @@ -1133,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 @@ -1150,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 @@ -1162,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() { From 5942f99b28a8f5928191f121ba17fc452f336c9b Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 1 Jun 2026 01:19:34 +0200 Subject: [PATCH 5/7] ci: use default testing --- .gitea/workflows/ci.yaml | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7c19e83..f1d9970 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -27,28 +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"}}' From dbbbfcd85bacb7c18f2cfb36d7d92c484b5cd3c8 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 1 Jun 2026 01:24:43 +0200 Subject: [PATCH 6/7] fix: create cache file --- src/storage.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/storage.rs b/src/storage.rs index cc13fc8..b4c5815 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -55,7 +55,11 @@ fn read_file(path: &Path) -> Result { } 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())?; From 1d5c849b5f7b4b9a4d6faeb998b4a14b7899095c Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Mon, 1 Jun 2026 01:29:37 +0200 Subject: [PATCH 7/7] ci: speed up testing --- tests/tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/tests.rs b/tests/tests.rs index bff5b1c..b4049ff 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -25,12 +25,12 @@ async fn mxm() -> Musixmatch { _ = dotenvy::dotenv(); }); - let d = if std::env::var("CI").is_ok() { - 2000 - } else { - 500 - }; - tokio::time::sleep(Duration::from_millis(d)).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();