Compare commits
	
		
			3 commits
		
	
	
		
			
				main
			
			...
			
				feat/deskt
			
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9904597d8f | |||
| 2300932afc | |||
| 6b4312a6a5 | 
					 32 changed files with 912 additions and 1766 deletions
				
			
		|  | @ -1,2 +1,3 @@ | |||
| MUSIXMATCH_CLIENT=Android | ||||
| MUSIXMATCH_EMAIL=mail@example.com | ||||
| MUSIXMATCH_PASSWORD=super-secret | ||||
|  |  | |||
|  | @ -1,51 +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 nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 --workspace | ||||
|         env: | ||||
|           ALL_PROXY: "http://warpproxy:8124" | ||||
| 
 | ||||
|       - name: Move test report | ||||
|         if: always() | ||||
|         run: mv target/nextest/ci/junit.xml junit.xml || true | ||||
| 
 | ||||
|       - name: 💌 Upload test report | ||||
|         if: always() | ||||
|         uses: https://code.forgejo.org/forgejo/upload-artifact@v4 | ||||
|         with: | ||||
|           name: test | ||||
|           path: | | ||||
|             junit.xml | ||||
| 
 | ||||
|       - name: 🔗 Artifactview PR comment | ||||
|         if: ${{ always() && github.event_name == 'pull_request' }} | ||||
|         run: | | ||||
|           if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi | ||||
|           curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \ | ||||
|             --data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}' | ||||
|  | @ -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<<END_OF_FILE' | ||||
|             git show -s --format=%N "${{ github.ref_name }}" | tail -n +4 | awk 'BEGIN{RS="-----BEGIN PGP SIGNATURE-----"} NR==1{printf $0}' | ||||
|             echo END_OF_FILE | ||||
|           } >> "$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 }}" | ||||
|  | @ -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 <forgejo-renovate-action@forgejo.org>' | ||||
| 
 | ||||
|           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 }} | ||||
							
								
								
									
										15
									
								
								.woodpecker.yml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								.woodpecker.yml
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
							
								
								
									
										75
									
								
								CHANGELOG.md
									
										
									
									
									
								
							
							
						
						
									
										75
									
								
								CHANGELOG.md
									
										
									
									
									
								
							|  | @ -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 | ||||
| 
 | ||||
| <!-- generated by git-cliff --> | ||||
							
								
								
									
										54
									
								
								Cargo.toml
									
										
									
									
									
								
							
							
						
						
									
										54
									
								
								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 <t.testboy@gmail.com>"] | ||||
| 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 <thetadev@magenta.de>"] | ||||
| 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.26.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 | ||||
|  |  | |||
							
								
								
									
										44
									
								
								Justfile
									
										
									
									
									
								
							
							
						
						
									
										44
									
								
								Justfile
									
										
									
									
									
								
							|  | @ -1,44 +0,0 @@ | |||
| test: | ||||
|     cargo nextest run --workspace --no-fail-fast --retries 1 | ||||
| 
 | ||||
| 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" | ||||
							
								
								
									
										24
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										24
									
								
								README.md
									
										
									
									
									
								
							|  | @ -1,20 +1,16 @@ | |||
| # musixmatch-inofficial | ||||
| 
 | ||||
