Compare commits
7 commits
main
...
feat/captc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5c849b5f | |||
| dbbbfcd85b | |||
| 5942f99b28 | |||
| 1d23a3bec6 | |||
| 930b0c21e6 | |||
| c75b6358ec | |||
| a79d0abce2 |
7 changed files with 411 additions and 77 deletions
|
|
@ -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"}}'
|
||||
|
|
|
|||
|
|
@ -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
213
src/captcha.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
159
src/lib.rs
159
src/lib.rs
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())?;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue