Compare commits
7 commits
feat/captc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2848c8a85a | |||
| cb0567c227 | |||
| 5c91c0872c | |||
| 080ca253e1 | |||
| be8247666c | |||
| 266b282e04 | |||
| a5810b91c1 |
24 changed files with 167 additions and 442 deletions
|
|
@ -27,30 +27,26 @@ 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 test --workspace -- --nocapture --test-threads 1
|
||||
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --workspace --nocapture
|
||||
env:
|
||||
RUST_LOG: debug
|
||||
RUST_LOG: info
|
||||
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"}}'
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ jobs:
|
|||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: 📤 Publish crate on crates.io
|
||||
run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}"
|
||||
run: cargo publish --package "${{ env.CRATE }}"
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_TOKEN }}"
|
||||
|
||||
- name: 🎉 Publish release
|
||||
uses: https://gitea.com/actions/release-action@main
|
||||
|
|
|
|||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -3,6 +3,23 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "musixmatch-inofficial"
|
||||
version = "0.4.0"
|
||||
version = "0.5.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 = "2021"
|
||||
edition = "2024"
|
||||
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.4.0", path = ".", default-features = false }
|
||||
musixmatch-inofficial = { version = "0.5.0", path = ".", default-features = false }
|
||||
|
||||
[features]
|
||||
default = ["default-tls"]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "musixmatch-cli"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
rust-version = "1.89.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
#![warn(missing_docs, clippy::todo)]
|
||||
|
||||
use std::{
|
||||
io::{stdin, stdout, Write},
|
||||
io::{Write, stdin, stdout},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use musixmatch_inofficial::{
|
||||
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
|
||||
Musixmatch,
|
||||
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -233,31 +233,31 @@ async fn run(cli: Cli) -> Result<()> {
|
|||
|
||||
let mut lyrics_body = lyrics.lyrics_body;
|
||||
|
||||
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.");
|
||||
if let Some(lang) = lang
|
||||
&& 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.");
|
||||
} else {
|
||||
eprintln!("Translated to: {}", tl.lang);
|
||||
let tm = TranslationMap::from(tl);
|
||||
let translated = tm.translate_lyrics(&lyrics_body);
|
||||
lyrics_body = if bi {
|
||||
lyrics_body
|
||||
.lines()
|
||||
.zip(translated.lines())
|
||||
.map(|(a, b)| {
|
||||
if a == b {
|
||||
a.to_string() + "\n"
|
||||
} else {
|
||||
format!("{a}\n> {b}\n")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
eprintln!("Translated to: {}", tl.lang);
|
||||
let tm = TranslationMap::from(tl);
|
||||
let translated = tm.translate_lyrics(&lyrics_body);
|
||||
lyrics_body = if bi {
|
||||
lyrics_body
|
||||
.lines()
|
||||
.zip(translated.lines())
|
||||
.map(|(a, b)| {
|
||||
if a == b {
|
||||
a.to_string() + "\n"
|
||||
} else {
|
||||
format!("{a}\n> {b}\n")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
translated
|
||||
};
|
||||
}
|
||||
translated
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::{marker::PhantomData, str::FromStr};
|
||||
|
||||
use serde::{
|
||||
de::{DeserializeOwned, Visitor},
|
||||
Deserialize, Deserializer, Serialize,
|
||||
de::{DeserializeOwned, Visitor},
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
|
|
@ -416,9 +416,9 @@ where
|
|||
|
||||
pub mod optional_date {
|
||||
use super::*;
|
||||
use serde::ser::Error as _;
|
||||
use serde::Serializer;
|
||||
use time::{macros::format_description, Date};
|
||||
use serde::ser::Error as _;
|
||||
use time::{Date, macros::format_description};
|
||||
|
||||
const DATE_FORMAT: &[time::format_description::FormatItem] =
|
||||
format_description!("[year]-[month]-[day]");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::Result;
|
||||
use crate::models::artist::{Artist, ArtistBody, ArtistListBody};
|
||||
use crate::models::ArtistId;
|
||||
use crate::Musixmatch;
|
||||
use crate::error::Result;
|
||||
use crate::models::ArtistId;
|
||||
use crate::models::artist::{Artist, ArtistBody, ArtistListBody};
|
||||
|
||||
impl Musixmatch {
|
||||
/// Get the metadata for an artist specified by its ID.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::Result;
|
||||
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
|
||||
use crate::models::TrackId;
|
||||
use crate::Musixmatch;
|
||||
use crate::error::Result;
|
||||
use crate::models::TrackId;
|
||||
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
|
||||
|
||||
impl Musixmatch {
|
||||
/// Get the richsync (word-by-word synchronized lyrics) for a track specified by its ID.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::Result;
|
||||
use crate::models::snippet::{Snippet, SnippetBody};
|
||||
use crate::models::TrackId;
|
||||
use crate::Musixmatch;
|
||||
use crate::error::Result;
|
||||
use crate::models::TrackId;
|
||||
use crate::models::snippet::{Snippet, SnippetBody};
|
||||
|
||||
impl Musixmatch {
|
||||
/// Get a lyrics snippet for a track specified by its ID.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::error::Result;
|
||||
use crate::models::subtitle::{Subtitle, SubtitleBody, SubtitleFormat};
|
||||
use crate::models::TrackId;
|
||||
use crate::Musixmatch;
|
||||
use crate::error::Result;
|
||||
use crate::models::TrackId;
|
||||
use crate::models::subtitle::{Subtitle, SubtitleBody, SubtitleFormat};
|
||||
|
||||
impl Musixmatch {
|
||||
/// Get the subtitles (synchronized lyrics) for a track specified by its name and artist.
|
||||
|
|
|
|||
|
|
@ -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,21 +403,17 @@ impl<'a> TrackSearchQuery<'a> {
|
|||
}
|
||||
if let Some(f_track_release_group_first_release_date_min) =
|
||||
self.f_track_release_group_first_release_date_min
|
||||
{
|
||||
if let Ok(date) =
|
||||
&& 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);
|
||||
}
|
||||
{
|
||||
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
|
||||
{
|
||||
if let Ok(date) =
|
||||
&& 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);
|
||||
}
|
||||
{
|
||||
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
213
src/captcha.rs
|
|
@ -1,213 +0,0 @@
|
|||
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,12 +35,6 @@ 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),
|
||||
|
|
|
|||
170
src/lib.rs
170
src/lib.rs
|
|
@ -3,7 +3,6 @@
|
|||
|
||||
mod api_model;
|
||||
mod apis;
|
||||
mod captcha;
|
||||
mod error;
|
||||
pub mod models;
|
||||
pub mod storage;
|
||||
|
|
@ -24,10 +23,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 time::OffsetDateTime;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::api_model::parse_body;
|
||||
use crate::error::Result;
|
||||
|
|
@ -62,7 +61,6 @@ pub struct MusixmatchBuilder {
|
|||
device: Option<String>,
|
||||
storage: DefaultOpt<Box<dyn SessionStorage>>,
|
||||
credentials: Option<Credentials>,
|
||||
capmonster_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -89,9 +87,7 @@ struct MusixmatchRef {
|
|||
credentials: RwLock<Option<Credentials>>,
|
||||
brand: String,
|
||||
device: String,
|
||||
usertoken: AsyncRwLock<Option<String>>,
|
||||
capmonster_key: Option<String>,
|
||||
captcha: AsyncRwLock<Option<Captcha>>,
|
||||
usertoken: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
|
|
@ -100,17 +96,9 @@ 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 {
|
||||
|
|
@ -190,14 +178,6 @@ 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())
|
||||
|
|
@ -217,10 +197,6 @@ 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,
|
||||
|
|
@ -228,9 +204,7 @@ 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: AsyncRwLock::new(usertoken),
|
||||
capmonster_key: self.capmonster_key,
|
||||
captcha: AsyncRwLock::new(captcha),
|
||||
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
|
||||
}
|
||||
.into(),
|
||||
})
|
||||
|
|
@ -253,12 +227,10 @@ 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.write().await;
|
||||
let mut stored_usertoken = self.inner.usertoken.lock().await;
|
||||
|
||||
if !force_new {
|
||||
if let Some(usertoken) = &mut *stored_usertoken {
|
||||
return Ok(usertoken.clone());
|
||||
}
|
||||
if !force_new && let Some(usertoken) = &mut *stored_usertoken {
|
||||
return Ok(usertoken.clone());
|
||||
}
|
||||
|
||||
let credentials = {
|
||||
|
|
@ -305,36 +277,17 @@ impl Musixmatch {
|
|||
}
|
||||
|
||||
*stored_usertoken = Some(usertoken.to_owned());
|
||||
drop(stored_usertoken);
|
||||
self.store_session().await;
|
||||
self.store_session(&usertoken);
|
||||
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);
|
||||
Self::sign_url(&mut url);
|
||||
sign_url_with_date(&mut url, OffsetDateTime::now_utc());
|
||||
|
||||
let api_credentials = api_model::Credentials {
|
||||
credential_list: &[api_model::CredentialWrap {
|
||||
|
|
@ -347,19 +300,17 @@ impl Musixmatch {
|
|||
}],
|
||||
};
|
||||
|
||||
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 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 credential = login
|
||||
.0
|
||||
.into_iter()
|
||||
|
|
@ -382,18 +333,15 @@ impl Musixmatch {
|
|||
}
|
||||
}
|
||||
|
||||
async fn store_session(&self) {
|
||||
fn store_session(&self, usertoken: &str) {
|
||||
if let Some(storage) = &self.inner.storage {
|
||||
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: usertoken.to_owned(),
|
||||
};
|
||||
|
||||
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}"),
|
||||
}
|
||||
match serde_json::to_string(&to_store) {
|
||||
Ok(json) => storage.write(&json),
|
||||
Err(e) => error!("Could not serialize session. Error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -430,35 +378,22 @@ impl Musixmatch {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
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 usertoken = self.get_usertoken(force_new_session).await?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("usertoken", &usertoken)
|
||||
.finish();
|
||||
|
||||
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);
|
||||
sign_url_with_date(url, OffsetDateTime::now_utc());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn _get_request_attempt<T>(&self, url: &Url, force_new_session: bool) -> Result<T>
|
||||
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, force_new_session).await?;
|
||||
self.finish_url(&mut req_url, false).await?;
|
||||
|
||||
let resp = self
|
||||
.inner
|
||||
|
|
@ -469,25 +404,27 @@ impl Musixmatch {
|
|||
.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,
|
||||
{
|
||||
match self._get_request_attempt(url, false).await {
|
||||
Ok(resp) => return Ok(resp),
|
||||
match parse_body(&resp_txt) {
|
||||
Ok(body) => Ok(body),
|
||||
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(Error::Ratelimit) => {
|
||||
info!("ratelimit, attempting to solve captcha");
|
||||
self.obtain_captcha_id().await?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
self._get_request_attempt(url, true).await
|
||||
}
|
||||
|
||||
/// Ensure the client has a login token
|
||||
|
|
@ -564,6 +501,9 @@ 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr};
|
||||
|
||||
use serde::{de::Visitor, Deserialize, Serialize};
|
||||
use serde::{Deserialize, Serialize, de::Visitor};
|
||||
|
||||
use crate::IdError;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{error::Result, Error};
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct LyricsBody {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||
use std::fmt::Write;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{error::Result, Error};
|
||||
use crate::{Error, error::Result};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SubtitleBody {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use super::{subtitle::SubtitleLines, SubtitleLine};
|
||||
use super::{SubtitleLine, subtitle::SubtitleLines};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct TranslationListBody {
|
||||
|
|
|
|||
|
|
@ -55,12 +55,14 @@ 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(())
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use path_macro::path;
|
||||
use rstest::{fixture, rstest};
|
||||
use time::macros::{date, datetime};
|
||||
|
||||
use musixmatch_inofficial::{
|
||||
models::{AlbumId, ArtistId, TrackId},
|
||||
Error, Musixmatch,
|
||||
models::{AlbumId, ArtistId, TrackId},
|
||||
};
|
||||
|
||||
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
|
||||
|
|
@ -18,20 +15,8 @@ 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)) = (
|
||||
|
|
@ -41,11 +26,8 @@ async fn mxm() -> Musixmatch {
|
|||
mxm = mxm.credentials(email, password);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -751,7 +733,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]
|
||||
|
|
@ -918,8 +900,9 @@ mod lyrics {
|
|||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(album.len(), 13);
|
||||
|
||||
let x = stream::iter(album)
|
||||
let lyrics = stream::iter(album)
|
||||
.map(|track| {
|
||||
mxm.track_lyrics(musixmatch_inofficial::models::TrackId::TrackId(
|
||||
track.track_id,
|
||||
|
|
@ -931,8 +914,7 @@ mod lyrics {
|
|||
.into_iter()
|
||||
.map(Result::unwrap)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
dbg!(x);
|
||||
assert_eq!(lyrics.len(), 13);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue