Compare commits

..

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
24 changed files with 442 additions and 167 deletions

View file

@ -27,26 +27,30 @@ jobs:
run: cargo clippy --all -- -D warnings
- name: 🧪 Test
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --workspace --nocapture
# 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: info
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

@ -24,9 +24,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: 📤 Publish crate on crates.io
run: cargo publish --package "${{ env.CRATE }}"
env:
CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_TOKEN }}"
run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}"
- name: 🎉 Publish release
uses: https://gitea.com/actions/release-action@main

View file

@ -3,23 +3,6 @@
All notable changes to this project will be documented in this file.
## [v0.5.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.4.0..musixmatch-inofficial/v0.5.0) - 2026-06-11
### 🚀 Features
- Lock cache files before reading/writing - ([e188175](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e188175f2ea849d4663235aa3f510759e2486ec4))
### 🐛 Bug Fixes
- Creation of session file - ([a5810b9](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a5810b91c1094b7fac3ac1abdf65dac2e696f47e))
- Fix clippy lints - ([be82476](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/be8247666c27b6b6e841fd0e08355cd497f5d42a))
### ⚙️ Miscellaneous Tasks
- Update dependencies - ([7383f15](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/7383f156626f7acc71e10ceb88e6a21ec43962a0))
- Update edition to 2024 - ([266b282](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/266b282e04f5813564ad07b87bd965be73fc3f7f))
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.3.0..musixmatch-inofficial/v0.4.0) - 2025-12-19
### 🚀 Features

View file

@ -1,6 +1,6 @@
[package]
name = "musixmatch-inofficial"
version = "0.5.0"
version = "0.4.0"
rust-version = "1.89.0"
edition.workspace = true
authors.workspace = true
@ -15,7 +15,7 @@ include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"]
members = [".", "cli"]
[workspace.package]
edition = "2024"
edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"]
license = "MIT"
repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial"
@ -23,7 +23,7 @@ keywords = ["music", "lyrics"]
categories = ["api-bindings", "multimedia"]
[workspace.dependencies]
musixmatch-inofficial = { version = "0.5.0", path = ".", default-features = false }
musixmatch-inofficial = { version = "0.4.0", path = ".", default-features = false }
[features]
default = ["default-tls"]

View file

@ -3,15 +3,6 @@
All notable changes to this project will be documented in this file.
## [v0.5.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.4.0..musixmatch-cli/v0.5.0) - 2026-06-12
### ⚙️ Miscellaneous Tasks
- Update musixmatch to v0.5.0
- Update reqwest to 0.13.0 (new TLS feature flags)
- Update edition to 2024 - ([266b282](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/266b282e04f5813564ad07b87bd965be73fc3f7f))
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.3.1..musixmatch-cli/v0.4.0) - 2025-12-19
### 🚀 Features

View file

@ -1,6 +1,6 @@
[package]
name = "musixmatch-cli"
version = "0.5.0"
version = "0.4.0"
rust-version = "1.89.0"
edition.workspace = true
authors.workspace = true

View file

@ -2,15 +2,15 @@
#![warn(missing_docs, clippy::todo)]
use std::{
io::{Write, stdin, stdout},
io::{stdin, stdout, Write},
path::PathBuf,
};
use anyhow::{Result, anyhow, bail};
use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand};
use musixmatch_inofficial::{
Musixmatch,
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
Musixmatch,
};
#[derive(Parser)]
@ -233,9 +233,8 @@ async fn run(cli: Cli) -> Result<()> {
let mut lyrics_body = lyrics.lyrics_body;
if let Some(lang) = lang
&& Some(&lang) != lyrics.lyrics_language.as_ref()
{
if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
eprintln!("Translation not found. Returning lyrics in original language.");
@ -260,6 +259,7 @@ async fn run(cli: Cli) -> Result<()> {
};
}
}
}
eprintln!();
println!("{lyrics_body}");

View file

@ -1,8 +1,8 @@
use std::{marker::PhantomData, str::FromStr};
use serde::{
Deserialize, Deserializer, Serialize,
de::{DeserializeOwned, Visitor},
Deserialize, Deserializer, Serialize,
};
use time::OffsetDateTime;
@ -416,9 +416,9 @@ where
pub mod optional_date {
use super::*;
use serde::Serializer;
use serde::ser::Error as _;
use time::{Date, macros::format_description};
use serde::Serializer;
use time::{macros::format_description, Date};
const DATE_FORMAT: &[time::format_description::FormatItem] =
format_description!("[year]-[month]-[day]");

View file

@ -1,7 +1,7 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::album::{Album, AlbumBody, AlbumListBody};
use crate::models::{AlbumId, ArtistId, SortOrder};
use crate::Musixmatch;
impl Musixmatch {
/// Get the metadata for an album specified by its ID.

View file

@ -1,7 +1,7 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::ArtistId;
use crate::models::artist::{Artist, ArtistBody, ArtistListBody};
use crate::models::ArtistId;
use crate::Musixmatch;
impl Musixmatch {
/// Get the metadata for an artist specified by its ID.

View file

@ -1,8 +1,8 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::TrackId;
use crate::models::lyrics::{Lyrics, LyricsBody};
use crate::models::translation::{TranslationList, TranslationListBody};
use crate::models::TrackId;
use crate::Musixmatch;
impl Musixmatch {
/// Get the lyrics for a track specified by its name and artist.

View file

@ -1,7 +1,7 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::TrackId;
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
use crate::models::TrackId;
use crate::Musixmatch;
impl Musixmatch {
/// Get the richsync (word-by-word synchronized lyrics) for a track specified by its ID.

View file

@ -1,7 +1,7 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::TrackId;
use crate::models::snippet::{Snippet, SnippetBody};
use crate::models::TrackId;
use crate::Musixmatch;
impl Musixmatch {
/// Get a lyrics snippet for a track specified by its ID.

View file

@ -1,7 +1,7 @@
use crate::Musixmatch;
use crate::error::Result;
use crate::models::TrackId;
use crate::models::subtitle::{Subtitle, SubtitleBody, SubtitleFormat};
use crate::models::TrackId;
use crate::Musixmatch;
impl Musixmatch {
/// Get the subtitles (synchronized lyrics) for a track specified by its name and artist.

View file

@ -1,9 +1,9 @@
use time::Date;
use crate::Musixmatch;
use crate::error::Result;
use crate::models::track::{Track, TrackBody, TrackListBody};
use crate::models::{AlbumId, ChartName, Genre, Genres, SortOrder, TrackId};
use crate::Musixmatch;
impl Musixmatch {
/// Get the metadata for a track specified by its name, artist and album.
@ -403,18 +403,22 @@ impl<'a> TrackSearchQuery<'a> {
}
if let Some(f_track_release_group_first_release_date_min) =
self.f_track_release_group_first_release_date_min
&& let Ok(date) =
{
if let Ok(date) =
f_track_release_group_first_release_date_min.format(crate::YMD_FORMAT)
{
url_query.append_pair("f_track_release_group_first_release_date_min", &date);
}
}
if let Some(f_track_release_group_first_release_date_max) =
self.f_track_release_group_first_release_date_max
&& let Ok(date) =
{
if let Ok(date) =
f_track_release_group_first_release_date_max.format(crate::YMD_FORMAT)
{
url_query.append_pair("f_track_release_group_first_release_date_max", &date);
}
}
if let Some(s_artist_rating) = &self.s_artist_rating {
url_query.append_pair("s_artist_rating", s_artist_rating.as_str());
}

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;
@ -23,10 +24,10 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use storage::{FileStorage, SessionStorage};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use time::macros::format_description;
use tokio::sync::Mutex;
use time::OffsetDateTime;
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,11 +253,13 @@ 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 && let Some(usertoken) = &mut *stored_usertoken {
if !force_new {
if let Some(usertoken) = &mut *stored_usertoken {
return Ok(usertoken.clone());
}
}
let credentials = {
let c = self.inner.credentials.read().unwrap();
@ -277,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 {
@ -300,17 +347,19 @@ impl Musixmatch {
}],
};
let resp = self
.inner
.http
.post(url)
.json(&api_credentials)
.send()
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?
.error_for_status()?;
let resp_txt = resp.text().await?;
let login = parse_body::<api_model::Login>(&resp_txt)?;
}
Err(e) => return Err(e),
};
let credential = login
.0
.into_iter()
@ -333,11 +382,13 @@ 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() };
let to_store = StoredSession { usertoken, captcha };
match serde_json::to_string(&to_store) {
Ok(json) => storage.write(&json),
@ -345,6 +396,7 @@ impl Musixmatch {
}
}
}
}
fn retrieve_session(storage: &Option<Box<dyn SessionStorage>>) -> Option<StoredSession> {
storage.as_ref().and_then(|storage| {
@ -378,53 +430,64 @@ 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 _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, force_new_session).await?;
let resp = self
.inner
.http
.get(req_url)
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
parse_body(&resp_txt)
}
async fn execute_get_request<T>(&self, url: &Url) -> Result<T>
where
T: DeserializeOwned,
{
let mut req_url = url.clone();
self.finish_url(&mut req_url, false).await?;
let resp = self
.inner
.http
.get(req_url)
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
match parse_body(&resp_txt) {
Ok(body) => Ok(body),
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
@ -501,9 +564,6 @@ mod tests {
fn t_sign_url() {
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));
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=78ywxkeXlazpevI%2BbD8E3YluLPc%3D%0A&signature_protocol=sha1"
)
assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=78ywxkeXlazpevI%2BbD8E3YluLPc%3D%0A&signature_protocol=sha1")
}
}

View file

@ -1,6 +1,6 @@
use std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr};
use serde::{Deserialize, Serialize, de::Visitor};
use serde::{de::Visitor, Deserialize, Serialize};
use crate::IdError;

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::{Error, error::Result};
use crate::{error::Result, Error};
#[derive(Debug, Deserialize)]
pub(crate) struct LyricsBody {

View file

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::fmt::Write;
use time::OffsetDateTime;
use crate::{Error, error::Result};
use crate::{error::Result, Error};
#[derive(Debug, Deserialize)]
pub(crate) struct SubtitleBody {

View file

@ -3,7 +3,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use super::{SubtitleLine, subtitle::SubtitleLines};
use super::{subtitle::SubtitleLines, SubtitleLine};
#[derive(Debug, Deserialize)]
pub(crate) struct TranslationListBody {

View file

@ -55,14 +55,12 @@ fn read_file(path: &Path) -> Result<String, std::io::Error> {
}
fn write_file(path: &Path, data: &str) -> Result<(), std::io::Error> {
// Open file in append mode to not immediately truncate it
let mut f = File::options()
.create(true)
.read(true)
.append(true)
.open(path)?;
f.lock()?;
// Truncate file after making sure no one is reading/writing to it
f.set_len(0)?;
f.write_all(data.as_bytes())?;
Ok(())

View file

@ -1,12 +1,15 @@
use std::path::{Path, PathBuf};
use std::{
path::{Path, PathBuf},
time::Duration,
};
use path_macro::path;
use rstest::{fixture, rstest};
use time::macros::{date, datetime};
use musixmatch_inofficial::{
Error, Musixmatch,
models::{AlbumId, ArtistId, TrackId},
Error, Musixmatch,
};
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
@ -15,8 +18,20 @@ 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();
SETUP_LOCK.get_or_init(|| {
_ = dotenvy::dotenv();
});
// 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();
if let (Ok(email), Ok(password)) = (
@ -26,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
}
@ -733,7 +751,7 @@ mod track {
async fn genres(#[future] mxm: Musixmatch) {
let genres = mxm.await.genres().await.unwrap();
assert!(genres.len() > 360);
// dbg!(&genres);
dbg!(&genres);
}
#[rstest]
@ -900,9 +918,8 @@ mod lyrics {
)
.await
.unwrap();
assert_eq!(album.len(), 13);
let lyrics = stream::iter(album)
let x = stream::iter(album)
.map(|track| {
mxm.track_lyrics(musixmatch_inofficial::models::TrackId::TrackId(
track.track_id,
@ -914,7 +931,8 @@ mod lyrics {
.into_iter()
.map(Result::unwrap)
.collect::<Vec<_>>();
assert_eq!(lyrics.len(), 13);
dbg!(x);
}
}