diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index bc6e278..5ebadac 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,5 +1,8 @@ name: CI -on: [push, pull_request] +on: + push: + pull_request: + workflow_dispatch: jobs: Test: @@ -24,6 +27,25 @@ jobs: run: cargo clippy --all -- -D warnings - name: ๐Ÿงช Test - run: cargo test --workspace + run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 -j 1 --workspace env: ALL_PROXY: "http://warpproxy:8124" + + - 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: ๐Ÿ”— 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"}}' diff --git a/.gitea/workflows/renovate.yaml b/.gitea/workflows/renovate.yaml.bak similarity index 97% rename from .gitea/workflows/renovate.yaml rename to .gitea/workflows/renovate.yaml.bak index e91c636..013b185 100644 --- a/.gitea/workflows/renovate.yaml +++ b/.gitea/workflows/renovate.yaml.bak @@ -17,7 +17,7 @@ jobs: renovate: runs-on: docker container: - image: renovate/renovate:latest + image: renovate/renovate:39 steps: - name: Load renovate repo cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c73e0..0801b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,62 @@ All notable changes to this project will be documented in this file. +## [v0.3.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.2.1..musixmatch-inofficial/v0.3.0) - 2025-12-08 + +### ๐Ÿš€ Features + +- [**breaking**] Removed artist_related endpoint (discontinued) - ([74d5359](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/74d5359547e0b3ffa75f5f7384ed3b7ab0e2ea11)) + +### ๐Ÿ› Bug Fixes + +- Update signature secret - ([59dee61](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/59dee61a2fdbda6a7643aa209b9baaf25a6de0f8)) +- Clippy lints - ([bfe6fec](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/bfe6fec115b9ba3b58f1e949ac7b583a95041f8f)) +- Clippy lints (2) - ([09c6004](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/09c6004ed13c2f9039490c083b0e6b61dccaa884)) +- Remove serde::__private::fmt - ([e087752](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e087752dd0d331093a3434e18b5ff276bf4dc7c1)) + + +## [v0.2.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.2.0..musixmatch-inofficial/v0.2.1) - 2025-04-04 + +### ๐Ÿ› Bug Fixes + +- Parsing unset has_fan_chant field - ([6f90033](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6f90033cf4284eff5c12a30aafb21943c1575b92)) + +### ๐Ÿ“š Documentation + +- Fix docs - ([4a46e7b](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4a46e7bb1d83c6261660d403c009cdb640b301d7)) + +### โš™๏ธ Miscellaneous Tasks + +- *(deps)* Update rust crate governor to 0.10.0 - ([87859e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/87859e629f3c236ba450872b29beb7876be7ef0b)) +- *(deps)* Update rust crate rstest to 0.25.0 - ([a3f2ffc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a3f2ffc5d99ddddf777b4de306bd215bd3bbf5ce)) +- *(deps)* Update rust crate rand to 0.9.0 - ([7c325c4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/7c325c4af779e32059680c1cfb874f83896d7649)) + + +## [v0.2.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.2..musixmatch-inofficial/v0.2.0) - 2025-01-16 + +### ๐Ÿš€ Features + +- Add track performer tagging, artist images - ([b136bb3](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/b136bb30040dc3ee849c26ff984884e706739235)) + +### โš™๏ธ Miscellaneous Tasks + +- Fix clippy lints - ([26f4729](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/26f4729738536d735cb808fce8a8e466f2e82449)) +- *(deps)* Update rust crate governor to 0.8.0 (#5) - ([4d26c4a](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4d26c4a72f617228a5e62d4d565e2c7a6f3d7f95)) +- *(deps)* Update rust crate rstest to 0.24.0 (#6) - ([6942d0e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6942d0eaaa6dfa15846c7f1a09ca4165a5a4b3c3)) + + +## [v0.1.2](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.1..musixmatch-inofficial/v0.1.2) - 2024-11-15 + +### ๐Ÿ› Bug Fixes + +- *(deps)* Update rust crate thiserror to v2 (#4) - ([6a6ced1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6a6ced16224c6ef3d05eb6ebd0aa0bdc40a34684)) + +### โš™๏ธ Miscellaneous Tasks + +- *(deps)* Update rust crate rstest to 0.23.0 (#2) - ([5ef76f5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/5ef76f5a6b2a3b243f847cf86e72ebe176819d7a)) +- *(deps)* Update rust crate governor to 0.7.0 (#3) - ([4bfcb79](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4bfcb791733ce5ebd9d4e074c64eb23e9a768fc6)) + + ## [v0.1.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.0..musixmatch-inofficial/v0.1.1) - 2024-08-18 ### ๐Ÿš€ Features diff --git a/Cargo.toml b/Cargo.toml index 0a3b896..0d3ead1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "musixmatch-inofficial" -version = "0.1.1" +version = "0.3.0" rust-version = "1.70.0" edition.workspace = true authors.workspace = true @@ -23,7 +23,7 @@ keywords = ["music", "lyrics"] categories = ["api-bindings", "multimedia"] [workspace.dependencies] -musixmatch-inofficial = { version = "0.1.1", path = ".", default-features = false } +musixmatch-inofficial = { version = "0.3.0", path = ".", default-features = false } [features] default = ["default-tls"] @@ -44,7 +44,7 @@ reqwest = { version = "0.12.0", default-features = false, features = [ tokio = { version = "1.20.4" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.85" -thiserror = "1.0.0" +thiserror = "2.0.0" log = "0.4.17" time = { version = "0.3.10", features = [ "macros", @@ -54,13 +54,14 @@ time = { version = "0.3.10", features = [ ] } hmac = "0.12.0" sha1 = "0.10.0" -rand = "0.8.0" +rand = "0.9.0" base64 = "0.22.0" [dev-dependencies] -rstest = { version = "0.22.0", default-features = false } +rstest = { version = "0.26.0", default-features = false } dotenvy = "0.15.5" tokio = { version = "1.20.4", features = ["macros"] } futures = "0.3.21" path_macro = "1.0.0" -governor = "0.6.3" +governor = "0.10.0" +test-log = "0.2.16" diff --git a/Justfile b/Justfile index 7456066..f288b43 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,5 @@ test: - cargo test + cargo nextest run --workspace --no-fail-fast --retries 1 -j 1 release crate="musixmatch-inofficial": #!/usr/bin/env bash diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index c8a2e23..9c882bc 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to this project will be documented in this file. +## [v0.3.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.3.0..musixmatch-cli/v0.3.1) - 2025-12-08 + +### ๐Ÿ› Bug Fixes + +- Clippy lints - ([bfe6fec](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/bfe6fec115b9ba3b58f1e949ac7b583a95041f8f)) +- Clippy lints (3) - ([39bbd50](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/39bbd5070a62d865c0e276904047bb687f1ba8a8)) + +### โš™๏ธ Miscellaneous Tasks + +- *(deps)* Update rust crate governor to 0.10.0 - ([87859e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/87859e629f3c236ba450872b29beb7876be7ef0b)) +- *(deps)* Update rust crate rstest to 0.25.0 - ([a3f2ffc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a3f2ffc5d99ddddf777b4de306bd215bd3bbf5ce)) +- *(deps)* Update rust crate rand to 0.9.0 - ([7c325c4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/7c325c4af779e32059680c1cfb874f83896d7649)) +- *(deps)* Update musixmatch to 0.3.0 + + +## [v0.3.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.2.0..musixmatch-cli/v0.3.0) - 2025-01-16 + +### ๐Ÿš€ Features + +- Add track performer tagging, artist images - ([b136bb3](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/b136bb30040dc3ee849c26ff984884e706739235)) + +### ๐Ÿ› Bug Fixes + +- *(deps)* Update rust crate thiserror to v2 (#4) - ([6a6ced1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6a6ced16224c6ef3d05eb6ebd0aa0bdc40a34684)) +- *(deps)* Update rust crate dirs to v6 (#7) - ([319dabe](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/319dabeee018f8b5b633cf91e792b12fa18e7775)) + +### โš™๏ธ Miscellaneous Tasks + +- *(deps)* Update rust crate rstest to 0.23.0 (#2) - ([5ef76f5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/5ef76f5a6b2a3b243f847cf86e72ebe176819d7a)) +- *(deps)* Update rust crate governor to 0.7.0 (#3) - ([4bfcb79](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4bfcb791733ce5ebd9d4e074c64eb23e9a768fc6)) +- *(deps)* Update rust crate governor to 0.8.0 (#5) - ([4d26c4a](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4d26c4a72f617228a5e62d4d565e2c7a6f3d7f95)) +- *(deps)* Update rust crate rstest to 0.24.0 (#6) - ([6942d0e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6942d0eaaa6dfa15846c7f1a09ca4165a5a4b3c3)) + + ## [v0.2.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.1.0..musixmatch-cli/v0.2.0) - 2024-08-18 ### ๐Ÿš€ Features diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 8b77e98..6543bc5 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "musixmatch-cli" -version = "0.2.0" +version = "0.3.1" rust-version = "1.70.0" edition.workspace = true authors.workspace = true @@ -25,5 +25,5 @@ tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] } clap = { version = "4.0.0", features = ["derive"] } anyhow = "1.0.0" rpassword = "7.0.0" -dirs = "5.0.0" +dirs = "6.0.0" serde_json = "1.0.85" diff --git a/cli/src/main.rs b/cli/src/main.rs index 6a303da..84e26b9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -55,6 +55,11 @@ enum Commands { #[clap(flatten)] ident: TrackIdentifiers, }, + /// Get performer tagging + Performer { + #[clap(flatten)] + ident: TrackIdentifiers, + }, /// Get album metadata Album { #[clap(flatten)] @@ -170,7 +175,7 @@ async fn main() { let cli = Cli::parse(); run(cli).await.unwrap_or_else(|e| { - println!("Error: {}", e); + eprintln!("Error: {e}"); std::process::exit(1); }); } @@ -246,7 +251,7 @@ async fn run(cli: Cli) -> Result<()> { } eprintln!(); - println!("{}", lyrics_body); + println!("{lyrics_body}"); } Commands::Subtitles { ident, @@ -307,16 +312,23 @@ async fn run(cli: Cli) -> Result<()> { bail!("subtitle format {format:?} cant be translated") } }; - println!("{}", res); + println!("{res}"); } else { eprintln!(); println!("{}", subtitles.subtitle_body); } } Commands::Track { ident } => { - let track = get_track(ident, &mxm).await?; + let track = get_track(ident, &mxm, false).await?; println!("{}", serde_json::to_string_pretty(&track)?) } + Commands::Performer { ident } => { + let track = get_track(ident, &mxm, true).await?; + println!( + "{}", + serde_json::to_string_pretty(&track.performer_tagging)? + ) + } Commands::Album { ident } => { let id = if let Some(id) = ident.mxm_id { AlbumId::AlbumId(id) @@ -397,6 +409,7 @@ async fn get_track_or_id( ident: TrackIdentifiers, mxm: &Musixmatch, translation_status: bool, + performer_tagging: bool, ) -> Result> { Ok( match ( @@ -422,8 +435,15 @@ async fn get_track_or_id( } (_, _, _, _, _, _, _, Some(isrc)) => TrackOrId::TrackId(TrackId::Isrc(isrc.into())), (Some(name), Some(artist), _, _, _, _, _, _) => TrackOrId::Track(Box::new( - mxm.matcher_track(&name, &artist, "", translation_status, true) - .await?, + mxm.matcher_track( + &name, + &artist, + "", + translation_status, + true, + performer_tagging, + ) + .await?, )), _ => bail!("no track identifier given"), }, @@ -431,21 +451,27 @@ async fn get_track_or_id( } async fn get_track_id(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result> { - Ok(match get_track_or_id(ident, mxm, false).await? { + Ok(match get_track_or_id(ident, mxm, false, false).await? { TrackOrId::Track(track) => TrackId::TrackId(track.track_id), TrackOrId::TrackId(id) => id, }) } -async fn get_track(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result { - Ok(match get_track_or_id(ident, mxm, true).await? { - TrackOrId::Track(track) => *track, - TrackOrId::TrackId(id) => mxm.track(id, true, true).await?, - }) +async fn get_track( + ident: TrackIdentifiers, + mxm: &Musixmatch, + performer_tagging: bool, +) -> Result { + Ok( + match get_track_or_id(ident, mxm, true, performer_tagging).await? { + TrackOrId::Track(track) => *track, + TrackOrId::TrackId(id) => mxm.track(id, true, true, performer_tagging).await?, + }, + ) } fn input(prompt: &str) -> String { - print!("{}", prompt); + print!("{prompt}"); stdout().flush().expect("Failed to flush stdout!"); @@ -457,7 +483,7 @@ fn input(prompt: &str) -> String { } fn input_pwd(prompt: &str) -> String { - print!("{}", prompt); + print!("{prompt}"); stdout().flush().expect("Failed to flush stdout!"); rpassword::read_password().expect("Failed to read password") diff --git a/cliff.toml b/cliff.toml index a12fc01..b456d2d 100644 --- a/cliff.toml +++ b/cliff.toml @@ -73,7 +73,7 @@ commit_parsers = [ { message = "^perf", group = "โšก Performance" }, { message = "^refactor", group = "๐Ÿšœ Refactor" }, { message = "^style", group = "๐ŸŽจ Styling" }, - { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^test", skip = true }, { message = "^chore\\(release\\)", skip = true }, { message = "^chore\\(pr\\)", skip = true }, { message = "^chore\\(pull\\)", skip = true }, diff --git a/renovate.json b/renovate.json index 3ff20b1..1ec2687 100644 --- a/renovate.json +++ b/renovate.json @@ -1,8 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:best-practices" - ], + "extends": ["config:best-practices", ":preserveSemverRanges"], "semanticCommits": "enabled", "automerge": true, "automergeStrategy": "squash", diff --git a/src/api_model.rs b/src/api_model.rs index 463e5d1..d69aaa8 100644 --- a/src/api_model.rs +++ b/src/api_model.rs @@ -117,7 +117,7 @@ where { struct BoolFromIntVisitor; - impl<'de> Visitor<'de> for BoolFromIntVisitor { + impl Visitor<'_> for BoolFromIntVisitor { type Value = bool; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -232,7 +232,7 @@ where n: PhantomData, } - impl<'de, N> Visitor<'de> for NullIfZeroVisitor + impl Visitor<'_> for NullIfZeroVisitor where N: TryFrom, { @@ -300,7 +300,7 @@ where { struct NullIfEmptyVisitor; - impl<'de> Visitor<'de> for NullIfEmptyVisitor { + impl Visitor<'_> for NullIfEmptyVisitor { type Value = Option; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -347,7 +347,7 @@ where n: PhantomData, } - impl<'de, N> Visitor<'de> for ParseIntVisitor + impl Visitor<'_> for ParseIntVisitor where N: FromStr + TryFrom, { @@ -441,13 +441,10 @@ pub mod optional_date { ) -> Result, D::Error> { struct OptionalDateVisitor; - impl<'de> Visitor<'de> for OptionalDateVisitor { + impl Visitor<'_> for OptionalDateVisitor { type Value = Option; - fn expecting( - &self, - formatter: &mut serde::__private::fmt::Formatter, - ) -> serde::__private::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("date or empty string") } @@ -501,13 +498,10 @@ pub mod optional_datetime { ) -> Result, D::Error> { struct OptionalDateVisitor; - impl<'de> Visitor<'de> for OptionalDateVisitor { + impl Visitor<'_> for OptionalDateVisitor { type Value = Option; - fn expecting( - &self, - formatter: &mut serde::__private::fmt::Formatter, - ) -> serde::__private::fmt::Result { + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("timestamp or empty string") } @@ -543,6 +537,55 @@ pub mod optional_datetime { } } +pub fn single_or_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct SingleOrVecVisitor { + t: PhantomData, + } + + impl<'de, T> Visitor<'de> for SingleOrVecVisitor + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("single object or list") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut res = Vec::new(); + while let Some(x) = seq.next_element()? { + res.push(x); + } + Ok(res) + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let (k1, val) = map + .next_entry::<&str, T>()? + .ok_or(serde::de::Error::missing_field("value"))?; + if let Some((k2, _)) = map.next_entry::<&str, serde::de::IgnoredAny>()? { + return Err(serde::de::Error::custom(format!( + "expected only 1 value, got keys `{k1}`, `{k2}`" + ))); + } + Ok(vec![val]) + } + } + + deserializer.deserialize_any(SingleOrVecVisitor { t: PhantomData }) +} + #[cfg(test)] mod tests { use time::Date; @@ -686,4 +729,21 @@ mod tests { let res = serde_json::from_str::(json_date).unwrap(); assert!(res.date.is_some()); } + + #[test] + fn deserialize_single_or_vec() { + #[derive(Deserialize, Debug)] + struct S { + #[serde(deserialize_with = "single_or_vec")] + vec: Vec, + } + + let res = serde_json::from_str::(r#"{"vec": [1, 2, 3]}"#).unwrap(); + assert_eq!(res.vec, [1, 2, 3]); + + let res = serde_json::from_str::(r#"{"vec": {"value": 1}}"#).unwrap(); + assert_eq!(res.vec, [1]); + + serde_json::from_str::(r#"{"vec": {"value": 1, "other": "xyz"}}"#).unwrap_err(); + } } diff --git a/src/apis/artist_api.rs b/src/apis/artist_api.rs index f2dde1f..024c6bc 100644 --- a/src/apis/artist_api.rs +++ b/src/apis/artist_api.rs @@ -18,6 +18,7 @@ impl Musixmatch { let id_param = id.to_param(); url_query.append_pair(id_param.0, &id_param.1); + url_query.append_pair("part", "artist_image"); url_query.finish(); } @@ -25,40 +26,6 @@ impl Musixmatch { Ok(artist_body.artist) } - /// Get a list of artists somehow related to the one specified by its ID. - /// - /// # Parameters - /// - `id`: [Artist ID](crate::models::ArtistId) - /// - `page_size`: Define the page size for paginated results. Range is 1 to 100. - /// - `page`: Define the page number for paginated results, starting from 1. - /// - /// # Reference - /// - pub async fn artist_related( - &self, - id: ArtistId<'_>, - page_size: u8, - page: u32, - ) -> Result> { - let mut url = self.new_url("artist.related.get"); - { - let mut url_query = url.query_pairs_mut(); - - let id_param = id.to_param(); - url_query.append_pair(id_param.0, &id_param.1); - url_query.append_pair("page_size", &page_size.to_string()); - url_query.append_pair("page", &page.to_string()); - url_query.finish(); - } - - let artist_list_body = self.execute_get_request::(&url).await?; - Ok(artist_list_body - .artist_list - .into_iter() - .map(|a| a.artist) - .collect()) - } - /// Search for artists in the Musixmatch database. /// /// # Parameters diff --git a/src/apis/track_api.rs b/src/apis/track_api.rs index 045779f..1c25975 100644 --- a/src/apis/track_api.rs +++ b/src/apis/track_api.rs @@ -26,6 +26,7 @@ impl Musixmatch { q_album: &str, translation_status: bool, lang_3c: bool, + performer_tagging: bool, ) -> Result { let mut url = self.new_url("matcher.track.get"); { @@ -40,8 +41,10 @@ impl Musixmatch { if !q_album.is_empty() { url_query.append_pair("q_album", q_album); } + + let mut part = Vec::new(); if translation_status { - url_query.append_pair("part", "track_lyrics_translation_status"); + part.push("track_lyrics_translation_status"); url_query.append_pair( "language_iso_code", match lang_3c { @@ -50,6 +53,13 @@ impl Musixmatch { }, ); } + if performer_tagging { + part.push("track_performer_tagging"); + } + if !part.is_empty() { + url_query.append_pair("part", &part.join(",")); + } + url_query.finish(); } @@ -73,6 +83,7 @@ impl Musixmatch { id: TrackId<'_>, translation_status: bool, lang_3c: bool, + performer_tagging: bool, ) -> Result { let mut url = self.new_url("track.get"); { @@ -80,8 +91,10 @@ impl Musixmatch { let id_param = id.to_param(); url_query.append_pair(id_param.0, &id_param.1); + + let mut part = Vec::new(); if translation_status { - url_query.append_pair("part", "track_lyrics_translation_status"); + part.push("track_lyrics_translation_status"); url_query.append_pair( "language_iso_code", match lang_3c { @@ -90,6 +103,13 @@ impl Musixmatch { }, ); } + if performer_tagging { + part.push("track_performer_tagging"); + } + if !part.is_empty() { + url_query.append_pair("part", &part.join(",")); + } + url_query.finish(); } @@ -206,7 +226,7 @@ impl Musixmatch { /// /// # Reference /// - pub fn track_search(&self) -> TrackSearchQuery { + pub fn track_search(&self) -> TrackSearchQuery<'_> { TrackSearchQuery { mxm: self.clone(), q_track: None, diff --git a/src/error.rs b/src/error.rs index 17e03e0..f58312b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,3 +52,8 @@ impl From for Error { Self::InvalidData(value.to_string().into()) } } + +/// Could not parse Musixmatch FQID +#[derive(thiserror::Error, Debug)] +#[error("Could not parse Musixmatch FQID")] +pub struct IdError; diff --git a/src/lib.rs b/src/lib.rs index b494a13..448d8d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,7 @@ use std::fmt::Debug; use std::path::Path; use std::sync::{Arc, RwLock}; -pub use error::Error; +pub use error::{Error, IdError}; use base64::Engine; use hmac::{Hmac, Mac}; @@ -36,7 +36,7 @@ const YMD_FORMAT: &[time::format_description::FormatItem] = const APP_ID: &str = "android-player-v1.0"; const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/"; -const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('"; +const SIGNATURE_SECRET: &[u8; 29] = b"mNdca@6W7TeEcFn6*3.s97sJ*yPMd"; const DEFAULT_UA: &str = "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)"; const DEFAULT_BRAND: &str = "Google"; @@ -247,7 +247,7 @@ impl Musixmatch { // Get user token // The get_token endpoint seems to be rate limited for 2 requests per minute let mut url = Url::parse_with_params( - &format!("{}{}", API_URL, "token.get"), + &format!("{API_URL}token.get"), &[ ("adv_id", adv_id.as_str()), ("root", "0"), @@ -343,7 +343,7 @@ impl Musixmatch { match serde_json::to_string(&to_store) { Ok(json) => storage.write(&json), - Err(e) => error!("Could not serialize session. Error: {}", e), + Err(e) => error!("Could not serialize session. Error: {e}"), } } } @@ -355,7 +355,7 @@ impl Musixmatch { .and_then(|json| match serde_json::from_str::(&json) { Ok(session) => Some(session), Err(e) => { - error!("Could not deserialize session. Error: {}", e); + error!("Could not deserialize session. Error: {e}"); None } }) @@ -374,7 +374,7 @@ impl Musixmatch { fn new_url(&self, endpoint: &str) -> reqwest::Url { Url::parse_with_params( - &format!("{}{}", API_URL, endpoint), + &format!("{API_URL}{endpoint}"), &[("app_id", APP_ID), ("format", "json")], ) .unwrap() @@ -449,26 +449,26 @@ impl Musixmatch { } fn random_guid() -> String { - let mut rng = rand::thread_rng(); - let n = rng.gen::(); - format!("{:016x}", n) + let mut rng = rand::rng(); + let n = rng.random::(); + format!("{n:016x}") } fn random_uuid() -> String { - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); format!( "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - rng.gen::(), - rng.gen::(), - rng.gen::(), - rng.gen::(), - rng.gen::() & 0xffffffffffff, + rng.random::(), + rng.random::(), + rng.random::(), + rng.random::(), + rng.random::() & 0xffffffffffff, ) } fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url { Url::parse_with_params( - &format!("{}{}", API_URL, endpoint), + &format!("{API_URL}{endpoint}"), &[ ("app_id", APP_ID), ("usertoken", usertoken), @@ -503,6 +503,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=cvXbedVvGneT7o4k8QG6jfk9pAM%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") } } diff --git a/src/models/album.rs b/src/models/album.rs index afc0d59..9d4e8f7 100644 --- a/src/models/album.rs +++ b/src/models/album.rs @@ -15,6 +15,7 @@ pub(crate) struct AlbumListBody { /// Album: an album of songs in the Musixmatch database. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Album { /// Unique Musixmatch Album ID pub album_id: u64, diff --git a/src/models/artist.rs b/src/models/artist.rs index e6d1a76..0d85dd7 100644 --- a/src/models/artist.rs +++ b/src/models/artist.rs @@ -15,6 +15,7 @@ pub(crate) struct ArtistListBody { /// Artist: an artist in the Musixmatch database. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Artist { /// Musixmatch Artist ID pub artist_id: u64, @@ -85,10 +86,14 @@ pub struct Artist { /// End date of the artist's presence #[serde(default, with = "crate::api_model::optional_date")] pub end_date: Option, + /// Pictures of the artist + #[serde(default, deserialize_with = "crate::api_model::single_or_vec")] + pub artist_image: Vec, } /// Alternative artist name (e.g. different languages) #[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] pub struct ArtistAlias { /// Alternative artist name pub artist_alias: String, @@ -96,6 +101,7 @@ pub struct ArtistAlias { /// Artist name in another language #[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] pub struct ArtistNameTranslation { /// Artist name in another language pub artist_name_translation: ArtistNameTranslationInner, @@ -103,6 +109,7 @@ pub struct ArtistNameTranslation { /// Alternative artist name (e.g. different languages) #[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] pub struct ArtistNameTranslationInner { /// Language code (e.g. "EN") /// @@ -111,3 +118,42 @@ pub struct ArtistNameTranslationInner { /// Translated name pub translation: String, } + +/// Artist image +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ArtistImage { + /// ID of the image in the Musixmatch database + pub image_id: u64, + pub image_source_id: u32, + /// Author who created the image + #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] + pub image_author: Option, + /// Copyright info for the image + #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] + pub image_copyright: Option, + /// Image tags + #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] + pub image_tags: Option, + // List of image files scaled to different sizes + pub image_format_list: Vec, +} + +/// Image file (wrapper struct) +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ImageFormatWrap { + pub image_format: ImageFormat, +} + +/// Image file +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ImageFormat { + /// URL to the image file + pub image_url: String, + /// Image width in pixels + pub width: u32, + /// Image height in pixels + pub height: u32, +} diff --git a/src/models/id.rs b/src/models/id.rs index 2e8879a..3753c49 100644 --- a/src/models/id.rs +++ b/src/models/id.rs @@ -1,4 +1,8 @@ -use std::borrow::Cow; +use std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr}; + +use serde::{de::Visitor, Deserialize, Serialize}; + +use crate::IdError; /// Track identifiers from different sources #[derive(Debug, Clone, PartialEq, Eq)] @@ -25,7 +29,7 @@ pub enum TrackId<'a> { Spotify(Cow<'a, str>), } -impl<'a> TrackId<'a> { +impl TrackId<'_> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { TrackId::Commontrack(id) => ("commontrack_id", id.to_string()), @@ -50,7 +54,7 @@ pub enum ArtistId<'a> { Musicbrainz(&'a str), } -impl<'a> ArtistId<'a> { +impl ArtistId<'_> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { ArtistId::ArtistId(id) => ("artist_id", id.to_string()), @@ -71,7 +75,7 @@ pub enum AlbumId<'a> { Musicbrainz(&'a str), } -impl<'a> AlbumId<'a> { +impl AlbumId<'_> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { AlbumId::AlbumId(id) => ("album_id", id.to_string()), @@ -96,3 +100,130 @@ impl SortOrder { } } } + +/// Musixmatch fully qualified ID +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Fqid { + /// Numeric Musixmatch ID + pub id: u64, + /// Entity type + pub typ: MxmEntityType, +} + +/// Musixmatch entity type +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[allow(missing_docs)] +#[non_exhaustive] +pub enum MxmEntityType { + Artist, + #[serde(other)] + Unknown, +} + +impl std::fmt::Display for MxmEntityType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + MxmEntityType::Artist => "artist", + MxmEntityType::Unknown => "unknown", + }; + f.write_str(s) + } +} + +impl FromStr for MxmEntityType { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "artist" => Self::Artist, + _ => Self::Unknown, + }) + } +} + +impl std::fmt::Debug for MxmEntityType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self, f) + } +} + +impl std::fmt::Display for Fqid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "mxm:{}:{}", self.typ, self.id) + } +} + +impl std::fmt::Debug for Fqid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_char('"')?; + std::fmt::Display::fmt(&self, f)?; + f.write_char('"') + } +} + +impl FromStr for Fqid { + type Err = IdError; + + fn from_str(s: &str) -> Result { + let wo_pfx = s.strip_prefix("mxm:").ok_or(IdError)?; + let (typ_s, id_s) = wo_pfx.split_once(':').ok_or(IdError)?; + let id = id_s.parse().map_err(|_| IdError)?; + let typ = typ_s.parse().unwrap(); + Ok(Self { id, typ }) + } +} + +impl Serialize for Fqid { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Fqid { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct FqidVisitor; + + impl Visitor<'_> for FqidVisitor { + type Value = Fqid; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("Musixmatch FQID") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + v.parse().map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(FqidVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::Fqid; + + #[test] + fn serialize_fqid() { + let json = r#""mxm:artist:27853427""#; + let id = serde_json::from_str::(json).unwrap(); + assert_eq!( + id, + Fqid { + id: 27853427, + typ: crate::models::id::MxmEntityType::Artist + } + ); + assert_eq!(serde_json::to_string(&id).unwrap(), json) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 720d4e6..8aef72e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,6 +10,8 @@ pub use subtitle::SubtitleTime; mod id; pub use id::AlbumId; pub use id::ArtistId; +pub use id::Fqid; +pub use id::MxmEntityType; pub use id::SortOrder; pub use id::TrackId; @@ -23,8 +25,12 @@ pub use translation::TranslationMap; pub(crate) mod track; pub use track::ChartName; +pub use track::Performer; +pub use track::PerformerTaggingPart; +pub use track::PerformerTaggingResources; pub use track::Track; pub use track::TrackLyricsTranslationStatus; +pub use track::TrackPerformerTagging; mod genre; pub use genre::Genre; diff --git a/src/models/snippet.rs b/src/models/snippet.rs index 9dc03a9..fa9944b 100644 --- a/src/models/snippet.rs +++ b/src/models/snippet.rs @@ -14,6 +14,7 @@ pub(crate) struct SnippetBody { /// /// Example: "There's not a thing that I would change" #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[allow(missing_docs)] pub struct Snippet { /// Unique Musixmatch Snippet ID pub snippet_id: u64, diff --git a/src/models/track.rs b/src/models/track.rs index 7c72a62..bfb1980 100644 --- a/src/models/track.rs +++ b/src/models/track.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -use super::Genres; +use super::{Artist, Fqid, Genres}; #[derive(Debug, Deserialize)] pub(crate) struct TrackBody { @@ -140,6 +140,8 @@ pub struct Track { /// Status of lyrics translation #[serde(default)] pub track_lyrics_translation_status: Vec, + /// Lyrics parts marked with the performer who is singing them + pub performer_tagging: Option, } /// Status of lyrics translation (language + progress) @@ -156,6 +158,72 @@ pub struct TrackLyricsTranslationStatus { pub perc: f32, } +/// Lyrics parts marked with the performer who is singing them +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TrackPerformerTagging { + /// Musixmatch user ID of the user who added the performer tags + /// + /// Format: `mxm:<16 byte hex>` + pub user_id: String, + /// True if the lyrics are completely tagged + #[serde(default)] + pub completed: bool, + /// True if the lyrics have unknown performers + #[serde(default)] + pub has_unknown: bool, + /// True if the lyrics contain parts that are intended to be sung by the + /// audience during concerts + #[serde(default)] + pub has_fan_chant: bool, + /// List of tagged lyrics parts + #[serde(default)] + pub content: Vec, + /// Artists (and possibly other objects) that are referenced by the tagged parts + #[serde(default)] + pub resources: PerformerTaggingResources, +} + +/// Performer-tagged lyrics part +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PerformerTaggingPart { + /// Part of the lyrics text + /// + /// Includes whitespace (spaces and newline characters). + pub snippet: String, + /// Unknown + /// + /// Values: 0-3 + pub position: u32, + /// List of performers singing this part + pub performers: Vec, +} + +/// Lyrics performer +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Performer { + /// artist / unknown + #[serde(rename = "type")] + pub typ: Option, + /// Fully-qualified performer ID + pub fqid: Option, + /// Unbekannt + /// + /// 9 + pub category_id: Option, + /// Unbekannt + /// + /// 405 + pub credit_role_id: Option, +} + +/// Artists (and possibly other objects) that are referenced by the tagged parts +#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PerformerTaggingResources { + /// List of artists tagged as performers + pub artists: Vec, +} + /// Available track charts #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ChartName { diff --git a/tests/tests.rs b/tests/tests.rs index aa762f9..70d1fc8 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,7 +1,7 @@ use std::{ - num::NonZeroU32, path::{Path, PathBuf}, sync::LazyLock, + time::Duration, }; use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; @@ -21,8 +21,13 @@ fn testfile>(name: P) -> PathBuf { #[fixture] async fn mxm() -> Musixmatch { static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); - static MXM_LIMITER: LazyLock = - LazyLock::new(|| RateLimiter::direct(Quota::per_second(NonZeroU32::new(1).unwrap()))); + static MXM_LIMITER: LazyLock = LazyLock::new(|| { + RateLimiter::direct(if std::env::var("CI").is_ok() { + Quota::with_period(Duration::from_millis(2000)).unwrap() + } else { + Quota::with_period(Duration::from_millis(500)).unwrap() + }) + }); MXM_LIMITER.until_ready().await; @@ -47,18 +52,19 @@ mod album { #[rstest] #[case::id(AlbumId::AlbumId(14248253))] - #[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))] + // #[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))] #[tokio::test] async fn by_id(#[case] album_id: AlbumId<'_>, #[future] mxm: Musixmatch) { let album = mxm.await.album(album_id).await.unwrap(); + // dbg!(&album); assert_eq!(album.album_id, 14248253); - assert_eq!( - album.album_mbid.unwrap(), - "6c3cf9d8-88a8-43ed-850b-55813f01e451" - ); + // assert_eq!( + // album.album_mbid.expect("mbid"), + // "6c3cf9d8-88a8-43ed-850b-55813f01e451" + // ); assert_eq!(album.album_name, "Gangnam Style (๊ฐ•๋‚จ์Šคํƒ€์ผ)"); - assert!(album.album_rating > 20); + assert!(album.album_rating > 12); assert_eq!(album.album_track_count, 1); assert_eq!(album.album_release_date.unwrap(), date!(2012 - 01 - 01)); assert_eq!(album.album_release_type, AlbumType::Single); @@ -115,11 +121,12 @@ mod album { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] #[tokio::test] + #[ignore] async fn artist_albums(#[future] mxm: Musixmatch) { let albums = mxm .await @@ -139,7 +146,7 @@ mod album { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] @@ -156,7 +163,7 @@ mod artist { #[rstest] #[case::id(ArtistId::ArtistId(410698))] - #[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))] + // #[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))] #[tokio::test] async fn by_id(#[case] artist_id: ArtistId<'_>, #[future] mxm: Musixmatch) { let artist = mxm.await.artist(artist_id).await.unwrap(); @@ -165,7 +172,7 @@ mod artist { assert_eq!(artist.artist_id, 410698); assert_eq!( - artist.artist_mbid.unwrap(), + artist.artist_mbid.expect("mbid"), "f99b7d67-4e63-4678-aa66-4c6ac0f7d24a" ); assert_eq!(artist.artist_name, "PSY"); @@ -178,7 +185,11 @@ mod artist { artist.artist_name_translation_list ); assert_eq!(artist.artist_country.unwrap(), "KR"); - assert!(artist.artist_rating > 50); + assert!( + artist.artist_rating > 10, + "rating: {}", + artist.artist_rating + ); let first_genre = &artist.primary_genres.music_genre_list[0].music_genre; assert_eq!(first_genre.music_genre_id, 14); assert_eq!(first_genre.music_genre_parent_id, 34); @@ -199,6 +210,21 @@ mod artist { assert_eq!(artist.begin_date.unwrap(), date!(1977 - 12 - 31)); assert_eq!(artist.end_date_year, None); assert_eq!(artist.end_date, None); + let image = artist.artist_image.first().expect("artist image"); + assert_eq!(image.image_id, 20511); + let image_format = &image + .image_format_list + .iter() + .find(|img| img.image_format.height == 250 && img.image_format.width == 250) + .expect("image format 250px") + .image_format; + assert!( + image_format.image_url.starts_with( + "https://static.musixmatch.com/images-storage/mxmimages/1/1/5/0/2/20511_14.jpg?" + ), + "url: {}", + image_format.image_url + ); } #[rstest] @@ -210,31 +236,7 @@ mod artist { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); - } - - #[rstest] - #[tokio::test] - async fn related(#[future] mxm: Musixmatch) { - let artists = mxm - .await - .artist_related(ArtistId::ArtistId(26485840), 10, 1) - .await - .unwrap(); - - assert_eq!(artists.len(), 10); - } - - #[rstest] - #[tokio::test] - async fn related_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await - .artist_related(ArtistId::ArtistId(999999999999), 10, 1) - .await - .unwrap_err(); - - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] @@ -285,8 +287,10 @@ mod artist { } mod track { + use std::collections::HashMap; + use super::*; - use musixmatch_inofficial::models::{ChartName, SortOrder}; + use musixmatch_inofficial::models::{ChartName, MxmEntityType, SortOrder}; #[rstest] #[case::no_translation(false, false)] @@ -301,66 +305,65 @@ mod track { let track = mxm .await .matcher_track( - "Poker Face", - "Lady Gaga", - "The Fame", + "Du fehlst hier", + "Silbermond", + "Himmel auf", translation_status, lang_3c, + false, ) .await .unwrap(); // dbg!(&track); - assert_eq!(track.track_id, 85213841); - // assert_eq!( - // track.track_mbid.unwrap(), - // "080975b0-39b1-493c-ae64-5cb3292409bb" - // ); - // assert_eq!(track.track_isrc.unwrap(), "USUM70824409"); + assert_eq!(track.track_id, 17633259); + assert_eq!( + track.track_mbid.unwrap(), + "746af8c4-703e-4461-a40d-74ecdbcd755e" + ); + assert_eq!(track.track_isrc.unwrap(), "DEE861200095"); assert!( track.commontrack_isrcs[0] .iter() - .any(|isrc| isrc == "USUM70824409"), + .any(|isrc| isrc == "DEE861200095"), "commontrack_isrcs: {:?}", &track.commontrack_isrcs[0], ); - assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg"); + assert_eq!(track.track_spotify_id.unwrap(), "3wZwbYSozyMLnJJcT3e51Q"); assert!( track .commontrack_spotify_ids .iter() - .any(|spid| spid == "1QV6tiMFM6fSOKOGLMHYYg"), + .any(|spid| spid == "7lsi0kAFXHUCRjJzKlCZdA"), "commontrack_spotify_ids: {:?}", track.commontrack_spotify_ids, ); - assert_eq!(track.track_name, "Poker Face"); - assert!(track.track_rating > 50); - assert_eq!(track.commontrack_id, 47672612); + assert_eq!(track.track_name, "Du fehlst hier"); + assert!(track.track_rating > 25); + assert_eq!(track.commontrack_id, 10514065); assert!(!track.instrumental); - assert!(track.explicit); + assert!(!track.explicit); + assert!(track.has_lyrics); assert!(track.has_subtitles); - assert!(track.has_richsync); - assert!(track.has_track_structure); - assert!(track.num_favourite > 50); - assert!(track.lyrics_id.is_some()); - assert_eq!(track.subtitle_id.unwrap(), 36450705); - assert_eq!(track.album_id, 20960801); - assert_eq!(track.album_name, "The Fame"); - assert_eq!(track.artist_id, 378462); + assert_eq!(track.lyrics_id.unwrap(), 34727375); + assert_eq!(track.subtitle_id.unwrap(), 15014656); + assert_eq!(track.album_id, 14122870); + assert_eq!(track.album_name, "Himmel auf"); + assert_eq!(track.artist_id, 84849); assert_eq!( track.artist_mbid.unwrap(), - "650e7db6-b795-4eb5-a702-5ea2fc46c848" + "34d42823-6b56-4861-a675-1565bf40d557" ); - assert_eq!(track.artist_name, "Lady Gaga"); - assert_imgurl(&track.album_coverart_100x100, "/32133892.jpg"); - assert_imgurl(&track.album_coverart_350x350, "/32133892_350_350.jpg"); - assert_imgurl(&track.album_coverart_500x500, "/32133892_500_500.jpg"); - assert_imgurl(&track.album_coverart_800x800, "/32133892_800_800.jpg"); - assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1"); + assert_eq!(track.artist_name, "Silbermond"); + assert_imgurl(&track.album_coverart_100x100, "/30913494.jpg"); + assert_imgurl(&track.album_coverart_350x350, "/30913494_350_350.jpg"); + assert_imgurl(&track.album_coverart_500x500, "/30913494_500_500.jpg"); + assert_imgurl(&track.album_coverart_800x800, "/30913494_800_800.jpg"); + assert_eq!(track.commontrack_vanity_id, "Silbermond/Du-fehlst-hier"); let first_release = track.first_release_date.unwrap(); - assert_eq!(first_release.date(), date!(2008 - 1 - 1)); - assert!(track.updated_time > datetime!(2023-1-17 0:00 UTC)); + assert_eq!(first_release.date(), date!(2012 - 3 - 23)); + assert!(track.updated_time > datetime!(2024-2-22 11:00 UTC)); let first_pri_genre = &track.primary_genres.music_genre_list[0].music_genre; assert_eq!(first_pri_genre.music_genre_id, 14); @@ -369,23 +372,13 @@ mod track { assert_eq!(first_pri_genre.music_genre_name_extended, "Pop"); assert_eq!(first_pri_genre.music_genre_vanity.as_ref().unwrap(), "Pop"); - let first_sec_genre = &track.secondary_genres.music_genre_list[0].music_genre; - assert_eq!(first_sec_genre.music_genre_id, 17); - assert_eq!(first_sec_genre.music_genre_parent_id, 34); - assert_eq!(first_sec_genre.music_genre_name, "Dance"); - assert_eq!(first_sec_genre.music_genre_name_extended, "Dance"); - assert_eq!( - first_sec_genre.music_genre_vanity.as_ref().unwrap(), - "Dance" - ); - if translation_status { assert!( track.track_lyrics_translation_status.iter().all(|tl| { (if lang_3c { - tl.from.as_deref() == Some("eng") + tl.from.as_deref() == Some("deu") } else { - tl.from.as_deref() == Some("en") + tl.from.as_deref() == Some("de") }) && tl.perc >= 0.0 && tl.perc <= 1.0 }), @@ -398,48 +391,86 @@ mod track { } #[rstest] - #[case::trackid(TrackId::TrackId(205688271))] - #[case::commontrack(TrackId::Commontrack(118480583))] - #[case::vanity(TrackId::CommontrackVanity("aespa/Black-Mamba".into()))] - #[case::isrc(TrackId::Isrc("KRA302000590".into()))] - #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] + #[case::trackid(TrackId::TrackId(167254015))] + #[case::commontrack(TrackId::Commontrack(93933821))] + #[case::vanity(TrackId::CommontrackVanity("Nightbirde-2/Girl-in-a-Bubble".into()))] + #[case::isrc(TrackId::Isrc("QZDA41918667".into()))] + #[case::spotify(TrackId::Spotify("2roGy5AYlaJpmL9CuXj6tT".into()))] #[tokio::test] async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { - let track = mxm.await.track(track_id, true, false).await.unwrap(); + let track = mxm.await.track(track_id, true, false, false).await.unwrap(); // dbg!(&track); - assert_eq!(track.track_id, 205688271); - assert_eq!(track.track_isrc.unwrap(), "KRA302000590"); - assert_eq!(track.track_spotify_id.unwrap(), "1t2qYCAjUAoGfeFeoBlK51"); - assert_eq!(track.track_name, "Black Mamba"); - assert!(track.track_rating > 50); - assert_eq!(track.track_length, 175); + assert_eq!(track.track_id, 167254015); + assert_eq!(track.track_isrc.unwrap(), "QZDA41918667"); + assert_eq!(track.track_spotify_id.unwrap(), "2roGy5AYlaJpmL9CuXj6tT"); + assert_eq!(track.track_name, "Girl in a Bubble"); + assert!(track.track_rating > 40); + assert_eq!(track.track_length, 238); assert!(!track.explicit); assert!(track.has_lyrics); assert!(track.has_subtitles); - assert!(track.has_richsync); - assert!(track.num_favourite > 200); - assert!(track.lyrics_id.is_some()); - assert_eq!(track.subtitle_id.unwrap(), 36476905); - assert_eq!(track.album_id, 41035954); - assert_eq!(track.album_name, "Black Mamba"); - assert_eq!(track.artist_id, 46970441); - assert_eq!(track.artist_name, "aespa"); - assert_imgurl(&track.album_coverart_100x100, "/52156772.jpg"); - assert_imgurl(&track.album_coverart_350x350, "/52156772_350_350.jpg"); - assert_imgurl(&track.album_coverart_500x500, "/52156772_500_500.jpg"); - assert_eq!(track.commontrack_vanity_id, "aespa/Black-Mamba"); + assert!(track.num_favourite > 1); + assert_eq!(track.lyrics_id.unwrap(), 25830616); + assert_eq!(track.subtitle_id.unwrap(), 34307878); + assert_eq!(track.album_id, 31842378); + assert_eq!(track.album_name, "Girl in a Bubble"); + assert_eq!(track.artist_id, 38035381); + assert_eq!(track.artist_name, "Nightbirde"); + assert_imgurl(&track.album_coverart_100x100, "/43015335.jpg"); + assert_imgurl(&track.album_coverart_350x350, "/43015335_350_350.jpg"); + assert_imgurl(&track.album_coverart_500x500, "/43015335_500_500.jpg"); + assert_eq!(track.commontrack_vanity_id, "Nightbirde-2/Girl-in-a-Bubble"); let release_date = track.first_release_date.unwrap(); - assert_eq!(release_date.date(), date!(2020 - 11 - 17)); + assert_eq!(release_date.date(), date!(2019 - 03 - 22)); assert!(track.updated_time > datetime!(2022-8-27 0:00 UTC)); let first_tstatus = &track.track_lyrics_translation_status[0]; - assert_eq!(first_tstatus.from.as_deref(), Some("ko")); + assert_eq!(first_tstatus.from.as_deref(), Some("en")); assert!(first_tstatus.perc >= 0.0 && first_tstatus.perc <= 1.0); } + #[rstest] + #[tokio::test] + #[test_log::test] + async fn performer(#[future] mxm: Musixmatch) { + let track = mxm + .await + .track(TrackId::TrackId(206591653), false, false, true) + .await + .unwrap(); + let perf = track.performer_tagging.expect("performer tagging"); + assert!(perf.completed); + assert!(!perf.has_unknown); + assert!(!perf.has_fan_chant); + + let artists = perf + .resources + .artists + .into_iter() + .map(|a| (a.artist_id, a)) + .collect::>(); + + assert_eq!(artists.len(), 2); + let jhayco = &artists[&53077263]; + let bad_bunny = &artists[&33491954]; + assert_eq!(jhayco.artist_name, "Jhayco"); + assert_eq!(bad_bunny.artist_name, "Bad Bunny"); + assert_eq!(bad_bunny.artist_image.len(), 1); + + for part in perf.content { + assert!(!part.snippet.trim().is_empty(), "empty snippet"); + assert_gte(part.performers.len(), 1, "part performers"); + for performer in &part.performers { + let pid = performer.fqid.expect("performer id"); + assert_eq!(pid.typ, MxmEntityType::Artist); + assert!(artists.contains_key(&pid.id)) + } + } + } + #[rstest] #[case::no_translation(false, false)] #[case::translation_2c(true, false)] @@ -452,7 +483,12 @@ mod track { ) { let track = mxm .await - .track(TrackId::Commontrack(47672612), translation_status, lang_3c) + .track( + TrackId::Commontrack(47672612), + translation_status, + lang_3c, + false, + ) .await .unwrap(); @@ -548,11 +584,11 @@ mod track { async fn from_id_missing(#[future] mxm: Musixmatch) { let err = mxm .await - .track(TrackId::TrackId(999999999999), false, false) + .track(TrackId::TrackId(999999999999), false, false, false) .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] @@ -605,7 +641,7 @@ mod track { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] @@ -748,7 +784,7 @@ mod lyrics { // dbg!(&lyrics); - assert_eq!(lyrics.lyrics_id, 30126001); + assert_eq!(lyrics.lyrics_id, 36846057); assert_eq!(lyrics.lyrics_language.unwrap(), "ko"); assert_eq!(lyrics.lyrics_language_description.unwrap(), "Korean"); let copyright = lyrics.lyrics_copyright.unwrap(); @@ -785,11 +821,11 @@ mod lyrics { async fn missing(#[future] mxm: Musixmatch) { let err = mxm .await - .track_lyrics(TrackId::Spotify("674JwwTP7xCje81T0DRrLn".into())) + .track_lyrics(TrackId::Spotify("2gwMMr1a4aXXN5L6KC80Pu".into())) .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest] @@ -936,7 +972,7 @@ mod subtitles { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } /// This track has not been synced @@ -954,7 +990,7 @@ mod subtitles { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } /// Try to get subtitles with wrong length parameter @@ -972,7 +1008,7 @@ mod subtitles { .await .unwrap_err(); - assert!(matches!(err, Error::NotFound)); + assert!(matches!(err, Error::NotFound), "got: {err:?}"); } #[rstest]