Compare commits
7 commits
main
...
feat/captc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d5c849b5f | |||
| dbbbfcd85b | |||
| 5942f99b28 | |||
| 1d23a3bec6 | |||
| 930b0c21e6 | |||
| c75b6358ec | |||
| a79d0abce2 |
24 changed files with 442 additions and 167 deletions
|
|
@ -27,26 +27,30 @@ jobs:
|
||||||
run: cargo clippy --all -- -D warnings
|
run: cargo clippy --all -- -D warnings
|
||||||
|
|
||||||
- name: 🧪 Test
|
- 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:
|
env:
|
||||||
RUST_LOG: info
|
RUST_LOG: debug
|
||||||
ALL_PROXY: "http://warpproxy:8124"
|
ALL_PROXY: "http://warpproxy:8124"
|
||||||
|
MUSIXMATCH_EMAIL: ${{ secrets.MUSIXMATCH_EMAIL }}
|
||||||
|
MUSIXMATCH_PASSWORD: ${{ secrets.MUSIXMATCH_PASSWORD }}
|
||||||
|
CAPMONSTER_KEY: ${{ secrets.CAPMONSTER_KEY }}
|
||||||
|
|
||||||
- name: Move test report
|
# - name: Move test report
|
||||||
if: always()
|
# if: always()
|
||||||
run: mv target/nextest/ci/junit.xml junit.xml || true
|
# run: mv target/nextest/ci/junit.xml junit.xml || true
|
||||||
|
|
||||||
- name: 💌 Upload test report
|
# - name: 💌 Upload test report
|
||||||
if: always()
|
# if: always()
|
||||||
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
# uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||||
with:
|
# with:
|
||||||
name: test
|
# name: test
|
||||||
path: |
|
# path: |
|
||||||
junit.xml
|
# junit.xml
|
||||||
|
|
||||||
- name: 🔗 Artifactview PR comment
|
# - name: 🔗 Artifactview PR comment
|
||||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
# if: ${{ always() && github.event_name == 'pull_request' }}
|
||||||
run: |
|
# run: |
|
||||||
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
|
# 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" \
|
# 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"}}'
|
# --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,9 +24,7 @@ jobs:
|
||||||
} >> "$GITHUB_ENV"
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: 📤 Publish crate on crates.io
|
- name: 📤 Publish crate on crates.io
|
||||||
run: cargo publish --package "${{ env.CRATE }}"
|
run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}"
|
||||||
env:
|
|
||||||
CARGO_REGISTRY_TOKEN: "${{ secrets.CARGO_TOKEN }}"
|
|
||||||
|
|
||||||
- name: 🎉 Publish release
|
- name: 🎉 Publish release
|
||||||
uses: https://gitea.com/actions/release-action@main
|
uses: https://gitea.com/actions/release-action@main
|
||||||
|
|
|
||||||
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -3,23 +3,6 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.3.0..musixmatch-inofficial/v0.4.0) - 2025-12-19
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "musixmatch-inofficial"
|
name = "musixmatch-inofficial"
|
||||||
version = "0.5.0"
|
version = "0.4.0"
|
||||||
rust-version = "1.89.0"
|
rust-version = "1.89.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
@ -15,7 +15,7 @@ include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"]
|
||||||
members = [".", "cli"]
|
members = [".", "cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
authors = ["ThetaDev <thetadev@magenta.de>"]
|
authors = ["ThetaDev <thetadev@magenta.de>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial"
|
repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial"
|
||||||
|
|
@ -23,7 +23,7 @@ keywords = ["music", "lyrics"]
|
||||||
categories = ["api-bindings", "multimedia"]
|
categories = ["api-bindings", "multimedia"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
musixmatch-inofficial = { version = "0.5.0", path = ".", default-features = false }
|
musixmatch-inofficial = { version = "0.4.0", path = ".", default-features = false }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["default-tls"]
|
default = ["default-tls"]
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,6 @@
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.3.1..musixmatch-cli/v0.4.0) - 2025-12-19
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "musixmatch-cli"
|
name = "musixmatch-cli"
|
||||||
version = "0.5.0"
|
version = "0.4.0"
|
||||||
rust-version = "1.89.0"
|
rust-version = "1.89.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
#![warn(missing_docs, clippy::todo)]
|
#![warn(missing_docs, clippy::todo)]
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io::{Write, stdin, stdout},
|
io::{stdin, stdout, Write},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
use musixmatch_inofficial::{
|
use musixmatch_inofficial::{
|
||||||
Musixmatch,
|
|
||||||
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
|
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
|
||||||
|
Musixmatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -233,31 +233,31 @@ async fn run(cli: Cli) -> Result<()> {
|
||||||
|
|
||||||
let mut lyrics_body = lyrics.lyrics_body;
|
let mut lyrics_body = lyrics.lyrics_body;
|
||||||
|
|
||||||
if let Some(lang) = lang
|
if let Some(lang) = lang {
|
||||||
&& Some(&lang) != lyrics.lyrics_language.as_ref()
|
if Some(&lang) != lyrics.lyrics_language.as_ref() {
|
||||||
{
|
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
|
||||||
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
|
if tl.is_empty() {
|
||||||
if tl.is_empty() {
|
eprintln!("Translation not found. Returning lyrics in original language.");
|
||||||
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 {
|
} else {
|
||||||
translated
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::{marker::PhantomData, str::FromStr};
|
use std::{marker::PhantomData, str::FromStr};
|
||||||
|
|
||||||
use serde::{
|
use serde::{
|
||||||
Deserialize, Deserializer, Serialize,
|
|
||||||
de::{DeserializeOwned, Visitor},
|
de::{DeserializeOwned, Visitor},
|
||||||
|
Deserialize, Deserializer, Serialize,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
|
@ -416,9 +416,9 @@ where
|
||||||
|
|
||||||
pub mod optional_date {
|
pub mod optional_date {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde::Serializer;
|
|
||||||
use serde::ser::Error as _;
|
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] =
|
const DATE_FORMAT: &[time::format_description::FormatItem] =
|
||||||
format_description!("[year]-[month]-[day]");
|
format_description!("[year]-[month]-[day]");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::album::{Album, AlbumBody, AlbumListBody};
|
use crate::models::album::{Album, AlbumBody, AlbumListBody};
|
||||||
use crate::models::{AlbumId, ArtistId, SortOrder};
|
use crate::models::{AlbumId, ArtistId, SortOrder};
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the metadata for an album specified by its ID.
|
/// Get the metadata for an album specified by its ID.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::ArtistId;
|
|
||||||
use crate::models::artist::{Artist, ArtistBody, ArtistListBody};
|
use crate::models::artist::{Artist, ArtistBody, ArtistListBody};
|
||||||
|
use crate::models::ArtistId;
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the metadata for an artist specified by its ID.
|
/// Get the metadata for an artist specified by its ID.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::TrackId;
|
|
||||||
use crate::models::lyrics::{Lyrics, LyricsBody};
|
use crate::models::lyrics::{Lyrics, LyricsBody};
|
||||||
use crate::models::translation::{TranslationList, TranslationListBody};
|
use crate::models::translation::{TranslationList, TranslationListBody};
|
||||||
|
use crate::models::TrackId;
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the lyrics for a track specified by its name and artist.
|
/// Get the lyrics for a track specified by its name and artist.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::TrackId;
|
|
||||||
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
|
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
|
||||||
|
use crate::models::TrackId;
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the richsync (word-by-word synchronized lyrics) for a track specified by its ID.
|
/// Get the richsync (word-by-word synchronized lyrics) for a track specified by its ID.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::TrackId;
|
|
||||||
use crate::models::snippet::{Snippet, SnippetBody};
|
use crate::models::snippet::{Snippet, SnippetBody};
|
||||||
|
use crate::models::TrackId;
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get a lyrics snippet for a track specified by its ID.
|
/// Get a lyrics snippet for a track specified by its ID.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::TrackId;
|
|
||||||
use crate::models::subtitle::{Subtitle, SubtitleBody, SubtitleFormat};
|
use crate::models::subtitle::{Subtitle, SubtitleBody, SubtitleFormat};
|
||||||
|
use crate::models::TrackId;
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the subtitles (synchronized lyrics) for a track specified by its name and artist.
|
/// Get the subtitles (synchronized lyrics) for a track specified by its name and artist.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use time::Date;
|
use time::Date;
|
||||||
|
|
||||||
use crate::Musixmatch;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::track::{Track, TrackBody, TrackListBody};
|
use crate::models::track::{Track, TrackBody, TrackListBody};
|
||||||
use crate::models::{AlbumId, ChartName, Genre, Genres, SortOrder, TrackId};
|
use crate::models::{AlbumId, ChartName, Genre, Genres, SortOrder, TrackId};
|
||||||
|
use crate::Musixmatch;
|
||||||
|
|
||||||
impl Musixmatch {
|
impl Musixmatch {
|
||||||
/// Get the metadata for a track specified by its name, artist and album.
|
/// Get the metadata for a track specified by its name, artist and album.
|
||||||
|
|
@ -403,17 +403,21 @@ impl<'a> TrackSearchQuery<'a> {
|
||||||
}
|
}
|
||||||
if let Some(f_track_release_group_first_release_date_min) =
|
if let Some(f_track_release_group_first_release_date_min) =
|
||||||
self.f_track_release_group_first_release_date_min
|
self.f_track_release_group_first_release_date_min
|
||||||
&& 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 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) =
|
if let Some(f_track_release_group_first_release_date_max) =
|
||||||
self.f_track_release_group_first_release_date_max
|
self.f_track_release_group_first_release_date_max
|
||||||
&& 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 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 {
|
if let Some(s_artist_rating) = &self.s_artist_rating {
|
||||||
url_query.append_pair("s_artist_rating", s_artist_rating.as_str());
|
url_query.append_pair("s_artist_rating", s_artist_rating.as_str());
|
||||||
|
|
|
||||||
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
|
/// Musixmatch returned no data or the data that could not be deserialized
|
||||||
#[error("JSON parsing error: {0}")]
|
#[error("JSON parsing error: {0}")]
|
||||||
InvalidData(Cow<'static, str>),
|
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 from the HTTP client
|
||||||
#[error("http error: {0}")]
|
#[error("http error: {0}")]
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
|
|
|
||||||
172
src/lib.rs
172
src/lib.rs
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
mod api_model;
|
mod api_model;
|
||||||
mod apis;
|
mod apis;
|
||||||
|
mod captcha;
|
||||||
mod error;
|
mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
@ -23,10 +24,10 @@ use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha1::Sha1;
|
use sha1::Sha1;
|
||||||
use storage::{FileStorage, SessionStorage};
|
use storage::{FileStorage, SessionStorage};
|
||||||
use time::OffsetDateTime;
|
|
||||||
use time::format_description::well_known::Rfc3339;
|
use time::format_description::well_known::Rfc3339;
|
||||||
use time::macros::format_description;
|
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::api_model::parse_body;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
@ -61,6 +62,7 @@ pub struct MusixmatchBuilder {
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
storage: DefaultOpt<Box<dyn SessionStorage>>,
|
storage: DefaultOpt<Box<dyn SessionStorage>>,
|
||||||
credentials: Option<Credentials>,
|
credentials: Option<Credentials>,
|
||||||
|
capmonster_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
|
|
@ -87,7 +89,9 @@ struct MusixmatchRef {
|
||||||
credentials: RwLock<Option<Credentials>>,
|
credentials: RwLock<Option<Credentials>>,
|
||||||
brand: String,
|
brand: String,
|
||||||
device: String,
|
device: String,
|
||||||
usertoken: Mutex<Option<String>>,
|
usertoken: AsyncRwLock<Option<String>>,
|
||||||
|
capmonster_key: Option<String>,
|
||||||
|
captcha: AsyncRwLock<Option<Captcha>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
|
|
@ -96,9 +100,17 @@ struct Credentials {
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct Captcha {
|
||||||
|
captcha_id: String,
|
||||||
|
solved_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct StoredSession {
|
struct StoredSession {
|
||||||
usertoken: String,
|
usertoken: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
captcha: Option<Captcha>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusixmatchBuilder {
|
impl MusixmatchBuilder {
|
||||||
|
|
@ -178,6 +190,14 @@ impl MusixmatchBuilder {
|
||||||
self
|
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
|
/// Returns a new, configured Musixmatch client
|
||||||
pub fn build(self) -> Result<Musixmatch> {
|
pub fn build(self) -> Result<Musixmatch> {
|
||||||
self.build_with_client(ClientBuilder::new())
|
self.build_with_client(ClientBuilder::new())
|
||||||
|
|
@ -197,6 +217,10 @@ impl MusixmatchBuilder {
|
||||||
.default_headers(headers)
|
.default_headers(headers)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
let (usertoken, captcha) = stored_session
|
||||||
|
.map(|s| (Some(s.usertoken), s.captcha))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(Musixmatch {
|
Ok(Musixmatch {
|
||||||
inner: MusixmatchRef {
|
inner: MusixmatchRef {
|
||||||
http,
|
http,
|
||||||
|
|
@ -204,7 +228,9 @@ impl MusixmatchBuilder {
|
||||||
credentials: RwLock::new(self.credentials),
|
credentials: RwLock::new(self.credentials),
|
||||||
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
|
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
|
||||||
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.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(),
|
.into(),
|
||||||
})
|
})
|
||||||
|
|
@ -227,10 +253,12 @@ impl Musixmatch {
|
||||||
|
|
||||||
async fn get_usertoken(&self, force_new: bool) -> Result<String> {
|
async fn get_usertoken(&self, force_new: bool) -> Result<String> {
|
||||||
// Lock the session here to prevent concurrent tasks from obtaining sessions
|
// 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 {
|
||||||
return Ok(usertoken.clone());
|
if let Some(usertoken) = &mut *stored_usertoken {
|
||||||
|
return Ok(usertoken.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let credentials = {
|
let credentials = {
|
||||||
|
|
@ -277,17 +305,36 @@ impl Musixmatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
*stored_usertoken = Some(usertoken.to_owned());
|
*stored_usertoken = Some(usertoken.to_owned());
|
||||||
self.store_session(&usertoken);
|
drop(stored_usertoken);
|
||||||
|
self.store_session().await;
|
||||||
Ok(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(
|
async fn post_credentials(
|
||||||
&self,
|
&self,
|
||||||
usertoken: &str,
|
usertoken: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
) -> Result<api_model::Account> {
|
) -> Result<api_model::Account> {
|
||||||
let mut url = new_url_from_token("credential.post", usertoken);
|
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 {
|
let api_credentials = api_model::Credentials {
|
||||||
credential_list: &[api_model::CredentialWrap {
|
credential_list: &[api_model::CredentialWrap {
|
||||||
|
|
@ -300,17 +347,19 @@ impl Musixmatch {
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = self
|
let login = match self
|
||||||
.inner
|
._post_credentials_request_attempt(&url, &api_credentials)
|
||||||
.http
|
.await
|
||||||
.post(url)
|
{
|
||||||
.json(&api_credentials)
|
Ok(login) => login,
|
||||||
.send()
|
Err(Error::Ratelimit) => {
|
||||||
.await?
|
info!("ratelimit, attempting to solve captcha");
|
||||||
.error_for_status()?;
|
self.obtain_captcha_id().await?;
|
||||||
|
self._post_credentials_request_attempt(&url, &api_credentials)
|
||||||
let resp_txt = resp.text().await?;
|
.await?
|
||||||
let login = parse_body::<api_model::Login>(&resp_txt)?;
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
let credential = login
|
let credential = login
|
||||||
.0
|
.0
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -333,15 +382,18 @@ impl Musixmatch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_session(&self, usertoken: &str) {
|
async fn store_session(&self) {
|
||||||
if let Some(storage) = &self.inner.storage {
|
if let Some(storage) = &self.inner.storage {
|
||||||
let to_store = StoredSession {
|
let usertoken = { self.inner.usertoken.read().await.to_owned() };
|
||||||
usertoken: usertoken.to_owned(),
|
if let Some(usertoken) = usertoken {
|
||||||
};
|
let captcha = { self.inner.captcha.read().await.to_owned() };
|
||||||
|
|
||||||
match serde_json::to_string(&to_store) {
|
let to_store = StoredSession { usertoken, captcha };
|
||||||
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}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -378,22 +430,35 @@ impl Musixmatch {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
|
fn sign_url(url: &mut Url) {
|
||||||
let usertoken = self.get_usertoken(force_new_session).await?;
|
|
||||||
url.query_pairs_mut()
|
|
||||||
.append_pair("usertoken", &usertoken)
|
|
||||||
.finish();
|
|
||||||
|
|
||||||
sign_url_with_date(url, OffsetDateTime::now_utc());
|
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(())
|
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
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
{
|
{
|
||||||
let mut req_url = url.clone();
|
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
|
let resp = self
|
||||||
.inner
|
.inner
|
||||||
|
|
@ -404,27 +469,25 @@ impl Musixmatch {
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
let resp_txt = resp.text().await?;
|
let resp_txt = resp.text().await?;
|
||||||
|
|
||||||
match parse_body(&resp_txt) {
|
parse_body(&resp_txt)
|
||||||
Ok(body) => Ok(body),
|
}
|
||||||
|
|
||||||
|
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) => {
|
Err(Error::TokenExpired) => {
|
||||||
info!("Usertoken expired, getting a new one");
|
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
|
/// Ensure the client has a login token
|
||||||
|
|
@ -501,9 +564,6 @@ mod tests {
|
||||||
fn t_sign_url() {
|
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();
|
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));
|
sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
|
||||||
assert_eq!(
|
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")
|
||||||
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 std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize, de::Visitor};
|
use serde::{de::Visitor, Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::IdError;
|
use crate::IdError;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{Error, error::Result};
|
use crate::{error::Result, Error};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct LyricsBody {
|
pub(crate) struct LyricsBody {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::{Error, error::Result};
|
use crate::{error::Result, Error};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct SubtitleBody {
|
pub(crate) struct SubtitleBody {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::{SubtitleLine, subtitle::SubtitleLines};
|
use super::{subtitle::SubtitleLines, SubtitleLine};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct TranslationListBody {
|
pub(crate) struct TranslationListBody {
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
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()
|
let mut f = File::options()
|
||||||
.create(true)
|
.create(true)
|
||||||
.read(true)
|
.read(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(path)?;
|
.open(path)?;
|
||||||
f.lock()?;
|
f.lock()?;
|
||||||
// Truncate file after making sure no one is reading/writing to it
|
|
||||||
f.set_len(0)?;
|
f.set_len(0)?;
|
||||||
f.write_all(data.as_bytes())?;
|
f.write_all(data.as_bytes())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
use std::path::{Path, PathBuf};
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use path_macro::path;
|
use path_macro::path;
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
use time::macros::{date, datetime};
|
use time::macros::{date, datetime};
|
||||||
|
|
||||||
use musixmatch_inofficial::{
|
use musixmatch_inofficial::{
|
||||||
Error, Musixmatch,
|
|
||||||
models::{AlbumId, ArtistId, TrackId},
|
models::{AlbumId, ArtistId, TrackId},
|
||||||
|
Error, Musixmatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
|
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
|
||||||
|
|
@ -15,8 +18,20 @@ fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
async fn mxm() -> Musixmatch {
|
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 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();
|
let mut mxm = Musixmatch::builder();
|
||||||
|
|
||||||
if let (Ok(email), Ok(password)) = (
|
if let (Ok(email), Ok(password)) = (
|
||||||
|
|
@ -26,8 +41,11 @@ async fn mxm() -> Musixmatch {
|
||||||
mxm = mxm.credentials(email, password);
|
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();
|
LOGIN_LOCK.get_or_try_init(|| mxm.login()).await.unwrap();
|
||||||
mxm
|
mxm
|
||||||
}
|
}
|
||||||
|
|
@ -733,7 +751,7 @@ mod track {
|
||||||
async fn genres(#[future] mxm: Musixmatch) {
|
async fn genres(#[future] mxm: Musixmatch) {
|
||||||
let genres = mxm.await.genres().await.unwrap();
|
let genres = mxm.await.genres().await.unwrap();
|
||||||
assert!(genres.len() > 360);
|
assert!(genres.len() > 360);
|
||||||
// dbg!(&genres);
|
dbg!(&genres);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
|
|
@ -900,9 +918,8 @@ mod lyrics {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(album.len(), 13);
|
|
||||||
|
|
||||||
let lyrics = stream::iter(album)
|
let x = stream::iter(album)
|
||||||
.map(|track| {
|
.map(|track| {
|
||||||
mxm.track_lyrics(musixmatch_inofficial::models::TrackId::TrackId(
|
mxm.track_lyrics(musixmatch_inofficial::models::TrackId::TrackId(
|
||||||
track.track_id,
|
track.track_id,
|
||||||
|
|
@ -914,7 +931,8 @@ mod lyrics {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(Result::unwrap)
|
.map(Result::unwrap)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(lyrics.len(), 13);
|
|
||||||
|
dbg!(x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue