diff --git a/.env.example b/.env.example index 7ab1bf9..c79ac20 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ +MUSIXMATCH_CLIENT=Android MUSIXMATCH_EMAIL=mail@example.com MUSIXMATCH_PASSWORD=super-secret diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml deleted file mode 100644 index d03eb50..0000000 --- a/.gitea/workflows/ci.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: CI -on: - push: - pull_request: - workflow_dispatch: - -jobs: - Test: - runs-on: cimaster-latest - services: - warpproxy: - image: thetadev256/warpproxy - env: - WARP_DEVICE_ID: ${{ secrets.WARP_DEVICE_ID }} - WARP_ACCESS_TOKEN: ${{ secrets.WARP_ACCESS_TOKEN }} - WARP_LICENSE_KEY: ${{ secrets.WARP_LICENSE_KEY }} - WARP_PRIVATE_KEY: ${{ secrets.WARP_PRIVATE_KEY }} - steps: - - name: 📦 Checkout repository - uses: actions/checkout@v3 - - name: 🦀 Setup Rust cache - uses: https://github.com/Swatinem/rust-cache@v2 - with: - cache-on-failure: "true" - - - name: 📎 Clippy - run: cargo clippy --all -- -D warnings - - - name: 🧪 Test - run: cargo test --workspace - env: - ALL_PROXY: "http://warpproxy:8124" diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml deleted file mode 100644 index dd48fde..0000000 --- a/.gitea/workflows/release.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Release -on: - push: - tags: - - "*/v*.*.*" - -jobs: - Release: - runs-on: cimaster-latest - steps: - - name: 📦 Checkout repository - uses: actions/checkout@v3 - - - name: Get variables - run: | - git fetch --tags --force #the checkout action does not load the tag message - - echo "CRATE=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==1{print}')" >> "$GITHUB_ENV" - echo "CRATE_VERSION=$(echo '${{ github.ref_name }}' | awk 'BEGIN{RS="/"} NR==2{print}')" >> "$GITHUB_ENV" - { - echo 'CHANGELOG<> "$GITHUB_ENV" - - - name: 📤 Publish crate on crates.io - run: cargo publish --token ${{ secrets.CARGO_TOKEN }} --package "${{ env.CRATE }}" - - - name: 🎉 Publish release - uses: https://gitea.com/actions/release-action@main - with: - title: "${{ env.CRATE }} ${{ env.CRATE_VERSION }}" - body: "${{ env.CHANGELOG }}" diff --git a/.gitea/workflows/renovate.yaml.bak b/.gitea/workflows/renovate.yaml.bak deleted file mode 100644 index 013b185..0000000 --- a/.gitea/workflows/renovate.yaml.bak +++ /dev/null @@ -1,63 +0,0 @@ -name: renovate - -on: - push: - branches: ["main"] - paths: - - ".forgejo/workflows/renovate.yaml" - - "renovate.json" - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -env: - RENOVATE_REPOSITORIES: ${{ github.repository }} - -jobs: - renovate: - runs-on: docker - container: - image: renovate/renovate:39 - - steps: - - name: Load renovate repo cache - uses: actions/cache/restore@v4 - with: - path: | - .tmp/cache/renovate/repository - .tmp/cache/renovate/renovate-cache-sqlite - .tmp/osv - key: repo-cache-${{ github.run_id }} - restore-keys: | - repo-cache- - - - name: Run renovate - run: renovate - env: - LOG_LEVEL: debug - RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp - RENOVATE_ENDPOINT: ${{ github.server_url }} - RENOVATE_PLATFORM: gitea - RENOVATE_REPOSITORY_CACHE: 'enabled' - RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }} - GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }} - RENOVATE_GIT_AUTHOR: 'Renovate Bot ' - - RENOVATE_X_SQLITE_PACKAGE_CACHE: true - - GIT_AUTHOR_NAME: 'Renovate Bot' - GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org' - GIT_COMMITTER_NAME: 'Renovate Bot' - GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org' - - OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv - - - name: Save renovate repo cache - if: always() && env.RENOVATE_DRY_RUN != 'full' - uses: actions/cache/save@v4 - with: - path: | - .tmp/cache/renovate/repository - .tmp/cache/renovate/renovate-cache-sqlite - .tmp/osv - key: repo-cache-${{ github.run_id }} diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..5661a72 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,15 @@ +steps: + test: + image: rust:latest + environment: + - CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + secrets: + - musixmatch_email + - musixmatch_password + commands: + - rustup component add rustfmt clippy + - cargo fmt --all --check + - cargo clippy --all -- -D warnings + - MUSIXMATCH_CLIENT=Desktop cargo test --workspace + - sleep 60 # because of Musixmatch rate limit + - MUSIXMATCH_CLIENT=Android cargo test --workspace diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 4f099be..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,75 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - - -## [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 - -- Add msrv - ([a95f3fc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a95f3fcf478f1acda9fad12741604b6793e128c1)) - -### 📚 Documentation - -- Update readme - ([348e9c5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/348e9c5427e59c488d7e2f7cef9e7006a12864f2)) - -### 🧪 Testing - -- Fix tests - ([d2a7aed](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/d2a7aed917bfcec75ce00bb49d380fbc31c47384)) -- Fix tests - ([c120583](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c120583bf861cc74fbce686b2bd88bc575270130)) -- Fix tests - ([c9fea76](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c9fea762ec97a1c594e60a3b1cbc72bb786d0957)) -- Add rate limiter - ([3b69b36](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/3b69b36ae6c945d786534e0eaa353fb737b1fb54)) - -### ⚙️ Miscellaneous Tasks - -- Update justfile - ([1bc5ae4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/1bc5ae408343e6755e390909e7017647efcf59a1)) -- Update dependencies - ([dcc25bf](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dcc25bff202becdec7101c5ce1825cd75e445f99)) -- Change repo to codeberg - ([30e2afd](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/30e2afd3679d2c17a49afd523c8b8bad70f291e5)) - -## [v0.1.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/commits/tag/musixmatch-inofficial/v0.1.0) - 2024-03-23 - -Initial release - - diff --git a/Cargo.toml b/Cargo.toml index c1071e0..03dd2da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,17 @@ [package] name = "musixmatch-inofficial" -version = "0.2.1" -rust-version = "1.70.0" -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -keywords.workspace = true +version = "0.1.0" +edition = "2021" +authors = ["ThetaDev "] +license = "MIT" description = "Inofficial client for the Musixmatch API" +keywords = ["music", "lyrics"] -include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"] +include = ["/src", "README.md", "LICENSE"] [workspace] members = [".", "cli"] -[workspace.package] -edition = "2021" -authors = ["ThetaDev "] -license = "MIT" -repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial" -keywords = ["music", "lyrics"] -categories = ["api-bindings", "multimedia"] - -[workspace.dependencies] -musixmatch-inofficial = { version = "0.2.0", path = ".", default-features = false } - [features] default = ["default-tls"] @@ -37,31 +24,36 @@ rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] [dependencies] -reqwest = { version = "0.12.0", default-features = false, features = [ +reqwest = { version = "0.11.11", default-features = false, features = [ "json", "gzip", ] } -tokio = { version = "1.20.4" } +url = "2.0.0" +tokio = { version = "1.20.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.85" -thiserror = "2.0.0" +thiserror = "1.0.36" log = "0.4.17" -time = { version = "0.3.10", features = [ +time = { version = "0.3.15", features = [ "macros", "formatting", "serde", "serde-well-known", ] } -hmac = "0.12.0" -sha1 = "0.10.0" -rand = "0.9.0" -base64 = "0.22.0" +hmac = "0.12.1" +sha1 = "0.10.5" +rand = "0.8.5" +base64 = "0.21.0" [dev-dependencies] -rstest = { version = "0.25.0", default-features = false } +ctor = "0.2.0" +rstest = { version = "0.18.0", default-features = false } +env_logger = "0.11.0" dotenvy = "0.15.5" -tokio = { version = "1.20.4", features = ["macros"] } +tokio = { version = "1.20.0", features = ["macros"] } futures = "0.3.21" path_macro = "1.0.0" -governor = "0.10.0" -test-log = "0.2.16" +serde_plain = "1.0.2" + +[profile.release] +strip = true diff --git a/Justfile b/Justfile deleted file mode 100644 index 7456066..0000000 --- a/Justfile +++ /dev/null @@ -1,44 +0,0 @@ -test: - cargo test - -release crate="musixmatch-inofficial": - #!/usr/bin/env bash - set -e - - CRATE="{{crate}}" - INCLUDES='--include-path README.md --include-path LICENSE --include-path Cargo.toml' - CHANGELOG="CHANGELOG.md" - - if [ "$CRATE" = "musixmatch-inofficial" ]; then - INCLUDES="$INCLUDES --include-path 'src/**' --include-path 'tests/**' --include-path 'testfiles/**'" - else - if [ ! -d "$CRATE" ]; then - echo "$CRATE does not exist."; exit 1 - fi - INCLUDES="$INCLUDES --include-path '$CRATE/**'" - CHANGELOG="$CRATE/$CHANGELOG" - CRATE="musixmatch-$CRATE" # Add crate name prefix - fi - - VERSION=$(cargo pkgid --package "$CRATE" | tr '#@' '\n' | tail -n 1) - TAG="${CRATE}/v${VERSION}" - echo "Releasing $TAG:" - - if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi - - CLIFF_ARGS="--tag '${TAG}' --tag-pattern '${CRATE}/v*' --unreleased $INCLUDES" - echo "git-cliff $CLIFF_ARGS" - if [ -f "$CHANGELOG" ]; then - eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'" - else - eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'" - fi - - editor "$CHANGELOG" - - git add . - git commit -m "chore(release): release $CRATE v$VERSION" - - awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG" - - echo "🚀 Run 'git push origin $TAG' to publish" diff --git a/README.md b/README.md index cd5c568..a4a99ba 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,16 @@ -# musixmatch-inofficial - -[![Current crates.io version](https://img.shields.io/crates/v/musixmatch-inofficial.svg)](https://crates.io/crates/musixmatch-inofficial) -[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT) -[![CI status](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml) +# Musixmatch-Inofficial This is an inofficial client for the Musixmatch API that uses the key embedded in the -Musixmatch Android app. +Musixmatch Android app or desktop client. It allows you to obtain synchronized lyrics in different formats ([LRC](), [DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song. -The Musixmatch API used to require a free account on to be -used. However, as of 2024, this requirement was removed and the API can be used -anonymously. The client still allows you to supply credentials if Musixmatch decides to -close the API down again. +If you use the Android client, you need a free Musixmatch account +([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can +be used anonymously and is currently the default option, but since Musixmatch +discontinued that application, they may shut it down. ## ⚠️ Copyright disclaimer @@ -31,8 +27,12 @@ their [commercial plans](https://developer.musixmatch.com/plans)) and use their ## Development info -The test suite reads Musixmatch credentials from the `MUSIXMATCH_EMAIL` and -`MUSIXMATCH_PASSWORD` environment variables. +You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment +variable to either `Desktop` or `Android` (it defaults to Desktop). + +Running the tests for the Android client requires Musixmatch credentials. The +credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment +variables. To make local development easier, I have included `dotenvy` to read the credentials from an `.env` file. Copy the `.env.example` file in the root directory, rename it to `.env` diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md deleted file mode 100644 index ac7b1a4..0000000 --- a/cli/CHANGELOG.md +++ /dev/null @@ -1,58 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - - -## [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 - -- Add format option to mp3 subtitles cmd - ([19e209e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/19e209e34f4d129a4223930bfd41e1ccf117f231)) -- Add get album, get artist, search artist - ([c4bfbe5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c4bfbe563a00d399b3645dd68f03c1215ee51fdb)) -- [**breaking**] Remove MP3 feature, refactor cmd structure - ([54235e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/54235e6fb61084823a6583aaa7d59b1799deb07f)) -- Add msrv - ([a95f3fc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a95f3fcf478f1acda9fad12741604b6793e128c1)) - -### 🐛 Bug Fixes - -- Use native TLS for CLI - ([dc1bea1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dc1bea13cc2a37eae7f3727dc72f865a01430a2e)) - -### 📚 Documentation - -- Update readme - ([348e9c5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/348e9c5427e59c488d7e2f7cef9e7006a12864f2)) - -### 🧪 Testing - -- Fix tests - ([d2a7aed](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/d2a7aed917bfcec75ce00bb49d380fbc31c47384)) -- Add rate limiter - ([3b69b36](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/3b69b36ae6c945d786534e0eaa353fb737b1fb54)) - -### ⚙️ Miscellaneous Tasks - -- Fix changelogs - ([e72d2b4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e72d2b4363a3a9a48dec8f2be9389f6cc239035c)) -- Update justfile - ([1bc5ae4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/1bc5ae408343e6755e390909e7017647efcf59a1)) -- Update dependencies - ([dcc25bf](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dcc25bff202becdec7101c5ce1825cd75e445f99)) -- Change repo to codeberg - ([30e2afd](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/30e2afd3679d2c17a49afd523c8b8bad70f291e5)) - -## [v0.1.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/commits/tag/musixmatch-cli/v0.1.0) - 2024-03-23 - -Initial release - - diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6228dda..be21b09 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,16 +1,14 @@ [package] name = "musixmatch-cli" -version = "0.3.0" -rust-version = "1.70.0" -edition.workspace = true -authors.workspace = true -license.workspace = true -repository.workspace = true -keywords.workspace = true +version = "0.1.0" +edition = "2021" +authors = ["ThetaDev"] +license = "MIT" description = "Inofficial command line interface for the Musixmatch API" +keywords = ["music", "lyrics", "cli"] [features] -default = ["native-tls"] +default = ["rustls-tls-native-roots"] # Reqwest TLS options native-tls = ["musixmatch-inofficial/native-tls"] @@ -20,10 +18,12 @@ rustls-tls-webpki-roots = ["musixmatch-inofficial/rustls-tls-webpki-roots"] rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"] [dependencies] -musixmatch-inofficial.workspace = true -tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] } -clap = { version = "4.0.0", features = ["derive"] } -anyhow = "1.0.0" +musixmatch-inofficial = { path = "../" } +tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] } +id3 = "1.3.0" +mp3-duration = "0.1.10" +clap = { version = "4.0.10", features = ["derive"] } +anyhow = "1.0.65" rpassword = "7.0.0" -dirs = "6.0.0" -serde_json = "1.0.85" +dirs = "5.0.0" +serde_json = "1.0.91" diff --git a/cli/README.md b/cli/README.md deleted file mode 100644 index c015def..0000000 --- a/cli/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# musixmatch-cli - -[![Current crates.io version](https://img.shields.io/crates/v/musixmatch-cli.svg)](https://crates.io/crates/musixmatch-cli) -[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT) -[![CI status](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml) - -The Musixmatch CLI allows you to fetch lyrics, subtitles and track metadata from the -command line using the Musixmatch API. - -The Musixmatch API used to require a free account on to be -used. However, as of 2024, this requirement was removed and the API can be used -anonymously. The CLI still allows you to supply credentials if Musixmatch decides to -close the API down again. - -### Get lyrics - -```txt -musixmatch-cli lyrics -n shine -a spektrem -Lyrics ID: 34583240 -Language: en -Copyright: Writer(s): Jesse Warren -Copyright: Ncs Music - -Eyes in the sky gazing far into the night -I raise my hand to the fire, but it's no use -'Cause you can't stop it from shining through -It's true -... -``` - -### Get translated lyrics - -Musixmatch also offers translated lyrics. You have to select a language using the -`--lang` flag. You can also set the `--bi` flag to output both the original and -translated lines. - -```txt -musixmatch-cli lyrics -n shine -a spektrem --lang de --bi -Lyrics ID: 34583240 -Language: en -Copyright: Writer(s): Jesse Warren -Copyright: Ncs Music -Translated to: de - -Eyes in the sky gazing far into the night -> Augen starren in die weite Nacht -I raise my hand to the fire, but it's no use -> Ich hebe meine Hand in das Feuer, doch ihr geschieht nichts -'Cause you can't stop it from shining through -> Denn du kannst es nicht daran hindern, hindurch zu scheinen -It's true -> Es ist wahr -... -``` - -### Get subtitles (synchronized lyrics) - -For most lyrics Musixmatch provides timestamps for the individual lines so you can -display them in sync during playback. - -Musixmatch offers multiple subtitle formats you can select using the `--format` flag. -The available formats are: `lrc`, `ttml`, `ttml-structured`, `json`, `ebu-stl` - -```txt -musixmatch-cli subtitles -n shine -a spektrem -Subtitle ID: 35340319 -Language: en -Length: 316 -Copyright: Writer(s): Jesse Warren -Copyright: Ncs Music - -[00:59.84] Eyes in the sky gazing far into the night -[01:06.55] I raise my hand to the fire, but it's no use -[01:11.97] 'Cause you can't stop it from shining through -[01:16.07] It's true -... -``` diff --git a/cli/src/main.rs b/cli/src/main.rs index c10e2a1..b80813f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,6 +1,3 @@ -#![doc = include_str!("../README.md")] -#![warn(missing_docs, clippy::todo)] - use std::{ io::{stdin, stdout, Write}, path::PathBuf, @@ -8,8 +5,9 @@ use std::{ use anyhow::{anyhow, bail, Result}; use clap::{Args, Parser, Subcommand}; +use id3::{Tag, TagLike}; use musixmatch_inofficial::{ - models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap}, + models::{SubtitleFormat, Track, TrackId, TranslationMap}, Musixmatch, }; @@ -22,7 +20,18 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Get lyrics text + Get { + #[command(subcommand)] + command: GetCommands, + }, + Mp3 { + #[command(subcommand)] + command: FileCommands, + }, +} + +#[derive(Subcommand)] +enum GetCommands { Lyrics { #[clap(flatten)] ident: TrackIdentifiers, @@ -33,60 +42,26 @@ enum Commands { #[clap(long)] bi: bool, }, - /// Get subtitles (time-synced lyrics) Subtitles { #[clap(flatten)] ident: TrackIdentifiers, /// Track length - #[clap(short, long)] + #[clap(long, short)] length: Option, /// Maximum deviation from track length (Default: 1s) #[clap(long)] max_deviation: Option, /// Subtitle format - #[clap(short, long, default_value = "lrc")] + #[clap(long, default_value = "lrc")] format: SubtitleFormatClap, /// Language #[clap(long)] lang: Option, }, - /// Get track metadata Track { #[clap(flatten)] ident: TrackIdentifiers, }, - /// Get performer tagging - Performer { - #[clap(flatten)] - ident: TrackIdentifiers, - }, - /// Get album metadata - Album { - #[clap(flatten)] - ident: AlbumArtistIdentifiers, - }, - /// Get artist metadata - Artist { - #[clap(flatten)] - ident: AlbumArtistIdentifiers, - }, - /// Search for Musixmatch tracks - #[group(required = true)] - Search { - /// Track name - #[clap(short, long)] - name: Option, - /// Artist - #[clap(short, long)] - artist: Option, - /// Lyrics - #[clap(short, long)] - lyrics: Option, - /// Search query - query: Option>, - }, - /// Search for Musixmatch artists - SearchArtist { query: Vec }, } #[derive(Args)] @@ -117,38 +92,22 @@ struct TrackIdentifiers { isrc: Option, } -#[derive(Args)] -#[group(multiple = false)] -struct AlbumArtistIdentifiers { - /// Musixmatch-ID - #[clap(long)] - mxm_id: Option, - /// Musicbrainz-ID - #[clap(long)] - musicbrainz: Option, -} - #[derive(Subcommand)] enum FileCommands { - /// Get lyrics text Lyrics { /// Music file #[clap(value_parser)] file: PathBuf, }, - /// Get subtitles (time-synced lyrics) Subtitles { /// Music file #[clap(value_parser)] file: PathBuf, - /// Subtitle format - #[clap(short, long, default_value = "lrc")] - format: SubtitleFormatClap, }, } #[derive(clap::ValueEnum, Debug, Copy, Clone)] -enum SubtitleFormatClap { +pub enum SubtitleFormatClap { Lrc, Ttml, TtmlStructured, @@ -202,200 +161,164 @@ async fn run(cli: Cli) -> Result<()> { }; match cli.command { - Commands::Lyrics { ident, lang, bi } => { - let track_id = get_track_id(ident, &mxm).await?; - let lyrics = mxm.track_lyrics(track_id.clone()).await?; + Commands::Get { command } => match command { + GetCommands::Lyrics { ident, lang, bi } => { + let track_id = get_track_id(ident, &mxm).await?; + let lyrics = mxm.track_lyrics(track_id.clone()).await?; - eprintln!("Lyrics ID: {}", lyrics.lyrics_id); - eprintln!( - "Language: {}", - lyrics.lyrics_language.as_deref().unwrap_or(NA_STR) - ); - eprintln!( - "Copyright: {}", - lyrics - .lyrics_copyright - .as_deref() - .map(|c| c.trim()) - .unwrap_or(NA_STR) - ); + eprintln!("Lyrics ID: {}", lyrics.lyrics_id); + eprintln!( + "Language: {}", + lyrics.lyrics_language.as_deref().unwrap_or(NA_STR) + ); + eprintln!( + "Copyright: {}", + lyrics + .lyrics_copyright + .as_deref() + .map(|c| c.trim()) + .unwrap_or(NA_STR) + ); - let mut lyrics_body = lyrics.lyrics_body; + let mut lyrics_body = lyrics.lyrics_body; - if let Some(lang) = lang { - if Some(&lang) != lyrics.lyrics_language.as_ref() { - let tl = mxm.track_lyrics_translation(track_id, &lang).await?; - if tl.is_empty() { - eprintln!("Translation not found. Returning lyrics in original language."); - } 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() + if let Some(lang) = lang { + if Some(&lang) != lyrics.lyrics_language.as_ref() { + let tl = mxm.track_lyrics_translation(track_id, &lang).await?; + if tl.is_empty() { + eprintln!( + "Translation not found. Returning lyrics in original language." + ); } 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 + }; + } } } + + eprintln!(); + println!("{}", lyrics_body); + } + GetCommands::Subtitles { + ident, + length, + max_deviation, + format, + lang, + } => { + let track_id = get_track_id(ident, &mxm).await?; + let subtitles = mxm + .track_subtitle( + track_id.clone(), + if lang.is_some() { + SubtitleFormat::Json + } else { + format.into() + }, + length, + max_deviation.or(Some(1.0)), + ) + .await?; + + eprintln!("Subtitle ID: {}", subtitles.subtitle_id); + eprintln!( + "Language: {}", + subtitles.subtitle_language.as_deref().unwrap_or(NA_STR) + ); + eprintln!("Length: {}", subtitles.subtitle_length); + eprintln!( + "Copyright: {}", + subtitles + .lyrics_copyright + .as_deref() + .map(|s| s.trim()) + .unwrap_or(NA_STR) + ); + + if let Some(lang) = lang { + let mut lines = subtitles.to_lines()?; + + if Some(&lang) != subtitles.subtitle_language.as_ref() { + let tl = mxm.track_lyrics_translation(track_id, &lang).await?; + if tl.is_empty() { + bail!("Translation not found") + } else { + eprintln!("Translated to: {}", tl.lang); + let tm = TranslationMap::from(tl); + lines = tm.translate_subtitles(&lines); + } + } + + eprintln!(); + let res = match format { + SubtitleFormatClap::Lrc => lines.to_lrc(), + SubtitleFormatClap::Ttml => lines.to_ttml(), + SubtitleFormatClap::Json => lines.to_json()?, + SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => { + bail!("subtitle format {format:?} cant be translated") + } + }; + println!("{}", res); + } else { + eprintln!(); + println!("{}", subtitles.subtitle_body); + } } + GetCommands::Track { ident } => { + let track = get_track(ident, &mxm).await?; + println!("{}", serde_json::to_string_pretty(&track)?) + } + }, + Commands::Mp3 { command } => match command { + FileCommands::Lyrics { file } => { + let tag = Tag::read_from_path(&file)?; - eprintln!(); - println!("{}", lyrics_body); - } - Commands::Subtitles { - ident, - length, - max_deviation, - format, - lang, - } => { - let track_id = get_track_id(ident, &mxm).await?; - let subtitles = mxm - .track_subtitle( - track_id.clone(), - if lang.is_some() { - SubtitleFormat::Json - } else { - format.into() - }, - length, - max_deviation.or(Some(1.0)), - ) - .await?; + let title = tag.title().ok_or(anyhow!("no title"))?; + let artist = tag.artist().ok_or(anyhow!("no artist"))?; - eprintln!("Subtitle ID: {}", subtitles.subtitle_id); - eprintln!( - "Language: {}", - subtitles.subtitle_language.as_deref().unwrap_or(NA_STR) - ); - eprintln!("Length: {}", subtitles.subtitle_length); - eprintln!( - "Copyright: {}", - subtitles - .lyrics_copyright - .as_deref() - .map(|s| s.trim()) - .unwrap_or(NA_STR) - ); + let lyrics = mxm.matcher_lyrics(title, artist).await?; - if let Some(lang) = lang { - let mut lines = subtitles.to_lines()?; + println!( + "Lyrics for {} by {}:\n\n{}", + title, artist, lyrics.lyrics_body + ); + } + FileCommands::Subtitles { file } => { + let tag = Tag::read_from_path(&file)?; + let duration = mp3_duration::from_path(&file)?; - if Some(&lang) != subtitles.subtitle_language.as_ref() { - let tl = mxm.track_lyrics_translation(track_id, &lang).await?; - if tl.is_empty() { - bail!("Translation not found") - } else { - eprintln!("Translated to: {}", tl.lang); - let tm = TranslationMap::from(tl); - lines = tm.translate_subtitles(&lines); - } - } + let title = tag.title().ok_or(anyhow!("no title"))?; + let artist = tag.artist().ok_or(anyhow!("no artist"))?; + + let subtitles = mxm + .matcher_subtitle( + title, + artist, + SubtitleFormat::Lrc, + Some(duration.as_secs_f32()), + Some(1.0), + ) + .await?; - eprintln!(); - let res = match format { - SubtitleFormatClap::Lrc => lines.to_lrc(), - SubtitleFormatClap::Ttml => lines.to_ttml(), - SubtitleFormatClap::Json => lines.to_json()?, - SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => { - bail!("subtitle format {format:?} cant be translated") - } - }; - println!("{}", res); - } else { - eprintln!(); println!("{}", subtitles.subtitle_body); } - } - Commands::Track { ident } => { - 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) - } else if let Some(mb) = &ident.musicbrainz { - AlbumId::Musicbrainz(mb) - } else { - bail!("no album ID specified") - }; - let album = mxm.album(id).await?; - println!("{}", serde_json::to_string_pretty(&album)?) - } - Commands::Artist { ident } => { - let id = if let Some(id) = ident.mxm_id { - ArtistId::ArtistId(id) - } else if let Some(mb) = &ident.musicbrainz { - ArtistId::Musicbrainz(mb) - } else { - bail!("no artist ID specified") - }; - let album = mxm.artist(id).await?; - println!("{}", serde_json::to_string_pretty(&album)?) - } - Commands::Search { - query, - name, - artist, - lyrics, - } => { - let mut sb = mxm - .track_search() - .s_track_rating(musixmatch_inofficial::models::SortOrder::Desc); - let querystr; - if let Some(q) = &query { - querystr = q.join(" "); - sb = sb.q(&querystr); - } - if let Some(n) = &name { - sb = sb.q_track(n); - } - if let Some(a) = &artist { - sb = sb.q_artist(a); - } - if let Some(l) = &lyrics { - sb = sb.q_lyrics(l); - } - - let tracks = sb.send(20, 0).await?; - for t in tracks { - println!( - "{} - {} ({}) ISRC'{}' ", - t.track_name, - t.artist_name, - t.first_release_date.map(|d| d.year()).unwrap_or_default(), - t.track_isrc.unwrap_or_default(), - t.commontrack_vanity_id - ); - } - } - Commands::SearchArtist { query } => { - let artists = mxm.artist_search(&query.join(" "), 20, 0).await?; - for a in artists { - println!( - "{} ", - a.artist_name, a.artist_vanity_id - ); - } - } + }, }; Ok(()) } @@ -409,7 +332,6 @@ async fn get_track_or_id( ident: TrackIdentifiers, mxm: &Musixmatch, translation_status: bool, - performer_tagging: bool, ) -> Result> { Ok( match ( @@ -435,15 +357,8 @@ 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, - performer_tagging, - ) - .await?, + mxm.matcher_track(&name, &artist, "", translation_status, true) + .await?, )), _ => bail!("no track identifier given"), }, @@ -451,23 +366,17 @@ 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, false).await? { + Ok(match get_track_or_id(ident, mxm, false).await? { TrackOrId::Track(track) => TrackId::TrackId(track.track_id), TrackOrId::TrackId(id) => id, }) } -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?, - }, - ) +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?, + }) } fn input(prompt: &str) -> String { diff --git a/cliff.toml b/cliff.toml deleted file mode 100644 index b456d2d..0000000 --- a/cliff.toml +++ /dev/null @@ -1,100 +0,0 @@ -# git-cliff ~ default configuration file -# https://git-cliff.org/docs/configuration -# -# Lines starting with "#" are comments. -# Configuration options are organized into tables and keys. -# See documentation for more information on available options. - -[changelog] -# changelog header -header = """ -# Changelog\n -All notable changes to this project will be documented in this file.\n -""" -# template for the changelog body -# https://keats.github.io/tera/docs/#introduction -body = """ -{% set repo_url = "https://codeberg.org/ThetaDev/musixmatch-inofficial" %}\ -{% if version %}\ - {%set vname = version | split(pat="/") | last %} - {%if previous.version %}\ - ## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\ - {% else %}\ - ## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\ - {% endif %} - {{ timestamp | date(format="%Y-%m-%d") }} -{% else %}\ - ## [unreleased] -{% endif %}\ -{% if previous.version %}\ -{% for group, commits in commits | group_by(attribute="group") %} - ### {{ group | striptags | trim | upper_first }} - {% for commit in commits %} - - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ - {% if commit.breaking %}[**breaking**] {% endif %}\ - {{ commit.message | upper_first }} - \ - ([{{ commit.id | truncate(length=7, end="") }}]({{ repo_url }}/commit/{{ commit.id }}))\ - {% endfor %} -{% endfor %}\ -{% else %} -Initial release -{% endif %}\n -""" -# template for the changelog footer -footer = """ - -""" -# remove the leading and trailing s -trim = true -# postprocessors -postprocessors = [ - # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL -] - -[git] -# parse the commits based on https://www.conventionalcommits.org -conventional_commits = true -# filter out the commits that are not conventional -filter_unconventional = true -# process each line of a commit as an individual commit -split_commits = false -# regex for preprocessing the commit messages -commit_preprocessors = [ - # Replace issue numbers - #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, - # Check spelling of the commit with https://github.com/crate-ci/typos - # If the spelling is incorrect, it will be automatically fixed. - #{ pattern = '.*', replace_command = 'typos --write-changes -' }, -] -# regex for parsing and grouping commits -commit_parsers = [ - { message = "^feat", group = "🚀 Features" }, - { message = "^fix", group = "🐛 Bug Fixes" }, - { message = "^doc", group = "📚 Documentation" }, - { message = "^perf", group = "⚡ Performance" }, - { message = "^refactor", group = "🚜 Refactor" }, - { message = "^style", group = "🎨 Styling" }, - { message = "^test", skip = true }, - { message = "^chore\\(release\\)", skip = true }, - { message = "^chore\\(pr\\)", skip = true }, - { message = "^chore\\(pull\\)", skip = true }, - { message = "^chore", group = "⚙️ Miscellaneous Tasks" }, - { message = "^ci", skip = true }, - { body = ".*security", group = "🛡️ Security" }, - { message = "^revert", group = "◀️ Revert" }, -] -# protect breaking changes from being skipped due to matching a skipping commit_parser -protect_breaking_commits = false -# filter out the commits that are not matched by commit parsers -filter_commits = false -# regex for matching git tags -# tag_pattern = "v[0-9].*" -# regex for skipping tags -# skip_tags = "" -# regex for ignoring tags -# ignore_tags = "" -# sort the tags topologically -topo_order = false -# sort the commits inside sections by oldest/newest order -sort_commits = "oldest" -# limit the number of commits included in the changelog. -# limit_commits = 42 diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 1ec2687..0000000 --- a/renovate.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:best-practices", ":preserveSemverRanges"], - "semanticCommits": "enabled", - "automerge": true, - "automergeStrategy": "squash", - "osvVulnerabilityAlerts": true, - "labels": ["dependency-upgrade"], - "enabledManagers": ["cargo"], - "prHourlyLimit": 5 -} diff --git a/src/api_model.rs b/src/api_model.rs index 1eb4760..378daad 100644 --- a/src/api_model.rs +++ b/src/api_model.rs @@ -1,9 +1,6 @@ use std::{marker::PhantomData, str::FromStr}; -use serde::{ - de::{DeserializeOwned, Visitor}, - Deserialize, Deserializer, Serialize, -}; +use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; use time::OffsetDateTime; use crate::error::{Error, Result as MxmResult}; @@ -12,17 +9,24 @@ use crate::error::{Error, Result as MxmResult}; #[derive(Debug, Deserialize)] pub struct Resp { - pub message: T, + pub message: Message, } #[derive(Debug, Deserialize)] -pub struct HeaderMsg { +pub struct Message { pub header: Header, + pub body: Option>, } #[derive(Debug, Deserialize)] -pub struct BodyMsg { - pub body: T, +#[serde(untagged)] +pub enum MessageBody { + Some(T), + // "body": [] + EmptyArr(Vec<()>), + // "body": {} + EmptyObj {}, + EmptyStr(String), } #[derive(Debug, Deserialize)] @@ -33,24 +37,30 @@ pub struct Header { pub hint: String, } -pub fn parse_body(response: &str) -> MxmResult { - let header = serde_json::from_str::>(response)? - .message - .header; - if header.status_code < 400 { - let body = serde_json::from_str::>>(response)?; - Ok(body.message.body) - } else if header.status_code == 404 { - Err(Error::NotFound) - } else if header.status_code == 401 && header.hint == "renew" { - Err(Error::TokenExpired) - } else if header.status_code == 401 && header.hint == "captcha" { - Err(Error::Ratelimit) - } else { - Err(Error::MusixmatchError { - status_code: header.status_code, - msg: header.hint, - }) +impl Resp { + pub fn body_or_err(self) -> MxmResult { + match (self.message.body, self.message.header.status_code < 400) { + (Some(MessageBody::Some(body)), true) => Ok(body), + (_, true) => Err(Error::NoData), + (_, false) => { + if self.message.header.status_code == 404 { + Err(Error::NotFound) + } else if self.message.header.status_code == 401 + && self.message.header.hint == "renew" + { + Err(Error::TokenExpired) + } else if self.message.header.status_code == 401 + && self.message.header.hint == "captcha" + { + Err(Error::Ratelimit) + } else { + Err(Error::MusixmatchError { + status_code: self.message.header.status_code, + msg: self.message.header.hint, + }) + } + } + } } } @@ -99,8 +109,8 @@ pub enum LoginCredential { #[derive(Debug, Deserialize)] pub struct Account { - // pub id: String, - // pub email: String, + pub id: String, + pub email: String, pub name: String, } @@ -117,7 +127,7 @@ where { struct BoolFromIntVisitor; - impl Visitor<'_> for BoolFromIntVisitor { + impl<'de> Visitor<'de> for BoolFromIntVisitor { type Value = bool; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -232,7 +242,7 @@ where n: PhantomData, } - impl Visitor<'_> for NullIfZeroVisitor + impl<'de, N> Visitor<'de> for NullIfZeroVisitor where N: TryFrom, { @@ -300,7 +310,7 @@ where { struct NullIfEmptyVisitor; - impl Visitor<'_> for NullIfEmptyVisitor { + impl<'de> Visitor<'de> for NullIfEmptyVisitor { type Value = Option; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { @@ -347,7 +357,7 @@ where n: PhantomData, } - impl Visitor<'_> for ParseIntVisitor + impl<'de, N> Visitor<'de> for ParseIntVisitor where N: FromStr + TryFrom, { @@ -441,7 +451,7 @@ pub mod optional_date { ) -> Result, D::Error> { struct OptionalDateVisitor; - impl Visitor<'_> for OptionalDateVisitor { + impl<'de> Visitor<'de> for OptionalDateVisitor { type Value = Option; fn expecting( @@ -501,7 +511,7 @@ pub mod optional_datetime { ) -> Result, D::Error> { struct OptionalDateVisitor; - impl Visitor<'_> for OptionalDateVisitor { + impl<'de> Visitor<'de> for OptionalDateVisitor { type Value = Option; fn expecting( @@ -543,55 +553,6 @@ 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; @@ -604,22 +565,57 @@ mod tests { let json = r#"{"message":{"header":{"status_code":401,"execute_time":0.002,"hint":"fsck"}}}"#; - let err = parse_body::(json).unwrap_err(); + let res = serde_json::from_str::>(json).unwrap(); - if let Error::MusixmatchError { status_code, msg } = err { - assert_eq!(status_code, 401); - assert_eq!(msg, "fsck"); - } else { - panic!("invalid error: {err}"); - } + assert_eq!(res.message.header.status_code, 401); + assert_eq!(res.message.header.hint, "fsck"); + assert!(res.message.body.is_none()); + + let err = res.body_or_err().unwrap_err(); + assert_eq!( + err.to_string(), + "Error 401 returned by the Musixmatch API. Message: 'fsck'" + ); } #[test] - fn deserialize_body() { - let json = r#"{"message":{"header":{"status_code":200,"execute_time":0.002},"body":"Hello World"}}"#; + fn deserialize_emptyarr_body() { + let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":[]}}"#; - let res = parse_body::(json).unwrap(); - assert_eq!(res, "Hello World"); + let res = serde_json::from_str::>(json).unwrap(); + + assert_eq!(res.message.header.status_code, 403); + assert_eq!(res.message.header.hint, ""); + assert!(matches!( + res.message.body.as_ref().unwrap(), + MessageBody::EmptyArr(_) + )); + + let err = res.body_or_err().unwrap_err(); + assert_eq!( + err.to_string(), + "Error 403 returned by the Musixmatch API. Message: ''" + ); + } + + #[test] + fn deserialize_emptyobj_body() { + let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":{}}}"#; + + let res = serde_json::from_str::>(json).unwrap(); + + assert_eq!(res.message.header.status_code, 403); + assert_eq!(res.message.header.hint, ""); + assert!(matches!( + res.message.body.as_ref().unwrap(), + MessageBody::EmptyObj {} + )); + + let err = res.body_or_err().unwrap_err(); + assert_eq!( + err.to_string(), + "Error 403 returned by the Musixmatch API. Message: ''" + ); } #[test] @@ -735,21 +731,4 @@ 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/album_api.rs b/src/apis/album_api.rs index c95fead..04d6c43 100644 --- a/src/apis/album_api.rs +++ b/src/apis/album_api.rs @@ -12,7 +12,7 @@ impl Musixmatch { /// # Reference /// pub async fn album(&self, id: AlbumId<'_>) -> Result { - let mut url = self.new_url("album.get"); + let mut url = self.new_url("album.get")?; { let mut url_query = url.query_pairs_mut(); @@ -43,7 +43,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("artist.albums.get"); + let mut url = self.new_url("artist.albums.get")?; { let mut url_query = url.query_pairs_mut(); @@ -80,7 +80,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("chart.albums.get"); + let mut url = self.new_url("chart.albums.get")?; { let mut url_query = url.query_pairs_mut(); diff --git a/src/apis/artist_api.rs b/src/apis/artist_api.rs index 1581a5f..8b8d37b 100644 --- a/src/apis/artist_api.rs +++ b/src/apis/artist_api.rs @@ -12,13 +12,12 @@ impl Musixmatch { /// # Reference /// pub async fn artist(&self, id: ArtistId<'_>) -> Result { - let mut url = self.new_url("artist.get"); + let mut url = self.new_url("artist.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("part", "artist_image"); url_query.finish(); } @@ -41,7 +40,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("artist.related.get"); + let mut url = self.new_url("artist.related.get")?; { let mut url_query = url.query_pairs_mut(); @@ -75,7 +74,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("artist.search"); + let mut url = self.new_url("artist.search")?; { let mut url_query = url.query_pairs_mut(); @@ -108,7 +107,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("chart.artists.get"); + let mut url = self.new_url("chart.artists.get")?; { let mut url_query = url.query_pairs_mut(); diff --git a/src/apis/lyrics_api.rs b/src/apis/lyrics_api.rs index d9a5038..b3a087c 100644 --- a/src/apis/lyrics_api.rs +++ b/src/apis/lyrics_api.rs @@ -14,7 +14,7 @@ impl Musixmatch { /// # Reference /// pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result { - let mut url = self.new_url("matcher.lyrics.get"); + let mut url = self.new_url("matcher.lyrics.get")?; { let mut url_query = url.query_pairs_mut(); if !q_track.is_empty() { @@ -39,7 +39,7 @@ impl Musixmatch { /// # Reference /// pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result { - let mut url = self.new_url("track.lyrics.get"); + let mut url = self.new_url("track.lyrics.get")?; { let mut url_query = url.query_pairs_mut(); let id_param = id.to_param(); @@ -66,7 +66,7 @@ impl Musixmatch { id: TrackId<'_>, selected_language: &str, ) -> Result { - let mut url = self.new_url("crowd.track.translations.get"); + let mut url = self.new_url("crowd.track.translations.get")?; { let mut url_query = url.query_pairs_mut(); let id_param = id.to_param(); diff --git a/src/apis/snippet_api.rs b/src/apis/snippet_api.rs index c8b3591..01fc50d 100644 --- a/src/apis/snippet_api.rs +++ b/src/apis/snippet_api.rs @@ -16,7 +16,7 @@ impl Musixmatch { /// # Reference /// pub async fn track_snippet(&self, id: TrackId<'_>) -> Result { - let mut url = self.new_url("track.snippet.get"); + let mut url = self.new_url("track.snippet.get")?; { let mut url_query = url.query_pairs_mut(); diff --git a/src/apis/subtitle_api.rs b/src/apis/subtitle_api.rs index 904f028..43fcb8f 100644 --- a/src/apis/subtitle_api.rs +++ b/src/apis/subtitle_api.rs @@ -25,7 +25,7 @@ impl Musixmatch { f_subtitle_length: Option, f_subtitle_length_max_deviation: Option, ) -> Result { - let mut url = self.new_url("matcher.subtitle.get"); + let mut url = self.new_url("matcher.subtitle.get")?; { let mut url_query = url.query_pairs_mut(); if !q_track.is_empty() { @@ -73,7 +73,7 @@ impl Musixmatch { f_subtitle_length: Option, f_subtitle_length_max_deviation: Option, ) -> Result { - let mut url = self.new_url("track.subtitle.get"); + let mut url = self.new_url("track.subtitle.get")?; { let mut url_query = url.query_pairs_mut(); diff --git a/src/apis/track_api.rs b/src/apis/track_api.rs index 537c69b..4b3cd9d 100644 --- a/src/apis/track_api.rs +++ b/src/apis/track_api.rs @@ -26,9 +26,8 @@ impl Musixmatch { q_album: &str, translation_status: bool, lang_3c: bool, - performer_tagging: bool, ) -> Result { - let mut url = self.new_url("matcher.track.get"); + let mut url = self.new_url("matcher.track.get")?; { let mut url_query = url.query_pairs_mut(); @@ -41,10 +40,8 @@ impl Musixmatch { if !q_album.is_empty() { url_query.append_pair("q_album", q_album); } - - let mut part = Vec::new(); if translation_status { - part.push("track_lyrics_translation_status"); + url_query.append_pair("part", "track_lyrics_translation_status"); url_query.append_pair( "language_iso_code", match lang_3c { @@ -53,13 +50,6 @@ impl Musixmatch { }, ); } - if performer_tagging { - part.push("track_performer_tagging"); - } - if !part.is_empty() { - url_query.append_pair("part", &part.join(",")); - } - url_query.finish(); } @@ -83,18 +73,15 @@ impl Musixmatch { id: TrackId<'_>, translation_status: bool, lang_3c: bool, - performer_tagging: bool, ) -> Result { - let mut url = self.new_url("track.get"); + let mut url = self.new_url("track.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); - - let mut part = Vec::new(); if translation_status { - part.push("track_lyrics_translation_status"); + url_query.append_pair("part", "track_lyrics_translation_status"); url_query.append_pair( "language_iso_code", match lang_3c { @@ -103,13 +90,6 @@ impl Musixmatch { }, ); } - if performer_tagging { - part.push("track_performer_tagging"); - } - if !part.is_empty() { - url_query.append_pair("part", &part.join(",")); - } - url_query.finish(); } @@ -134,7 +114,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("album.tracks.get"); + let mut url = self.new_url("album.tracks.get")?; { let mut url_query = url.query_pairs_mut(); @@ -175,7 +155,7 @@ impl Musixmatch { page_size: u8, page: u32, ) -> Result> { - let mut url = self.new_url("chart.tracks.get"); + let mut url = self.new_url("chart.tracks.get")?; { let mut url_query = url.query_pairs_mut(); @@ -204,7 +184,7 @@ impl Musixmatch { /// # Reference /// pub async fn genres(&self) -> Result> { - let url = self.new_url("music.genres.get"); + let url = self.new_url("music.genres.get")?; let genres = self.execute_get_request::(&url).await?; Ok(genres.music_genre_list) } @@ -367,7 +347,7 @@ impl<'a> TrackSearchQuery<'a> { /// - `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. pub async fn send(&self, page_size: u8, page: u32) -> Result> { - let mut url = self.mxm.new_url("track.search"); + let mut url = self.mxm.new_url("track.search")?; { let mut url_query = url.query_pairs_mut(); diff --git a/src/error.rs b/src/error.rs index f58312b..1258bd7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,6 +20,9 @@ pub enum Error { /// Error message msg: String, }, + /// Musixmatch returned no data or the data that could not be deserialized + #[error("Musixmatch returned no data or data that could not be deserialized")] + NoData, /// Client requires credentials, but none were given #[error("You did not input credentials")] MissingCredentials, @@ -32,12 +35,12 @@ pub enum Error { /// Musixmatch content not available #[error("Unfortunately we're not authorized to show these lyrics")] NotAvailable, - /// Musixmatch returned no data or the data that could not be deserialized - #[error("JSON parsing error: {0}")] - InvalidData(Cow<'static, str>), /// Error from the HTTP client #[error("http error: {0}")] Http(reqwest::Error), + /// Unspecified error + #[error("{0}")] + Other(Cow<'static, str>), } impl From for Error { @@ -47,13 +50,8 @@ impl From for Error { } } -impl From for Error { - fn from(value: serde_json::Error) -> Self { - Self::InvalidData(value.to_string().into()) +impl From for Error { + fn from(value: url::ParseError) -> Self { + Self::Other(format!("url parse error: {value}").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 8a6f508..999bee3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,11 @@ pub mod models; pub mod storage; use std::fmt::Debug; +use std::ops::Deref; use std::path::Path; use std::sync::{Arc, RwLock}; -pub use error::{Error, IdError}; +pub use error::Error; use base64::Engine; use hmac::{Hmac, Mac}; @@ -28,17 +29,36 @@ use time::macros::format_description; use time::OffsetDateTime; use tokio::sync::Mutex; -use crate::api_model::parse_body; +use crate::api_model::Resp; use crate::error::Result; const YMD_FORMAT: &[time::format_description::FormatItem] = format_description!("[year][month][day]"); -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('"; +/// Hardcoded client configuration +struct ClientCfg { + app_id: &'static str, + api_url: &'static str, + signature_secret: &'static [u8; 20], + user_agent: &'static str, + login: bool, +} + +const DESKTOP_CLIENT: ClientCfg = ClientCfg { + app_id: "web-desktop-app-v1.0", + api_url: "https://apic-desktop.musixmatch.com/ws/1.1/", + signature_secret: b"IEJ5E8XFaHQvIQNfs7IC", + user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Musixmatch/0.19.4 Chrome/58.0.3029.110 Electron/1.7.6 Safari/537.36", + login: false +}; +const ANDROID_CLIENT: ClientCfg = ClientCfg { + app_id: "android-player-v1.0", + api_url: "https://apic.musixmatch.com/ws/1.1/", + signature_secret: b"967Pn4)N3&R_GBg5$b('", + user_agent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)", + login: true, +}; -const DEFAULT_UA: &str = "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)"; const DEFAULT_BRAND: &str = "Google"; const DEFAULT_DEVICE: &str = "Pixel 6"; @@ -56,6 +76,7 @@ pub struct Musixmatch { /// Used to construct a new [`Musixmatch`] client.# #[derive(Default)] pub struct MusixmatchBuilder { + client_type: ClientType, user_agent: Option, brand: Option, device: Option, @@ -63,6 +84,29 @@ pub struct MusixmatchBuilder { credentials: Option, } +/// Musixmatch client type +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientType { + /// The desktop client is used with Musixmatch's electron-based Desktop application. + /// + /// The client allows anonymous access and is currently the default option. + /// + /// Since Musixmatch's desktop application is discontinued, the client may stop working in the future. + #[default] + Desktop, + /// The Android client requires a (free) Musixmatch account + Android, +} + +impl From for ClientCfg { + fn from(value: ClientType) -> Self { + match value { + ClientType::Desktop => DESKTOP_CLIENT, + ClientType::Android => ANDROID_CLIENT, + } + } +} + #[derive(Default)] enum DefaultOpt { Some(T), @@ -85,6 +129,8 @@ struct MusixmatchRef { http: Client, storage: Option>, credentials: RwLock>, + client_type: ClientType, + client_cfg: ClientCfg, brand: String, device: String, usertoken: Mutex>, @@ -98,6 +144,7 @@ struct Credentials { #[derive(Debug, Serialize, Deserialize)] struct StoredSession { + client_type: ClientType, usertoken: String, } @@ -111,8 +158,8 @@ impl MusixmatchBuilder { /// Set the Musixmatch credentials /// - /// The Musixmatch API required a free account on to be - /// used. However, as of 2024, this requirement was removed. + /// You have to create a free account on to use + /// the API. /// /// The Musixmatch client can be constructed without any credentials. /// In this case you rely on the stored session token to authenticate @@ -166,6 +213,12 @@ impl MusixmatchBuilder { self } + /// Set the client type (Desktop, Android) of the Musixmatch client + pub fn client_type(mut self, client_type: ClientType) -> Self { + self.client_type = client_type; + self + } + /// Set the device brand of the Musixmatch client pub fn device_brand>(mut self, device_brand: S) -> Self { self.brand = Some(device_brand.into()); @@ -186,13 +239,18 @@ impl MusixmatchBuilder { /// Returns a new, configured Musixmatch client using a Reqwest client builder pub fn build_with_client(self, client_builder: ClientBuilder) -> Result { let storage = self.storage.or_default(|| Box::::default()); - let stored_session = Musixmatch::retrieve_session(&storage); + let stored_session = + Musixmatch::retrieve_session(&storage).filter(|s| s.client_type == self.client_type); + let client_cfg = ClientCfg::from(self.client_type); let mut headers = HeaderMap::new(); headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap()); let http = client_builder - .user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned())) + .user_agent( + self.user_agent + .unwrap_or_else(|| client_cfg.user_agent.to_owned()), + ) .gzip(true) .default_headers(headers) .build()?; @@ -202,6 +260,8 @@ impl MusixmatchBuilder { http, storage, credentials: RwLock::new(self.credentials), + client_type: self.client_type, + client_cfg, brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()), device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()), usertoken: Mutex::new(stored_session.map(|s| s.usertoken)), @@ -235,42 +295,60 @@ impl Musixmatch { } } - let credentials = { + let credentials = if self.inner.client_cfg.login { let c = self.inner.credentials.read().unwrap(); - c.clone() + match c.deref() { + Some(c) => Some(c.clone()), + None => return Err(Error::MissingCredentials), + } + } else { + None }; let now = OffsetDateTime::now_utc(); - let guid = random_guid(); - let adv_id = random_uuid(); // 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"), - &[ - ("adv_id", adv_id.as_str()), - ("root", "0"), - ("sideloaded", "0"), - ("app_id", "android-player-v1.0"), - // App version (7.9.5) - ("build_number", "2022090901"), - ("guid", guid.as_str()), - ("lang", "en_US"), - ("model", self.model_string().as_str()), - ( - "timestamp", - now.format(&Rfc3339).unwrap_or_default().as_str(), - ), - ("format", "json"), - ], - ) - .unwrap(); - sign_url_with_date(&mut url, now); + let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get"); + let mut url = match self.inner.client_type { + ClientType::Desktop => Url::parse_with_params( + &base_url, + &[ + ("format", "json"), + ("user_language", "en"), + ("app_id", self.inner.client_cfg.app_id), + ], + ), + ClientType::Android => { + let guid = random_guid(); + let adv_id = random_uuid(); + Url::parse_with_params( + &base_url, + &[ + ("adv_id", adv_id.as_str()), + ("root", "0"), + ("sideloaded", "0"), + ("app_id", self.inner.client_cfg.app_id), + // App version (7.9.5) + ("build_number", "2022090901"), + ("guid", guid.as_str()), + ("lang", "en_US"), + ("model", self.model_string().as_str()), + ( + "timestamp", + now.format(&Rfc3339).unwrap_or_default().as_str(), + ), + ("format", "json"), + ("user_language", "en"), + ], + ) + } + }?; + self.sign_url_with_date(&mut url, now); let resp = self.inner.http.get(url).send().await?.error_for_status()?; - let resp_txt = resp.text().await?; - let usertoken = parse_body::(&resp_txt)?.user_token; + let tdata = resp.json::>().await?; + let usertoken = tdata.body_or_err()?.user_token; info!("Received new usertoken: {}****", &usertoken[0..8]); if let Some(credentials) = credentials { @@ -288,8 +366,8 @@ impl Musixmatch { usertoken: &str, credentials: &Credentials, ) -> Result { - let mut url = new_url_from_token("credential.post", usertoken); - sign_url_with_date(&mut url, OffsetDateTime::now_utc()); + let mut url = self.new_url_from_token("credential.post", usertoken)?; + self.sign_url_with_date(&mut url, OffsetDateTime::now_utc()); let api_credentials = api_model::Credentials { credential_list: &[api_model::CredentialWrap { @@ -311,14 +389,8 @@ impl Musixmatch { .await? .error_for_status()?; - let resp_txt = resp.text().await?; - let login = parse_body::(&resp_txt)?; - let credential = login - .0 - .into_iter() - .next() - .ok_or(Error::InvalidData("no credentials returned".into()))? - .credential; + let login = resp.json::>().await?.body_or_err()?; + let credential = login.0.into_iter().next().ok_or(Error::NoData)?.credential; match credential { api_model::LoginCredential::Account { account } => Ok(account), @@ -338,6 +410,7 @@ impl Musixmatch { fn store_session(&self, usertoken: &str) { if let Some(storage) = &self.inner.storage { let to_store = StoredSession { + client_type: self.inner.client_type, usertoken: usertoken.to_owned(), }; @@ -372,12 +445,12 @@ impl Musixmatch { ) } - fn new_url(&self, endpoint: &str) -> reqwest::Url { + fn new_url(&self, endpoint: &str) -> Result { Url::parse_with_params( - &format!("{}{}", API_URL, endpoint), - &[("app_id", APP_ID), ("format", "json")], + &format!("{}{}", self.inner.client_cfg.api_url, endpoint), + &[("app_id", self.inner.client_cfg.app_id), ("format", "json")], ) - .unwrap() + .map_err(Error::from) } async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> { @@ -386,7 +459,7 @@ impl Musixmatch { .append_pair("usertoken", &usertoken) .finish(); - sign_url_with_date(url, OffsetDateTime::now_utc()); + self.sign_url_with_date(url, OffsetDateTime::now_utc()); Ok(()) } @@ -404,9 +477,9 @@ impl Musixmatch { .send() .await? .error_for_status()?; - let resp_txt = resp.text().await?; + let resp_obj = resp.json::>().await?; - match parse_body(&resp_txt) { + match resp_obj.body_or_err() { Ok(body) => Ok(body), Err(Error::TokenExpired) => { info!("Usertoken expired, getting a new one"); @@ -422,8 +495,7 @@ impl Musixmatch { .await? .error_for_status()?; - let resp_txt = resp.text().await?; - parse_body(&resp_txt) + resp.json::>().await?.body_or_err() } Err(e) => Err(e), } @@ -446,53 +518,53 @@ impl Musixmatch { password: password.into(), }); } + + fn new_url_from_token(&self, endpoint: &str, usertoken: &str) -> Result { + Url::parse_with_params( + &format!("{}{}", self.inner.client_cfg.api_url, endpoint), + &[ + ("app_id", self.inner.client_cfg.app_id), + ("usertoken", usertoken), + ("format", "json"), + ], + ) + .map_err(Error::from) + } + + fn sign_url_with_date(&self, url: &mut Url, date: OffsetDateTime) { + let mut mac = Hmac::::new_from_slice(self.inner.client_cfg.signature_secret).unwrap(); + + mac.update(url.as_str().as_bytes()); + mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes()); + + let sig = mac.finalize().into_bytes(); + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n"; + + url.query_pairs_mut() + .append_pair("signature", &sig_b64) + .append_pair("signature_protocol", "sha1") + .finish(); + } } fn random_guid() -> String { - let mut rng = rand::rng(); - let n = rng.random::(); + let mut rng = rand::thread_rng(); + let n = rng.gen::(); format!("{:016x}", n) } fn random_uuid() -> String { - let mut rng = rand::rng(); + let mut rng = rand::thread_rng(); format!( "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", - rng.random::(), - rng.random::(), - rng.random::(), - rng.random::(), - rng.random::() & 0xffffffffffff, + rng.gen::(), + rng.gen::(), + rng.gen::(), + rng.gen::(), + rng.gen::() & 0xffffffffffff, ) } -fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url { - Url::parse_with_params( - &format!("{}{}", API_URL, endpoint), - &[ - ("app_id", APP_ID), - ("usertoken", usertoken), - ("format", "json"), - ], - ) - .unwrap() -} - -fn sign_url_with_date(url: &mut Url, date: OffsetDateTime) { - let mut mac = Hmac::::new_from_slice(SIGNATURE_SECRET).unwrap(); - - mac.update(url.as_str().as_bytes()); - mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes()); - - let sig = mac.finalize().into_bytes(); - let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n"; - - url.query_pairs_mut() - .append_pair("signature", &sig_b64) - .append_pair("signature_protocol", "sha1") - .finish(); -} - #[cfg(test)] mod tests { use time::macros::datetime; @@ -501,8 +573,12 @@ mod tests { #[test] fn t_sign_url() { + let mxm = Musixmatch::builder() + .client_type(ClientType::Android) + .build() + .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)); + mxm.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") } } diff --git a/src/models/album.rs b/src/models/album.rs index 9d4e8f7..afc0d59 100644 --- a/src/models/album.rs +++ b/src/models/album.rs @@ -15,7 +15,6 @@ 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 0d85dd7..e6d1a76 100644 --- a/src/models/artist.rs +++ b/src/models/artist.rs @@ -15,7 +15,6 @@ 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, @@ -86,14 +85,10 @@ 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, @@ -101,7 +96,6 @@ 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, @@ -109,7 +103,6 @@ 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") /// @@ -118,42 +111,3 @@ 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 3753c49..2e8879a 100644 --- a/src/models/id.rs +++ b/src/models/id.rs @@ -1,8 +1,4 @@ -use std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr}; - -use serde::{de::Visitor, Deserialize, Serialize}; - -use crate::IdError; +use std::borrow::Cow; /// Track identifiers from different sources #[derive(Debug, Clone, PartialEq, Eq)] @@ -29,7 +25,7 @@ pub enum TrackId<'a> { Spotify(Cow<'a, str>), } -impl TrackId<'_> { +impl<'a> TrackId<'a> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { TrackId::Commontrack(id) => ("commontrack_id", id.to_string()), @@ -54,7 +50,7 @@ pub enum ArtistId<'a> { Musicbrainz(&'a str), } -impl ArtistId<'_> { +impl<'a> ArtistId<'a> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { ArtistId::ArtistId(id) => ("artist_id", id.to_string()), @@ -75,7 +71,7 @@ pub enum AlbumId<'a> { Musicbrainz(&'a str), } -impl AlbumId<'_> { +impl<'a> AlbumId<'a> { pub(crate) fn to_param(&self) -> (&'static str, String) { match self { AlbumId::AlbumId(id) => ("album_id", id.to_string()), @@ -100,130 +96,3 @@ 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 8aef72e..720d4e6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,8 +10,6 @@ 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; @@ -25,12 +23,8 @@ 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 fa9944b..9dc03a9 100644 --- a/src/models/snippet.rs +++ b/src/models/snippet.rs @@ -14,7 +14,6 @@ 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/subtitle.rs b/src/models/subtitle.rs index 0ff3b09..4843127 100644 --- a/src/models/subtitle.rs +++ b/src/models/subtitle.rs @@ -226,7 +226,7 @@ impl Subtitle { /// Only works with [SubtitleFormat::Json]. pub fn to_lines(&self) -> Result { Ok(SubtitleLines { - lines: serde_json::from_str(&self.subtitle_body)?, + lines: serde_json::from_str(&self.subtitle_body).map_err(|_| Error::NoData)?, lang: self.subtitle_language.to_owned(), length: self.subtitle_length, }) @@ -256,7 +256,7 @@ impl TryFrom for SubtitleLines { impl SubtitleLines { /// Convert subtitles into the [JSON](SubtitleFormat::Json) format pub fn to_json(&self) -> Result { - serde_json::to_string(&self).map_err(Error::from) + serde_json::to_string(&self).map_err(|_| Error::NoData) } /// Convert subtitles into the [LRC](SubtitleFormat::Lrc) format diff --git a/src/models/track.rs b/src/models/track.rs index bfb1980..7c72a62 100644 --- a/src/models/track.rs +++ b/src/models/track.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -use super::{Artist, Fqid, Genres}; +use super::Genres; #[derive(Debug, Deserialize)] pub(crate) struct TrackBody { @@ -140,8 +140,6 @@ 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) @@ -158,72 +156,6 @@ 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 7a45f64..2717f41 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,37 +1,32 @@ -use std::{ - num::NonZeroU32, - path::{Path, PathBuf}, - sync::LazyLock, -}; +use std::path::{Path, PathBuf}; -use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; use path_macro::path; -use rstest::{fixture, rstest}; +use rstest::rstest; use time::macros::{date, datetime}; use musixmatch_inofficial::{ models::{AlbumId, ArtistId, TrackId}, - Error, Musixmatch, + ClientType, Error, Musixmatch, }; -fn testfile>(name: P) -> PathBuf { - path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name) +#[ctor::ctor] +fn init() { + let _ = dotenvy::dotenv(); + env_logger::init(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(new_mxm().login()) + .unwrap(); } -#[fixture] -async fn mxm() -> Musixmatch { - static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); - static MXM_LIMITER: LazyLock = LazyLock::new(|| { - RateLimiter::direct(if std::env::var("CI").is_ok() { - Quota::with_period(std::time::Duration::from_millis(1500)).unwrap() - } else { - Quota::per_second(NonZeroU32::new(4).unwrap()) - }) - }); +fn new_mxm() -> Musixmatch { + let client_type = std::env::var("MUSIXMATCH_CLIENT") + .map(|ctype| serde_plain::from_str::(&ctype).expect("valid client type")) + .unwrap_or_default(); - MXM_LIMITER.until_ready().await; - - let mut mxm = Musixmatch::builder(); + let mut mxm = Musixmatch::builder().client_type(client_type); if let (Ok(email), Ok(password)) = ( std::env::var("MUSIXMATCH_EMAIL"), @@ -40,10 +35,11 @@ async fn mxm() -> Musixmatch { mxm = mxm.credentials(email, password); } - let mxm = mxm.build().unwrap(); + mxm.build().unwrap() +} - LOGIN_LOCK.get_or_try_init(|| mxm.login()).await.unwrap(); - mxm +fn testfile>(name: P) -> PathBuf { + path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name) } mod album { @@ -54,8 +50,8 @@ mod album { #[case::id(AlbumId::AlbumId(14248253))] #[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(); + async fn by_id(#[case] album_id: AlbumId<'_>) { + let album = new_mxm().album(album_id).await.unwrap(); assert_eq!(album.album_id, 14248253); assert_eq!( @@ -64,7 +60,7 @@ mod album { ); assert_eq!(album.album_name, "Gangnam Style (강남스타일)"); assert!(album.album_rating > 20); - assert_eq!(album.album_track_count, 0); + 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); assert_eq!(album.artist_id, 410698); @@ -97,25 +93,31 @@ mod album { ); assert_eq!(album.album_vanity_id, "410698/Gangnam-Style-Single"); assert!(album.updated_time > datetime!(2022-6-3 0:00 UTC)); - assert_imgurl(&album.album_coverart_100x100, "/26544045.jpg"); - assert_imgurl(&album.album_coverart_350x350, "/26544045_350_350.jpg"); - assert_imgurl(&album.album_coverart_500x500, "/26544045_500_500.jpg"); + assert_eq!( + album.album_coverart_100x100.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045.jpg" + ); + assert_eq!( + album.album_coverart_350x350.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045_350_350.jpg" + ); + assert_eq!( + album.album_coverart_500x500.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045_500_500.jpg" + ); } - #[rstest] #[tokio::test] - async fn album_ep(#[future] mxm: Musixmatch) { - let album = mxm.await.album(AlbumId::AlbumId(23976123)).await.unwrap(); + async fn album_ep() { + let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap(); assert_eq!(album.album_name, "Waldbrand EP"); - // assert_eq!(album.album_release_type, AlbumType::Ep); + assert_eq!(album.album_release_type, AlbumType::Ep); assert_eq!(album.album_release_date, Some(date!(2016 - 09 - 30))); } - #[rstest] #[tokio::test] - async fn by_id_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn by_id_missing() { + let err = new_mxm() .album(AlbumId::AlbumId(999999999999)) .await .unwrap_err(); @@ -123,12 +125,9 @@ mod album { assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - #[ignore] - async fn artist_albums(#[future] mxm: Musixmatch) { - let albums = mxm - .await + async fn artist_albums() { + let albums = new_mxm() .artist_albums(ArtistId::ArtistId(1039), None, 10, 1) .await .unwrap(); @@ -136,11 +135,9 @@ mod album { assert_eq!(albums.len(), 10); } - #[rstest] #[tokio::test] - async fn artist_albums_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn artist_albums_missing() { + let err = new_mxm() .artist_albums(ArtistId::ArtistId(999999999999), None, 10, 1) .await .unwrap_err(); @@ -148,10 +145,9 @@ mod album { assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn charts(#[future] mxm: Musixmatch) { - let albums = mxm.await.chart_albums("US", 10, 1).await.unwrap(); + async fn charts() { + let albums = new_mxm().chart_albums("US", 10, 1).await.unwrap(); assert_eq!(albums.len(), 10); } @@ -164,8 +160,8 @@ mod artist { #[case::id(ArtistId::ArtistId(410698))] #[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(); + async fn by_id(#[case] artist_id: ArtistId<'_>) { + let artist = new_mxm().artist(artist_id).await.unwrap(); // dbg!(&artist); @@ -205,28 +201,11 @@ 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] #[tokio::test] - async fn by_id_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn by_id_missing() { + let err = new_mxm() .artist(ArtistId::ArtistId(999999999999)) .await .unwrap_err(); @@ -234,11 +213,9 @@ mod artist { assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn related(#[future] mxm: Musixmatch) { - let artists = mxm - .await + async fn related() { + let artists = new_mxm() .artist_related(ArtistId::ArtistId(26485840), 10, 1) .await .unwrap(); @@ -246,11 +223,9 @@ mod artist { assert_eq!(artists.len(), 10); } - #[rstest] #[tokio::test] - async fn related_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn related_missing() { + let err = new_mxm() .artist_related(ArtistId::ArtistId(999999999999), 10, 1) .await .unwrap_err(); @@ -258,25 +233,20 @@ mod artist { assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn search(#[future] mxm: Musixmatch) { - let artists = mxm - .await - .artist_search("Snollebollekes", 5, 1) - .await - .unwrap(); + async fn search() { + let artists = new_mxm().artist_search("psy", 5, 1).await.unwrap(); + + assert_eq!(artists.len(), 5); let artist = &artists[0]; - assert_eq!(artist.artist_id, 25344078); - assert_eq!(artist.artist_name, "Snollebollekes"); + assert_eq!(artist.artist_id, 410698); + assert_eq!(artist.artist_name, "PSY"); } - #[rstest] #[tokio::test] - async fn search_empty(#[future] mxm: Musixmatch) { - let artists = mxm - .await + async fn search_empty() { + let artists = new_mxm() .artist_search( "Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz", 5, @@ -288,242 +258,50 @@ mod artist { assert_eq!(artists.len(), 0); } - #[rstest] #[tokio::test] - async fn charts(#[future] mxm: Musixmatch) { - let artists = mxm.await.chart_artists("US", 10, 1).await.unwrap(); + async fn charts() { + let artists = new_mxm().chart_artists("US", 10, 1).await.unwrap(); assert_eq!(artists.len(), 10); } - #[rstest] #[tokio::test] - async fn charts_no_country(#[future] mxm: Musixmatch) { - let artists = mxm.await.chart_artists("XY", 10, 1).await.unwrap(); + async fn charts_no_country() { + let artists = new_mxm().chart_artists("XY", 10, 1).await.unwrap(); assert_eq!(artists.len(), 10); } } mod track { - use std::collections::HashMap; - use super::*; - use musixmatch_inofficial::models::{ChartName, MxmEntityType, SortOrder}; + use musixmatch_inofficial::models::{ChartName, SortOrder}; #[rstest] #[case::no_translation(false, false)] #[case::translation_2c(true, false)] #[case::translation_3c(true, true)] #[tokio::test] - async fn from_match( - #[case] translation_status: bool, - #[case] lang_3c: bool, - #[future] mxm: Musixmatch, - ) { - let track = mxm - .await + async fn from_match(#[case] translation_status: bool, #[case] lang_3c: bool) { + let track = new_mxm() .matcher_track( - "Unter der Oberfläche", - "Silbermond", - "Himmel auf", + "Poker Face", + "Lady Gaga", + "The Fame", translation_status, lang_3c, - false, ) .await .unwrap(); // dbg!(&track); - assert_eq!(track.track_id, 17633247); + assert_eq!(track.track_id, 15476784); assert_eq!( track.track_mbid.unwrap(), - "1b97675a-537e-4599-82de-b4ef801e4a69" + "080975b0-39b1-493c-ae64-5cb3292409bb" ); - assert_eq!(track.track_isrc.unwrap(), "DEE861200084"); - assert!( - track.commontrack_isrcs[0] - .iter() - .any(|isrc| isrc == "DEE861200084"), - "commontrack_isrcs: {:?}", - &track.commontrack_isrcs[0], - ); - assert_eq!(track.track_spotify_id.unwrap(), "4kebfr96W2JTvb7xjf4lDV"); - assert!( - track - .commontrack_spotify_ids - .iter() - .any(|spid| spid == "4kebfr96W2JTvb7xjf4lDV"), - "commontrack_spotify_ids: {:?}", - track.commontrack_spotify_ids, - ); - assert_eq!(track.track_name, "Unter der Oberfläche"); - assert!(track.track_rating > 30); - assert_eq!(track.commontrack_id, 10514077); - assert!(!track.instrumental); - assert!(!track.explicit); - assert!(track.has_subtitles); - assert!(track.has_richsync); - assert!(!track.has_track_structure); - assert!(track.num_favourite > 10); - assert_eq!(track.lyrics_id.unwrap(), 7401716); - assert_eq!(track.subtitle_id.unwrap(), 35487435); - 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(), - "34d42823-6b56-4861-a675-1565bf40d557" - ); - 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/Unter-der-Oberfläche" - ); - let first_release = track.first_release_date.unwrap(); - assert_eq!(first_release.date(), date!(2012 - 3 - 23)); - assert!(track.updated_time > datetime!(2013-4-26 13:11:50 UTC)); - - let first_pri_genre = &track.primary_genres.music_genre_list[0].music_genre; - assert_eq!(first_pri_genre.music_genre_id, 14); - assert_eq!(first_pri_genre.music_genre_parent_id, 34); - assert_eq!(first_pri_genre.music_genre_name, "Pop"); - assert_eq!(first_pri_genre.music_genre_name_extended, "Pop"); - assert_eq!(first_pri_genre.music_genre_vanity.as_ref().unwrap(), "Pop"); - - if translation_status { - assert!( - track.track_lyrics_translation_status.iter().all(|tl| { - (if lang_3c { - tl.from.as_deref() == Some("deu") - } else { - tl.from.as_deref() == Some("de") - }) && tl.perc >= 0.0 - && tl.perc <= 1.0 - }), - "translation: {:?}", - track.track_lyrics_translation_status - ); - } else { - assert!(track.track_lyrics_translation_status.is_empty()) - } - } - - #[rstest] - #[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, false).await.unwrap(); - - // dbg!(&track); - - 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.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!(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("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)] - #[case::translation_3c(true, true)] - #[tokio::test] - async fn from_id_translations( - #[case] translation_status: bool, - #[case] lang_3c: bool, - #[future] mxm: Musixmatch, - ) { - let track = mxm - .await - .track( - TrackId::Commontrack(47672612), - 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_isrc.unwrap(), "USUM70824409"); assert!( track.commontrack_isrcs[0] .iter() @@ -531,7 +309,7 @@ mod track { "commontrack_isrcs: {:?}", &track.commontrack_isrcs[0], ); - assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg"); + assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO"); assert!( track .commontrack_spotify_ids @@ -551,7 +329,7 @@ mod track { 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_id, 13810402); assert_eq!(track.album_name, "The Fame"); assert_eq!(track.artist_id, 378462); assert_eq!( @@ -559,10 +337,18 @@ mod track { "650e7db6-b795-4eb5-a702-5ea2fc46c848" ); 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.album_coverart_100x100.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg" + ); + assert_eq!( + track.album_coverart_350x350.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_350_350.jpg" + ); + assert_eq!( + track.album_coverart_500x500.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_500_500.jpg" + ); assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1"); let first_release = track.first_release_date.unwrap(); assert_eq!(first_release.date(), date!(2008 - 1 - 1)); @@ -604,22 +390,176 @@ 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()))] #[tokio::test] - async fn from_id_missing(#[future] mxm: Musixmatch) { - let err = mxm + async fn from_id(#[case] track_id: TrackId<'_>) { + let track = new_mxm().track(track_id, true, 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!(!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_eq!( + track.album_coverart_100x100.unwrap(), + "https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772.jpg" + ); + assert_eq!( + track.album_coverart_350x350.unwrap(), + "https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772_350_350.jpg" + ); + assert_eq!( + track.album_coverart_500x500.unwrap(), + "https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772_500_500.jpg" + ); + assert_eq!(track.commontrack_vanity_id, "aespa/Black-Mamba"); + + let release_date = track.first_release_date.unwrap(); + assert_eq!(release_date.date(), date!(2020 - 11 - 17)); + 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!(first_tstatus.perc >= 0.0 && first_tstatus.perc <= 1.0); + } + + #[rstest] + #[case::no_translation(false, false)] + #[case::translation_2c(true, false)] + #[case::translation_3c(true, true)] + #[tokio::test] + async fn from_id_translations(#[case] translation_status: bool, #[case] lang_3c: bool) { + let track = new_mxm() + .track(TrackId::TrackId(15476784), translation_status, lang_3c) .await - .track(TrackId::TrackId(999999999999), false, false, false) + .unwrap(); + + // dbg!(&track); + + assert_eq!(track.track_id, 15476784); + assert_eq!( + track.track_mbid.unwrap(), + "080975b0-39b1-493c-ae64-5cb3292409bb" + ); + assert_eq!(track.track_isrc.unwrap(), "USUM70824409"); + assert!( + track.commontrack_isrcs[0] + .iter() + .any(|isrc| isrc == "USUM70824409"), + "commontrack_isrcs: {:?}", + &track.commontrack_isrcs[0], + ); + assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO"); + assert!( + track + .commontrack_spotify_ids + .iter() + .any(|spid| spid == "1QV6tiMFM6fSOKOGLMHYYg"), + "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!(!track.instrumental); + assert!(track.explicit); + 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, 13810402); + assert_eq!(track.album_name, "The Fame"); + assert_eq!(track.artist_id, 378462); + assert_eq!( + track.artist_mbid.unwrap(), + "650e7db6-b795-4eb5-a702-5ea2fc46c848" + ); + assert_eq!(track.artist_name, "Lady Gaga"); + assert_eq!( + track.album_coverart_100x100.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg" + ); + assert_eq!( + track.album_coverart_350x350.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_350_350.jpg" + ); + assert_eq!( + track.album_coverart_500x500.unwrap(), + "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_500_500.jpg" + ); + assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1"); + 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)); + + let first_pri_genre = &track.primary_genres.music_genre_list[0].music_genre; + assert_eq!(first_pri_genre.music_genre_id, 14); + assert_eq!(first_pri_genre.music_genre_parent_id, 34); + assert_eq!(first_pri_genre.music_genre_name, "Pop"); + 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") + } else { + tl.from.as_deref() == Some("en") + }) && tl.perc >= 0.0 + && tl.perc <= 1.0 + }), + "translation: {:?}", + track.track_lyrics_translation_status + ); + } else { + assert!(track.track_lyrics_translation_status.is_empty()) + } + } + + #[tokio::test] + async fn from_id_missing() { + let err = new_mxm() + .track(TrackId::TrackId(999999999999), false, false) .await .unwrap_err(); assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn album_tracks(#[future] mxm: Musixmatch) { - let tracks = mxm - .await + async fn album_tracks() { + let tracks = new_mxm() .album_tracks(AlbumId::AlbumId(17118624), true, 20, 1) .await .unwrap(); @@ -656,11 +596,9 @@ mod track { }); } - #[rstest] #[tokio::test] - async fn album_missing(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn album_missing() { + let err = new_mxm() .album_tracks(AlbumId::AlbumId(999999999999), false, 20, 1) .await .unwrap_err(); @@ -672,9 +610,8 @@ mod track { #[case::top(ChartName::Top)] #[case::hot(ChartName::Hot)] #[tokio::test] - async fn charts(#[case] chart_name: ChartName, #[future] mxm: Musixmatch) { - let tracks = mxm - .await + async fn charts(#[case] chart_name: ChartName) { + let tracks = new_mxm() .chart_tracks("US", chart_name, true, 20, 1) .await .unwrap(); @@ -682,11 +619,9 @@ mod track { assert_eq!(tracks.len(), 20); } - #[rstest] #[tokio::test] - async fn search(#[future] mxm: Musixmatch) { - let tracks = mxm - .await + async fn search() { + let tracks = new_mxm() .track_search() .q_artist("Lena") .q_track("Satellite") @@ -705,11 +640,9 @@ mod track { assert_eq!(track.artist_name, "Lena"); } - #[rstest] #[tokio::test] - async fn search_lyrics(#[future] mxm: Musixmatch) { - let tracks = mxm - .await + async fn search_lyrics() { + let tracks = new_mxm() .track_search() .q_lyrics("the whole world stops and stares for a while") .s_track_rating(SortOrder::Desc) @@ -717,18 +650,16 @@ mod track { .await .unwrap(); - assert_gte(tracks.len(), 8, "tracks"); + assert_eq!(tracks.len(), 10); let track = &tracks[0]; assert_eq!(track.track_name, "Just the Way You Are"); assert_eq!(track.artist_name, "Bruno Mars"); } - #[rstest] #[tokio::test] - async fn search_empty(#[future] mxm: Musixmatch) { - let artists = mxm - .await + async fn search_empty() { + let artists = new_mxm() .track_search() .q("Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz") .send(10, 1) @@ -738,19 +669,16 @@ mod track { assert_eq!(artists.len(), 0); } - #[rstest] #[tokio::test] - async fn genres(#[future] mxm: Musixmatch) { - let genres = mxm.await.genres().await.unwrap(); + async fn genres() { + let genres = new_mxm().genres().await.unwrap(); assert!(genres.len() > 360); - dbg!(&genres); + // dbg!(&genres); } - #[rstest] #[tokio::test] - async fn snippet(#[future] mxm: Musixmatch) { - let snippet = mxm - .await + async fn snippet() { + let snippet = new_mxm() .track_snippet(TrackId::Commontrack(8874280)) .await .unwrap(); @@ -772,27 +700,22 @@ mod lyrics { use super::*; - #[rstest] #[tokio::test] - async fn from_match(#[future] mxm: Musixmatch) { - let lyrics = mxm.await.matcher_lyrics("Shine", "Spektrem").await.unwrap(); + async fn from_match() { + let lyrics = new_mxm().matcher_lyrics("Shine", "Spektrem").await.unwrap(); // dbg!(&lyrics); - assert_eq!(lyrics.lyrics_id, 34583240); + assert_eq!(lyrics.lyrics_id, 25947036); assert!(!lyrics.instrumental); assert!(!lyrics.explicit); - assert!( - lyrics - .lyrics_body - .starts_with("Eyes in the sky gazing far into the night\n"), - "got: {}", - lyrics.lyrics_body - ); + assert!(lyrics + .lyrics_body + .starts_with("Eyes, in the sky, gazing far into the night\n")); assert_eq!(lyrics.lyrics_language.unwrap(), "en"); assert_eq!(lyrics.lyrics_language_description.unwrap(), "English"); let copyright = lyrics.lyrics_copyright.unwrap(); - assert!(copyright.contains("Jesse Warren"), "copyright: {copyright}",); + assert!(copyright.contains("Kim Jeffeson"), "copyright: {copyright}",); assert!(lyrics.updated_time > datetime!(2021-6-3 0:00 UTC)); } @@ -803,12 +726,12 @@ mod lyrics { #[case::isrc(TrackId::Isrc("KRA302000590".into()))] #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] #[tokio::test] - async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { - let lyrics = mxm.await.track_lyrics(track_id).await.unwrap(); + async fn from_id(#[case] track_id: TrackId<'_>) { + let lyrics = new_mxm().track_lyrics(track_id).await.unwrap(); // dbg!(&lyrics); - assert_eq!(lyrics.lyrics_id, 36846057); + assert_eq!(lyrics.lyrics_id, 30126001); assert_eq!(lyrics.lyrics_language.unwrap(), "ko"); assert_eq!(lyrics.lyrics_language_description.unwrap(), "Korean"); let copyright = lyrics.lyrics_copyright.unwrap(); @@ -820,11 +743,9 @@ mod lyrics { } /// This track has no lyrics - #[rstest] #[tokio::test] - async fn instrumental(#[future] mxm: Musixmatch) { - let lyrics = mxm - .await + async fn instrumental() { + let lyrics = new_mxm() .matcher_lyrics("drivers license", "Bobby G") .await .unwrap(); @@ -840,28 +761,24 @@ mod lyrics { } /// This track does not exist - #[rstest] #[tokio::test] - async fn missing(#[future] mxm: Musixmatch) { - let err = mxm - .await - .track_lyrics(TrackId::Spotify("2gwMMr1a4aXXN5L6KC80Pu".into())) + async fn missing() { + let err = new_mxm() + .track_lyrics(TrackId::Spotify("674JwwTP7xCje81T0DRrLn".into())) .await .unwrap_err(); assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn download_testdata(#[future] mxm: Musixmatch) { - let mxm = mxm.await; + async fn download_testdata() { let json_path = testfile("lyrics.json"); if json_path.exists() { return; } - let lyrics = mxm + let lyrics = new_mxm() .track_lyrics(TrackId::Commontrack(18576954)) .await .unwrap(); @@ -870,16 +787,14 @@ mod lyrics { serde_json::to_writer_pretty(BufWriter::new(json_file), &lyrics).unwrap(); } - #[rstest] #[tokio::test] - async fn download_testdata_translation(#[future] mxm: Musixmatch) { - let mxm = mxm.await; + async fn download_testdata_translation() { let json_path = testfile("translation.json"); if json_path.exists() { return; } - let translations = mxm + let translations = new_mxm() .track_lyrics_translation(TrackId::Commontrack(18576954), "de") .await .unwrap(); @@ -888,10 +803,9 @@ mod lyrics { serde_json::to_writer_pretty(BufWriter::new(json_file), &translations).unwrap(); } - #[rstest] #[tokio::test] - async fn concurrency(#[future] mxm: Musixmatch) { - let mxm = mxm.await; + async fn concurrency() { + let mxm = new_mxm(); let album = mxm .album_tracks( @@ -926,11 +840,9 @@ mod subtitles { use super::*; use musixmatch_inofficial::models::SubtitleFormat; - #[rstest] #[tokio::test] - async fn from_match(#[future] mxm: Musixmatch) { - let subtitle = mxm - .await + async fn from_match() { + let subtitle = new_mxm() .matcher_subtitle( "Shine", "Spektrem", @@ -943,12 +855,12 @@ mod subtitles { // dbg!(&subtitle); - assert_eq!(subtitle.subtitle_id, 35340319); + assert_eq!(subtitle.subtitle_id, 36913312); assert_eq!(subtitle.subtitle_language.unwrap(), "en"); assert_eq!(subtitle.subtitle_language_description.unwrap(), "English"); let copyright = subtitle.lyrics_copyright.unwrap(); - assert!(copyright.contains("Jesse Warren"), "copyright: {copyright}",); - assert_eq!(subtitle.subtitle_length, 316); + assert!(copyright.contains("Kim Jeffeson"), "copyright: {copyright}",); + assert_eq!(subtitle.subtitle_length, 315); assert!(subtitle.updated_time > datetime!(2021-6-30 0:00 UTC)); } @@ -959,9 +871,8 @@ mod subtitles { #[case::isrc(TrackId::Isrc("KRA302000590".into()))] #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] #[tokio::test] - async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { - let subtitle = mxm - .await + async fn from_id(#[case] track_id: TrackId<'_>) { + let subtitle = new_mxm() .track_subtitle(track_id, SubtitleFormat::Json, Some(175.0), Some(1.0)) .await .unwrap(); @@ -981,11 +892,9 @@ mod subtitles { } /// This track has no lyrics - #[rstest] #[tokio::test] - async fn instrumental(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn instrumental() { + let err = new_mxm() .matcher_subtitle( "drivers license", "Bobby G", @@ -1000,11 +909,9 @@ mod subtitles { } /// This track has not been synced - #[rstest] #[tokio::test] - async fn unsynced(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn unsynced() { + let err = new_mxm() .track_subtitle( TrackId::Spotify("6oaWIABGL7eeiMILEDyGX1".into()), SubtitleFormat::Json, @@ -1018,11 +925,9 @@ mod subtitles { } /// Try to get subtitles with wrong length parameter - #[rstest] #[tokio::test] - async fn wrong_length(#[future] mxm: Musixmatch) { - let err = mxm - .await + async fn wrong_length() { + let err = new_mxm() .track_subtitle( TrackId::Commontrack(118480583), SubtitleFormat::Json, @@ -1035,16 +940,14 @@ mod subtitles { assert!(matches!(err, Error::NotFound)); } - #[rstest] #[tokio::test] - async fn download_testdata(#[future] mxm: Musixmatch) { + async fn download_testdata() { let json_path = testfile("subtitles.json"); if json_path.exists() { return; } - let subtitle = mxm - .await + let subtitle = new_mxm() .track_subtitle( TrackId::Commontrack(18576954), SubtitleFormat::Json, @@ -1095,20 +998,16 @@ mod translation { } } -#[track_caller] -fn assert_imgurl(url: &Option, ends_with: &str) { - assert!( - url.as_deref().is_some_and( - |url| url.starts_with("https://s.mxmcdn.net/images-storage/") - && url.ends_with(ends_with) - ), - "expected url ending with {ends_with}\ngot {:?}", - url - ); -} - -/// Assert that number A is greater than or equal to number B -#[track_caller] -fn assert_gte(a: T, b: T, msg: &str) { - assert!(a >= b, "expected >= {b} {msg}, got {a}"); +#[tokio::test] +async fn no_credentials() { + let mxm = Musixmatch::builder() + .client_type(ClientType::Android) + .no_storage() + .build() + .unwrap(); + let err = mxm + .track_lyrics(TrackId::TrackId(205688271)) + .await + .unwrap_err(); + assert!(matches!(err, Error::MissingCredentials), "error: {err}"); }