| [](https://crates.io/crates/musixmatch-inofficial) | ||||
| [](http://opensource.org/licenses/MIT) | ||||
| [](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](<https://en.wikipedia.org/wiki/LRC_(file_format)>), | ||||
| [DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song. | ||||
| 
 | ||||
| The Musixmatch API used to require a free account on <https://www.musixmatch.com> 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` | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
| <!-- generated by git-cliff --> | ||||
|  | @ -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" | ||||
|  |  | |||
|  | @ -1,77 +0,0 @@ | |||
| # musixmatch-cli | ||||
| 
 | ||||
| [](https://crates.io/crates/musixmatch-cli) | ||||
| [](http://opensource.org/licenses/MIT) | ||||
| [](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 <https://www.musixmatch.com> 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 | ||||
| ... | ||||
| ``` | ||||
							
								
								
									
										435
									
								
								cli/src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										435
									
								
								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<f32>, | ||||
|         /// Maximum deviation from track length (Default: 1s)
 | ||||
|         #[clap(long)] | ||||
|         max_deviation: Option<f32>, | ||||
|         /// Subtitle format
 | ||||
|         #[clap(short, long, default_value = "lrc")] | ||||
|         #[clap(long, default_value = "lrc")] | ||||
|         format: SubtitleFormatClap, | ||||
|         /// Language
 | ||||
|         #[clap(long)] | ||||
|         lang: Option<String>, | ||||
|     }, | ||||
|     /// 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<String>, | ||||
|         /// Artist
 | ||||
|         #[clap(short, long)] | ||||
|         artist: Option<String>, | ||||
|         /// Lyrics
 | ||||
|         #[clap(short, long)] | ||||
|         lyrics: Option<String>, | ||||
|         /// Search query
 | ||||
|         query: Option<Vec<String>>, | ||||
|     }, | ||||
|     /// Search for Musixmatch artists
 | ||||
|     SearchArtist { query: Vec<String> }, | ||||
| } | ||||
| 
 | ||||
| #[derive(Args)] | ||||
|  | @ -117,38 +92,22 @@ struct TrackIdentifiers { | |||
|     isrc: Option<String>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Args)] | ||||
| #[group(multiple = false)] | ||||
| struct AlbumArtistIdentifiers { | ||||
|     /// Musixmatch-ID
 | ||||
|     #[clap(long)] | ||||
|     mxm_id: Option<u64>, | ||||
|     /// Musicbrainz-ID
 | ||||
|     #[clap(long)] | ||||
|     musicbrainz: Option<String>, | ||||
| } | ||||
| 
 | ||||
| #[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, | ||||
|  | @ -175,7 +134,7 @@ async fn main() { | |||
|     let cli = Cli::parse(); | ||||
| 
 | ||||
|     run(cli).await.unwrap_or_else(|e| { | ||||
|         eprintln!("Error: {e}"); | ||||
|         println!("Error: {}", e); | ||||
|         std::process::exit(1); | ||||
|     }); | ||||
| } | ||||
|  | @ -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'{}' <https://musixmatch.com/lyrics/{}>", | ||||
|                     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!( | ||||
|                     "{} <https://musixmatch.com/artist/{}>", | ||||
|                     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<TrackOrId<'static>> { | ||||
|     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,27 +366,21 @@ async fn get_track_or_id( | |||
| } | ||||
| 
 | ||||
| async fn get_track_id(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<TrackId<'static>> { | ||||
|     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<Track> { | ||||
|     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<Track> { | ||||
|     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 { | ||||
|     print!("{prompt}"); | ||||
|     print!("{}", prompt); | ||||
| 
 | ||||
|     stdout().flush().expect("Failed to flush stdout!"); | ||||
| 
 | ||||
|  | @ -483,7 +392,7 @@ fn input(prompt: &str) -> String { | |||
| } | ||||
| 
 | ||||
| fn input_pwd(prompt: &str) -> String { | ||||
|     print!("{prompt}"); | ||||
|     print!("{}", prompt); | ||||
|     stdout().flush().expect("Failed to flush stdout!"); | ||||
| 
 | ||||
|     rpassword::read_password().expect("Failed to read password") | ||||
|  |  | |||
							
								
								
									
										100
									
								
								cliff.toml
									
										
									
									
									
								
							
							
						
						
									
										100
									
								
								cliff.toml
									
										
									
									
									
								
							|  | @ -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 = """ | ||||
| <!-- generated by git-cliff --> | ||||
| """ | ||||
| # remove the leading and trailing s | ||||
| trim = true | ||||
| # postprocessors | ||||
| postprocessors = [ | ||||
|   # { pattern = '<REPO>', 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}](<REPO>/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 = "<!-- 0 -->🚀 Features" }, | ||||
|   { message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" }, | ||||
|   { message = "^doc", group = "<!-- 3 -->📚 Documentation" }, | ||||
|   { message = "^perf", group = "<!-- 4 -->⚡ Performance" }, | ||||
|   { message = "^refactor", group = "<!-- 2 -->🚜 Refactor" }, | ||||
|   { message = "^style", group = "<!-- 5 -->🎨 Styling" }, | ||||
|   { message = "^test", skip = true }, | ||||
|   { message = "^chore\\(release\\)", skip = true }, | ||||
|   { message = "^chore\\(pr\\)", skip = true }, | ||||
|   { message = "^chore\\(pull\\)", skip = true }, | ||||
|   { message = "^chore", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" }, | ||||
|   { message = "^ci", skip = true }, | ||||
|   { body = ".*security", group = "<!-- 8 -->🛡️ Security" }, | ||||
|   { message = "^revert", group = "<!-- 9 -->◀️ 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 | ||||
|  | @ -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 | ||||
| } | ||||
							
								
								
									
										201
									
								
								src/api_model.rs
									
										
									
									
									
								
							
							
						
						
									
										201
									
								
								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<T> { | ||||
|     pub message: T, | ||||
|     pub message: Message<T>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Deserialize)] | ||||
| pub struct HeaderMsg { | ||||
| pub struct Message<T> { | ||||
|     pub header: Header, | ||||
|     pub body: Option<MessageBody<T>>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Deserialize)] | ||||
| pub struct BodyMsg<T> { | ||||
|     pub body: T, | ||||
| #[serde(untagged)] | ||||
| pub enum MessageBody<T> { | ||||
|     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<T: DeserializeOwned>(response: &str) -> MxmResult<T> { | ||||
|     let header = serde_json::from_str::<Resp<HeaderMsg>>(response)? | ||||
|         .message | ||||
|         .header; | ||||
|     if header.status_code < 400 { | ||||
|         let body = serde_json::from_str::<Resp<BodyMsg<T>>>(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<T> Resp<T> { | ||||
|     pub fn body_or_err(self) -> MxmResult<T> { | ||||
|         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<N>, | ||||
|     } | ||||
| 
 | ||||
|     impl<N> Visitor<'_> for NullIfZeroVisitor<N> | ||||
|     impl<'de, N> Visitor<'de> for NullIfZeroVisitor<N> | ||||
|     where | ||||
|         N: TryFrom<u64>, | ||||
|     { | ||||
|  | @ -300,7 +310,7 @@ where | |||
| { | ||||
|     struct NullIfEmptyVisitor; | ||||
| 
 | ||||
|     impl Visitor<'_> for NullIfEmptyVisitor { | ||||
|     impl<'de> Visitor<'de> for NullIfEmptyVisitor { | ||||
|         type Value = Option<String>; | ||||
| 
 | ||||
|         fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { | ||||
|  | @ -347,7 +357,7 @@ where | |||
|         n: PhantomData<N>, | ||||
|     } | ||||
| 
 | ||||
|     impl<N> Visitor<'_> for ParseIntVisitor<N> | ||||
|     impl<'de, N> Visitor<'de> for ParseIntVisitor<N> | ||||
|     where | ||||
|         N: FromStr + TryFrom<u64>, | ||||
|     { | ||||
|  | @ -441,7 +451,7 @@ pub mod optional_date { | |||
|     ) -> Result<Option<Date>, D::Error> { | ||||
|         struct OptionalDateVisitor; | ||||
| 
 | ||||
|         impl Visitor<'_> for OptionalDateVisitor { | ||||
|         impl<'de> Visitor<'de> for OptionalDateVisitor { | ||||
|             type Value = Option<Date>; | ||||
| 
 | ||||
|             fn expecting( | ||||
|  | @ -501,7 +511,7 @@ pub mod optional_datetime { | |||
|     ) -> Result<Option<OffsetDateTime>, D::Error> { | ||||
|         struct OptionalDateVisitor; | ||||
| 
 | ||||
|         impl Visitor<'_> for OptionalDateVisitor { | ||||
|         impl<'de> Visitor<'de> for OptionalDateVisitor { | ||||
|             type Value = Option<OffsetDateTime>; | ||||
| 
 | ||||
|             fn expecting( | ||||
|  | @ -543,55 +553,6 @@ pub mod optional_datetime { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| pub fn single_or_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error> | ||||
| where | ||||
|     D: Deserializer<'de>, | ||||
|     T: Deserialize<'de>, | ||||
| { | ||||
|     struct SingleOrVecVisitor<T> { | ||||
|         t: PhantomData<T>, | ||||
|     } | ||||
| 
 | ||||
|     impl<'de, T> Visitor<'de> for SingleOrVecVisitor<T> | ||||
|     where | ||||
|         T: Deserialize<'de>, | ||||
|     { | ||||
|         type Value = Vec<T>; | ||||
| 
 | ||||
|         fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { | ||||
|             formatter.write_str("single object or list") | ||||
|         } | ||||
| 
 | ||||
|         fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> | ||||
|         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<A>(self, mut map: A) -> Result<Self::Value, A::Error> | ||||
|         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::<SubtitleBody>(json).unwrap_err(); | ||||
|         let res = serde_json::from_str::<Resp<SubtitleBody>>(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::<String>(json).unwrap(); | ||||
|         assert_eq!(res, "Hello World"); | ||||
|         let res = serde_json::from_str::<Resp<SubtitleBody>>(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::<Resp<SubtitleBody>>(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::<S>(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<u8>, | ||||
|         } | ||||
| 
 | ||||
|         let res = serde_json::from_str::<S>(r#"{"vec": [1, 2, 3]}"#).unwrap(); | ||||
|         assert_eq!(res.vec, [1, 2, 3]); | ||||
| 
 | ||||
|         let res = serde_json::from_str::<S>(r#"{"vec": {"value": 1}}"#).unwrap(); | ||||
|         assert_eq!(res.vec, [1]); | ||||
| 
 | ||||
|         serde_json::from_str::<S>(r#"{"vec": {"value": 1, "other": "xyz"}}"#).unwrap_err(); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ impl Musixmatch { | |||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/album-get>
 | ||||
|     pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> { | ||||
|         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<Vec<Album>> { | ||||
|         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<Vec<Album>> { | ||||
|         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(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -12,13 +12,12 @@ impl Musixmatch { | |||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
 | ||||
|     pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> { | ||||
|         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(); | ||||
|         } | ||||
| 
 | ||||
|  | @ -26,6 +25,40 @@ impl Musixmatch { | |||
|         Ok(artist_body.artist) | ||||
|     } | ||||
| 
 | ||||
|     /// Get a list of artists somehow related to the one specified by its ID.
 | ||||
|     ///
 | ||||
|     /// # Parameters
 | ||||
|     /// - `id`: [Artist ID](crate::models::ArtistId)
 | ||||
|     /// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
 | ||||
|     /// - `page`: Define the page number for paginated results, starting from 1.
 | ||||
|     ///
 | ||||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/artist-related-get>
 | ||||
|     pub async fn artist_related( | ||||
|         &self, | ||||
|         id: ArtistId<'_>, | ||||
|         page_size: u8, | ||||
|         page: u32, | ||||
|     ) -> Result<Vec<Artist>> { | ||||
|         let mut url = self.new_url("artist.related.get")?; | ||||
|         { | ||||
|             let mut url_query = url.query_pairs_mut(); | ||||
| 
 | ||||
|             let id_param = id.to_param(); | ||||
|             url_query.append_pair(id_param.0, &id_param.1); | ||||
|             url_query.append_pair("page_size", &page_size.to_string()); | ||||
|             url_query.append_pair("page", &page.to_string()); | ||||
|             url_query.finish(); | ||||
|         } | ||||
| 
 | ||||
|         let artist_list_body = self.execute_get_request::<ArtistListBody>(&url).await?; | ||||
|         Ok(artist_list_body | ||||
|             .artist_list | ||||
|             .into_iter() | ||||
|             .map(|a| a.artist) | ||||
|             .collect()) | ||||
|     } | ||||
| 
 | ||||
|     /// Search for artists in the Musixmatch database.
 | ||||
|     ///
 | ||||
|     /// # Parameters
 | ||||
|  | @ -41,7 +74,7 @@ impl Musixmatch { | |||
|         page_size: u8, | ||||
|         page: u32, | ||||
|     ) -> Result<Vec<Artist>> { | ||||
|         let mut url = self.new_url("artist.search"); | ||||
|         let mut url = self.new_url("artist.search")?; | ||||
|         { | ||||
|             let mut url_query = url.query_pairs_mut(); | ||||
| 
 | ||||
|  | @ -74,7 +107,7 @@ impl Musixmatch { | |||
|         page_size: u8, | ||||
|         page: u32, | ||||
|     ) -> Result<Vec<Artist>> { | ||||
|         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(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ impl Musixmatch { | |||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
 | ||||
|     pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result<Lyrics> { | ||||
|         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
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
 | ||||
|     pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> { | ||||
|         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<TranslationList> { | ||||
|         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(); | ||||
|  |  | |||
|  | @ -16,7 +16,7 @@ impl Musixmatch { | |||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
 | ||||
|     pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> { | ||||
|         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(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ impl Musixmatch { | |||
|         f_subtitle_length: Option<f32>, | ||||
|         f_subtitle_length_max_deviation: Option<f32>, | ||||
|     ) -> Result<Subtitle> { | ||||
|         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<f32>, | ||||
|         f_subtitle_length_max_deviation: Option<f32>, | ||||
|     ) -> Result<Subtitle> { | ||||
|         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(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -26,9 +26,8 @@ impl Musixmatch { | |||
|         q_album: &str, | ||||
|         translation_status: bool, | ||||
|         lang_3c: bool, | ||||
|         performer_tagging: bool, | ||||
|     ) -> Result<Track> { | ||||
|         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<Track> { | ||||
|         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<Vec<Track>> { | ||||
|         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<Vec<Track>> { | ||||
|         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
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
 | ||||
|     pub async fn genres(&self) -> Result<Vec<Genre>> { | ||||
|         let url = self.new_url("music.genres.get"); | ||||
|         let url = self.new_url("music.genres.get")?; | ||||
|         let genres = self.execute_get_request::<Genres>(&url).await?; | ||||
|         Ok(genres.music_genre_list) | ||||
|     } | ||||
|  | @ -226,7 +206,7 @@ impl Musixmatch { | |||
|     ///
 | ||||
|     /// # Reference
 | ||||
|     /// <https://developer.musixmatch.com/documentation/api-reference/track-search>
 | ||||
|     pub fn track_search(&self) -> TrackSearchQuery<'_> { | ||||
|     pub fn track_search(&self) -> TrackSearchQuery { | ||||
|         TrackSearchQuery { | ||||
|             mxm: self.clone(), | ||||
|             q_track: None, | ||||
|  | @ -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<Vec<Track>> { | ||||
|         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(); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										20
									
								
								src/error.rs
									
										
									
									
									
								
							
							
						
						
									
										20
									
								
								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<reqwest::Error> for Error { | ||||
|  | @ -47,13 +50,8 @@ impl From<reqwest::Error> for Error { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<serde_json::Error> for Error { | ||||
|     fn from(value: serde_json::Error) -> Self { | ||||
|         Self::InvalidData(value.to_string().into()) | ||||
| impl From<url::ParseError> 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; | ||||
|  |  | |||
							
								
								
									
										268
									
								
								src/lib.rs
									
										
									
									
									
								
							
							
						
						
									
										268
									
								
								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; 29] = b"mNdca@6W7TeEcFn6*3.s97sJ*yPMd"; | ||||
| /// 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<String>, | ||||
|     brand: Option<String>, | ||||
|     device: Option<String>, | ||||
|  | @ -63,6 +84,29 @@ pub struct MusixmatchBuilder { | |||
|     credentials: Option<Credentials>, | ||||
| } | ||||
| 
 | ||||
| /// 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<ClientType> for ClientCfg { | ||||
|     fn from(value: ClientType) -> Self { | ||||
|         match value { | ||||
|             ClientType::Desktop => DESKTOP_CLIENT, | ||||
|             ClientType::Android => ANDROID_CLIENT, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Default)] | ||||
| enum DefaultOpt<T> { | ||||
|     Some(T), | ||||
|  | @ -85,6 +129,8 @@ struct MusixmatchRef { | |||
|     http: Client, | ||||
|     storage: Option<Box<dyn SessionStorage>>, | ||||
|     credentials: RwLock<Option<Credentials>>, | ||||
|     client_type: ClientType, | ||||
|     client_cfg: ClientCfg, | ||||
|     brand: String, | ||||
|     device: String, | ||||
|     usertoken: Mutex<Option<String>>, | ||||
|  | @ -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 <https://www.musixmatch.com> to be
 | ||||
|     /// used. However, as of 2024, this requirement was removed.
 | ||||
|     /// You have to create a free account on <https://www.musixmatch.com> 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<S: Into<String>>(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<Musixmatch> { | ||||
|         let storage = self.storage.or_default(|| Box::<FileStorage>::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::<api_model::GetToken>(&resp_txt)?.user_token; | ||||
|         let tdata = resp.json::<Resp<api_model::GetToken>>().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<api_model::Account> { | ||||
|         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::<api_model::Login>(&resp_txt)?; | ||||
|         let credential = login | ||||
|             .0 | ||||
|             .into_iter() | ||||
|             .next() | ||||
|             .ok_or(Error::InvalidData("no credentials returned".into()))? | ||||
|             .credential; | ||||
|         let login = resp.json::<Resp<api_model::Login>>().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,12 +410,13 @@ 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(), | ||||
|             }; | ||||
| 
 | ||||
|             match serde_json::to_string(&to_store) { | ||||
|                 Ok(json) => storage.write(&json), | ||||
|                 Err(e) => error!("Could not serialize session. Error: {e}"), | ||||
|                 Err(e) => error!("Could not serialize session. Error: {}", e), | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | @ -355,7 +428,7 @@ impl Musixmatch { | |||
|                 .and_then(|json| match serde_json::from_str::<StoredSession>(&json) { | ||||
|                     Ok(session) => Some(session), | ||||
|                     Err(e) => { | ||||
|                         error!("Could not deserialize session. Error: {e}"); | ||||
|                         error!("Could not deserialize session. Error: {}", e); | ||||
|                         None | ||||
|                     } | ||||
|                 }) | ||||
|  | @ -372,12 +445,12 @@ impl Musixmatch { | |||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fn new_url(&self, endpoint: &str) -> reqwest::Url { | ||||
|     fn new_url(&self, endpoint: &str) -> Result<reqwest::Url> { | ||||
|         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::<Resp<T>>().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::<Resp<T>>().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<reqwest::Url> { | ||||
|         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::<Sha1>::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::<u64>(); | ||||
|     format!("{n:016x}") | ||||
|     let mut rng = rand::thread_rng(); | ||||
|     let n = rng.gen::<u64>(); | ||||
|     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::<u32>(), | ||||
|         rng.random::<u16>(), | ||||
|         rng.random::<u16>(), | ||||
|         rng.random::<u16>(), | ||||
|         rng.random::<u64>() & 0xffffffffffff, | ||||
|         rng.gen::<u32>(), | ||||
|         rng.gen::<u16>(), | ||||
|         rng.gen::<u16>(), | ||||
|         rng.gen::<u16>(), | ||||
|         rng.gen::<u64>() & 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::<Sha1>::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)); | ||||
|         assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=78ywxkeXlazpevI%2BbD8E3YluLPc%3D%0A&signature_protocol=sha1") | ||||
|         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") | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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<Date>, | ||||
|     /// Pictures of the artist
 | ||||
|     #[serde(default, deserialize_with = "crate::api_model::single_or_vec")] | ||||
|     pub artist_image: Vec<ArtistImage>, | ||||
| } | ||||
| 
 | ||||
| /// 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<String>, | ||||
|     /// Copyright info for the image
 | ||||
|     #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] | ||||
|     pub image_copyright: Option<String>, | ||||
|     /// Image tags
 | ||||
|     #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] | ||||
|     pub image_tags: Option<String>, | ||||
|     // List of image files scaled to different sizes
 | ||||
|     pub image_format_list: Vec<ImageFormatWrap>, | ||||
| } | ||||
| 
 | ||||
| /// 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, | ||||
| } | ||||
|  |  | |||
							
								
								
									
										139
									
								
								src/models/id.rs
									
										
									
									
									
								
							
							
						
						
									
										139
									
								
								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<Self, Self::Err> { | ||||
|         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<Self, Self::Err> { | ||||
|         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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | ||||
|     where | ||||
|         S: serde::Serializer, | ||||
|     { | ||||
|         serializer.serialize_str(&self.to_string()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl<'de> Deserialize<'de> for Fqid { | ||||
|     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||||
|     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<E>(self, v: &str) -> Result<Self::Value, E> | ||||
|             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::<Fqid>(json).unwrap(); | ||||
|         assert_eq!( | ||||
|             id, | ||||
|             Fqid { | ||||
|                 id: 27853427, | ||||
|                 typ: crate::models::id::MxmEntityType::Artist | ||||
|             } | ||||
|         ); | ||||
|         assert_eq!(serde_json::to_string(&id).unwrap(), json) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -226,7 +226,7 @@ impl Subtitle { | |||
|     /// Only works with [SubtitleFormat::Json].
 | ||||
|     pub fn to_lines(&self) -> Result<SubtitleLines> { | ||||
|         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<Subtitle> for SubtitleLines { | |||
| impl SubtitleLines { | ||||
|     /// Convert subtitles into the [JSON](SubtitleFormat::Json) format
 | ||||
|     pub fn to_json(&self) -> Result<String> { | ||||
|         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
 | ||||
|  |  | |||
|  | @ -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<TrackLyricsTranslationStatus>, | ||||
|     /// Lyrics parts marked with the performer who is singing them
 | ||||
|     pub performer_tagging: Option<TrackPerformerTagging>, | ||||
| } | ||||
| 
 | ||||
| /// 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<PerformerTaggingPart>, | ||||
|     /// 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<Performer>, | ||||
| } | ||||
| 
 | ||||
| /// Lyrics performer
 | ||||
| #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | ||||
| pub struct Performer { | ||||
|     /// artist / unknown
 | ||||
|     #[serde(rename = "type")] | ||||
|     pub typ: Option<String>, | ||||
|     /// Fully-qualified performer ID
 | ||||
|     pub fqid: Option<Fqid>, | ||||
|     /// Unbekannt
 | ||||
|     ///
 | ||||
|     /// 9
 | ||||
|     pub category_id: Option<std::num::NonZeroU32>, | ||||
|     /// Unbekannt
 | ||||
|     ///
 | ||||
|     /// 405
 | ||||
|     pub credit_role_id: Option<std::num::NonZeroU32>, | ||||
| } | ||||
| 
 | ||||
| /// 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<Artist>, | ||||
| } | ||||
| 
 | ||||
| /// Available track charts
 | ||||
| #[derive(Debug, Copy, Clone, PartialEq, Eq)] | ||||
| pub enum ChartName { | ||||
|  |  | |||
							
								
								
									
										756
									
								
								tests/tests.rs
									
										
									
									
									
								
							
							
						
						
									
										756
									
								
								tests/tests.rs
									
										
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue