Compare commits
3 commits
main
...
feat/deskt
Author | SHA1 | Date | |
---|---|---|---|
9904597d8f | |||
2300932afc | |||
6b4312a6a5 |
32 changed files with 845 additions and 1743 deletions
|
@ -1,2 +1,3 @@
|
||||||
|
MUSIXMATCH_CLIENT=Android
|
||||||
MUSIXMATCH_EMAIL=mail@example.com
|
MUSIXMATCH_EMAIL=mail@example.com
|
||||||
MUSIXMATCH_PASSWORD=super-secret
|
MUSIXMATCH_PASSWORD=super-secret
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
name: CI
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Test:
|
|
||||||
runs-on: cimaster-latest
|
|
||||||
services:
|
|
||||||
warpproxy:
|
|
||||||
image: thetadev256/warpproxy
|
|
||||||
env:
|
|
||||||
WARP_DEVICE_ID: ${{ secrets.WARP_DEVICE_ID }}
|
|
||||||
WARP_ACCESS_TOKEN: ${{ secrets.WARP_ACCESS_TOKEN }}
|
|
||||||
WARP_LICENSE_KEY: ${{ secrets.WARP_LICENSE_KEY }}
|
|
||||||
WARP_PRIVATE_KEY: ${{ secrets.WARP_PRIVATE_KEY }}
|
|
||||||
steps:
|
|
||||||
- name: 📦 Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: 🦀 Setup Rust cache
|
|
||||||
uses: https://github.com/Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
cache-on-failure: "true"
|
|
||||||
|
|
||||||
- name: 📎 Clippy
|
|
||||||
run: cargo clippy --all -- -D warnings
|
|
||||||
|
|
||||||
- name: 🧪 Test
|
|
||||||
run: cargo test --workspace
|
|
||||||
env:
|
|
||||||
ALL_PROXY: "http://warpproxy:8124"
|
|
|
@ -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]
|
[package]
|
||||||
name = "musixmatch-inofficial"
|
name = "musixmatch-inofficial"
|
||||||
version = "0.2.1"
|
version = "0.1.0"
|
||||||
rust-version = "1.70.0"
|
edition = "2021"
|
||||||
edition.workspace = true
|
authors = ["ThetaDev <t.testboy@gmail.com>"]
|
||||||
authors.workspace = true
|
license = "MIT"
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
keywords.workspace = true
|
|
||||||
description = "Inofficial client for the Musixmatch API"
|
description = "Inofficial client for the Musixmatch API"
|
||||||
|
keywords = ["music", "lyrics"]
|
||||||
|
|
||||||
include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"]
|
include = ["/src", "README.md", "LICENSE"]
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [".", "cli"]
|
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]
|
[features]
|
||||||
default = ["default-tls"]
|
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"]
|
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.12.0", default-features = false, features = [
|
reqwest = { version = "0.11.11", default-features = false, features = [
|
||||||
"json",
|
"json",
|
||||||
"gzip",
|
"gzip",
|
||||||
] }
|
] }
|
||||||
tokio = { version = "1.20.4" }
|
url = "2.0.0"
|
||||||
|
tokio = { version = "1.20.0" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0.85"
|
serde_json = "1.0.85"
|
||||||
thiserror = "2.0.0"
|
thiserror = "1.0.36"
|
||||||
log = "0.4.17"
|
log = "0.4.17"
|
||||||
time = { version = "0.3.10", features = [
|
time = { version = "0.3.15", features = [
|
||||||
"macros",
|
"macros",
|
||||||
"formatting",
|
"formatting",
|
||||||
"serde",
|
"serde",
|
||||||
"serde-well-known",
|
"serde-well-known",
|
||||||
] }
|
] }
|
||||||
hmac = "0.12.0"
|
hmac = "0.12.1"
|
||||||
sha1 = "0.10.0"
|
sha1 = "0.10.5"
|
||||||
rand = "0.9.0"
|
rand = "0.8.5"
|
||||||
base64 = "0.22.0"
|
base64 = "0.21.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = { version = "0.25.0", default-features = false }
|
ctor = "0.2.0"
|
||||||
|
rstest = { version = "0.18.0", default-features = false }
|
||||||
|
env_logger = "0.11.0"
|
||||||
dotenvy = "0.15.5"
|
dotenvy = "0.15.5"
|
||||||
tokio = { version = "1.20.4", features = ["macros"] }
|
tokio = { version = "1.20.0", features = ["macros"] }
|
||||||
futures = "0.3.21"
|
futures = "0.3.21"
|
||||||
path_macro = "1.0.0"
|
path_macro = "1.0.0"
|
||||||
governor = "0.10.0"
|
serde_plain = "1.0.2"
|
||||||
test-log = "0.2.16"
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
|
44
Justfile
44
Justfile
|
@ -1,44 +0,0 @@
|
||||||
test:
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
release crate="musixmatch-inofficial":
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
CRATE="{{crate}}"
|
|
||||||
INCLUDES='--include-path README.md --include-path LICENSE --include-path Cargo.toml'
|
|
||||||
CHANGELOG="CHANGELOG.md"
|
|
||||||
|
|
||||||
if [ "$CRATE" = "musixmatch-inofficial" ]; then
|
|
||||||
INCLUDES="$INCLUDES --include-path 'src/**' --include-path 'tests/**' --include-path 'testfiles/**'"
|
|
||||||
else
|
|
||||||
if [ ! -d "$CRATE" ]; then
|
|
||||||
echo "$CRATE does not exist."; exit 1
|
|
||||||
fi
|
|
||||||
INCLUDES="$INCLUDES --include-path '$CRATE/**'"
|
|
||||||
CHANGELOG="$CRATE/$CHANGELOG"
|
|
||||||
CRATE="musixmatch-$CRATE" # Add crate name prefix
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION=$(cargo pkgid --package "$CRATE" | tr '#@' '\n' | tail -n 1)
|
|
||||||
TAG="${CRATE}/v${VERSION}"
|
|
||||||
echo "Releasing $TAG:"
|
|
||||||
|
|
||||||
if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi
|
|
||||||
|
|
||||||
CLIFF_ARGS="--tag '${TAG}' --tag-pattern '${CRATE}/v*' --unreleased $INCLUDES"
|
|
||||||
echo "git-cliff $CLIFF_ARGS"
|
|
||||||
if [ -f "$CHANGELOG" ]; then
|
|
||||||
eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'"
|
|
||||||
else
|
|
||||||
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
|
|
||||||
fi
|
|
||||||
|
|
||||||
editor "$CHANGELOG"
|
|
||||||
|
|
||||||
git add .
|
|
||||||
git commit -m "chore(release): release $CRATE v$VERSION"
|
|
||||||
|
|
||||||
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG"
|
|
||||||
|
|
||||||
echo "🚀 Run 'git push origin $TAG' to publish"
|
|
24
README.md
24
README.md
|
@ -1,20 +1,16 @@
|
||||||
# musixmatch-inofficial
|
# Musixmatch-Inofficial
|
||||||
|
|
||||||
[](https://crates.io/crates/musixmatch-inofficial)
|
|
||||||
[](http://opensource.org/licenses/MIT)
|
|
||||||
[](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml)
|
|
||||||
|
|
||||||
This is an inofficial client for the Musixmatch API that uses the key embedded in the
|
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
|
It allows you to obtain synchronized lyrics in different formats
|
||||||
([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>),
|
([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>),
|
||||||
[DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song.
|
[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
|
If you use the Android client, you need a free Musixmatch account
|
||||||
used. However, as of 2024, this requirement was removed and the API can be used
|
([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can
|
||||||
anonymously. The client still allows you to supply credentials if Musixmatch decides to
|
be used anonymously and is currently the default option, but since Musixmatch
|
||||||
close the API down again.
|
discontinued that application, they may shut it down.
|
||||||
|
|
||||||
## ⚠️ Copyright disclaimer
|
## ⚠️ Copyright disclaimer
|
||||||
|
|
||||||
|
@ -31,8 +27,12 @@ their [commercial plans](https://developer.musixmatch.com/plans)) and use their
|
||||||
|
|
||||||
## Development info
|
## Development info
|
||||||
|
|
||||||
The test suite reads Musixmatch credentials from the `MUSIXMATCH_EMAIL` and
|
You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment
|
||||||
`MUSIXMATCH_PASSWORD` environment variables.
|
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
|
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`
|
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]
|
[package]
|
||||||
name = "musixmatch-cli"
|
name = "musixmatch-cli"
|
||||||
version = "0.3.0"
|
version = "0.1.0"
|
||||||
rust-version = "1.70.0"
|
edition = "2021"
|
||||||
edition.workspace = true
|
authors = ["ThetaDev"]
|
||||||
authors.workspace = true
|
license = "MIT"
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
keywords.workspace = true
|
|
||||||
description = "Inofficial command line interface for the Musixmatch API"
|
description = "Inofficial command line interface for the Musixmatch API"
|
||||||
|
keywords = ["music", "lyrics", "cli"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["native-tls"]
|
default = ["rustls-tls-native-roots"]
|
||||||
|
|
||||||
# Reqwest TLS options
|
# Reqwest TLS options
|
||||||
native-tls = ["musixmatch-inofficial/native-tls"]
|
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"]
|
rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
musixmatch-inofficial.workspace = true
|
musixmatch-inofficial = { path = "../" }
|
||||||
tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
|
||||||
clap = { version = "4.0.0", features = ["derive"] }
|
id3 = "1.3.0"
|
||||||
anyhow = "1.0.0"
|
mp3-duration = "0.1.10"
|
||||||
|
clap = { version = "4.0.10", features = ["derive"] }
|
||||||
|
anyhow = "1.0.65"
|
||||||
rpassword = "7.0.0"
|
rpassword = "7.0.0"
|
||||||
dirs = "6.0.0"
|
dirs = "5.0.0"
|
||||||
serde_json = "1.0.85"
|
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
|
|
||||||
...
|
|
||||||
```
|
|
429
cli/src/main.rs
429
cli/src/main.rs
|
@ -1,6 +1,3 @@
|
||||||
#![doc = include_str!("../README.md")]
|
|
||||||
#![warn(missing_docs, clippy::todo)]
|
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io::{stdin, stdout, Write},
|
io::{stdin, stdout, Write},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
|
@ -8,8 +5,9 @@ use std::{
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
use id3::{Tag, TagLike};
|
||||||
use musixmatch_inofficial::{
|
use musixmatch_inofficial::{
|
||||||
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
|
models::{SubtitleFormat, Track, TrackId, TranslationMap},
|
||||||
Musixmatch,
|
Musixmatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -22,7 +20,18 @@ struct Cli {
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Get lyrics text
|
Get {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: GetCommands,
|
||||||
|
},
|
||||||
|
Mp3 {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: FileCommands,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum GetCommands {
|
||||||
Lyrics {
|
Lyrics {
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
ident: TrackIdentifiers,
|
ident: TrackIdentifiers,
|
||||||
|
@ -33,60 +42,26 @@ enum Commands {
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
bi: bool,
|
bi: bool,
|
||||||
},
|
},
|
||||||
/// Get subtitles (time-synced lyrics)
|
|
||||||
Subtitles {
|
Subtitles {
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
ident: TrackIdentifiers,
|
ident: TrackIdentifiers,
|
||||||
/// Track length
|
/// Track length
|
||||||
#[clap(short, long)]
|
#[clap(long, short)]
|
||||||
length: Option<f32>,
|
length: Option<f32>,
|
||||||
/// Maximum deviation from track length (Default: 1s)
|
/// Maximum deviation from track length (Default: 1s)
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
max_deviation: Option<f32>,
|
max_deviation: Option<f32>,
|
||||||
/// Subtitle format
|
/// Subtitle format
|
||||||
#[clap(short, long, default_value = "lrc")]
|
#[clap(long, default_value = "lrc")]
|
||||||
format: SubtitleFormatClap,
|
format: SubtitleFormatClap,
|
||||||
/// Language
|
/// Language
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
lang: Option<String>,
|
lang: Option<String>,
|
||||||
},
|
},
|
||||||
/// Get track metadata
|
|
||||||
Track {
|
Track {
|
||||||
#[clap(flatten)]
|
#[clap(flatten)]
|
||||||
ident: TrackIdentifiers,
|
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)]
|
#[derive(Args)]
|
||||||
|
@ -117,38 +92,22 @@ struct TrackIdentifiers {
|
||||||
isrc: Option<String>,
|
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)]
|
#[derive(Subcommand)]
|
||||||
enum FileCommands {
|
enum FileCommands {
|
||||||
/// Get lyrics text
|
|
||||||
Lyrics {
|
Lyrics {
|
||||||
/// Music file
|
/// Music file
|
||||||
#[clap(value_parser)]
|
#[clap(value_parser)]
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
},
|
},
|
||||||
/// Get subtitles (time-synced lyrics)
|
|
||||||
Subtitles {
|
Subtitles {
|
||||||
/// Music file
|
/// Music file
|
||||||
#[clap(value_parser)]
|
#[clap(value_parser)]
|
||||||
file: PathBuf,
|
file: PathBuf,
|
||||||
/// Subtitle format
|
|
||||||
#[clap(short, long, default_value = "lrc")]
|
|
||||||
format: SubtitleFormatClap,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(clap::ValueEnum, Debug, Copy, Clone)]
|
#[derive(clap::ValueEnum, Debug, Copy, Clone)]
|
||||||
enum SubtitleFormatClap {
|
pub enum SubtitleFormatClap {
|
||||||
Lrc,
|
Lrc,
|
||||||
Ttml,
|
Ttml,
|
||||||
TtmlStructured,
|
TtmlStructured,
|
||||||
|
@ -202,200 +161,164 @@ async fn run(cli: Cli) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Commands::Lyrics { ident, lang, bi } => {
|
Commands::Get { command } => match command {
|
||||||
let track_id = get_track_id(ident, &mxm).await?;
|
GetCommands::Lyrics { ident, lang, bi } => {
|
||||||
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
|
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!("Lyrics ID: {}", lyrics.lyrics_id);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Language: {}",
|
"Language: {}",
|
||||||
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
|
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
|
||||||
);
|
);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Copyright: {}",
|
"Copyright: {}",
|
||||||
lyrics
|
lyrics
|
||||||
.lyrics_copyright
|
.lyrics_copyright
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|c| c.trim())
|
.map(|c| c.trim())
|
||||||
.unwrap_or(NA_STR)
|
.unwrap_or(NA_STR)
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut lyrics_body = lyrics.lyrics_body;
|
let mut lyrics_body = lyrics.lyrics_body;
|
||||||
|
|
||||||
if let Some(lang) = lang {
|
if let Some(lang) = lang {
|
||||||
if Some(&lang) != lyrics.lyrics_language.as_ref() {
|
if Some(&lang) != lyrics.lyrics_language.as_ref() {
|
||||||
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
|
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
|
||||||
if tl.is_empty() {
|
if tl.is_empty() {
|
||||||
eprintln!("Translation not found. Returning lyrics in original language.");
|
eprintln!(
|
||||||
} else {
|
"Translation not found. Returning lyrics in original language."
|
||||||
eprintln!("Translated to: {}", tl.lang);
|
);
|
||||||
let tm = TranslationMap::from(tl);
|
|
||||||
let translated = tm.translate_lyrics(&lyrics_body);
|
|
||||||
lyrics_body = if bi {
|
|
||||||
lyrics_body
|
|
||||||
.lines()
|
|
||||||
.zip(translated.lines())
|
|
||||||
.map(|(a, b)| {
|
|
||||||
if a == b {
|
|
||||||
a.to_string() + "\n"
|
|
||||||
} else {
|
|
||||||
format!("{a}\n> {b}\n")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
translated
|
eprintln!("Translated to: {}", tl.lang);
|
||||||
};
|
let tm = TranslationMap::from(tl);
|
||||||
|
let translated = tm.translate_lyrics(&lyrics_body);
|
||||||
|
lyrics_body = if bi {
|
||||||
|
lyrics_body
|
||||||
|
.lines()
|
||||||
|
.zip(translated.lines())
|
||||||
|
.map(|(a, b)| {
|
||||||
|
if a == b {
|
||||||
|
a.to_string() + "\n"
|
||||||
|
} else {
|
||||||
|
format!("{a}\n> {b}\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
translated
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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!();
|
let title = tag.title().ok_or(anyhow!("no title"))?;
|
||||||
println!("{}", lyrics_body);
|
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
|
||||||
}
|
|
||||||
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?;
|
|
||||||
|
|
||||||
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
|
let lyrics = mxm.matcher_lyrics(title, artist).await?;
|
||||||
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 {
|
println!(
|
||||||
let mut lines = subtitles.to_lines()?;
|
"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 title = tag.title().ok_or(anyhow!("no title"))?;
|
||||||
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
|
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
|
||||||
if tl.is_empty() {
|
|
||||||
bail!("Translation not found")
|
let subtitles = mxm
|
||||||
} else {
|
.matcher_subtitle(
|
||||||
eprintln!("Translated to: {}", tl.lang);
|
title,
|
||||||
let tm = TranslationMap::from(tl);
|
artist,
|
||||||
lines = tm.translate_subtitles(&lines);
|
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);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -409,7 +332,6 @@ async fn get_track_or_id(
|
||||||
ident: TrackIdentifiers,
|
ident: TrackIdentifiers,
|
||||||
mxm: &Musixmatch,
|
mxm: &Musixmatch,
|
||||||
translation_status: bool,
|
translation_status: bool,
|
||||||
performer_tagging: bool,
|
|
||||||
) -> Result<TrackOrId<'static>> {
|
) -> Result<TrackOrId<'static>> {
|
||||||
Ok(
|
Ok(
|
||||||
match (
|
match (
|
||||||
|
@ -435,15 +357,8 @@ async fn get_track_or_id(
|
||||||
}
|
}
|
||||||
(_, _, _, _, _, _, _, Some(isrc)) => TrackOrId::TrackId(TrackId::Isrc(isrc.into())),
|
(_, _, _, _, _, _, _, Some(isrc)) => TrackOrId::TrackId(TrackId::Isrc(isrc.into())),
|
||||||
(Some(name), Some(artist), _, _, _, _, _, _) => TrackOrId::Track(Box::new(
|
(Some(name), Some(artist), _, _, _, _, _, _) => TrackOrId::Track(Box::new(
|
||||||
mxm.matcher_track(
|
mxm.matcher_track(&name, &artist, "", translation_status, true)
|
||||||
&name,
|
.await?,
|
||||||
&artist,
|
|
||||||
"",
|
|
||||||
translation_status,
|
|
||||||
true,
|
|
||||||
performer_tagging,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
)),
|
)),
|
||||||
_ => bail!("no track identifier given"),
|
_ => bail!("no track identifier given"),
|
||||||
},
|
},
|
||||||
|
@ -451,23 +366,17 @@ async fn get_track_or_id(
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_track_id(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<TrackId<'static>> {
|
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::Track(track) => TrackId::TrackId(track.track_id),
|
||||||
TrackOrId::TrackId(id) => id,
|
TrackOrId::TrackId(id) => id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_track(
|
async fn get_track(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<Track> {
|
||||||
ident: TrackIdentifiers,
|
Ok(match get_track_or_id(ident, mxm, true).await? {
|
||||||
mxm: &Musixmatch,
|
TrackOrId::Track(track) => *track,
|
||||||
performer_tagging: bool,
|
TrackOrId::TrackId(id) => mxm.track(id, true, true).await?,
|
||||||
) -> 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?,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn input(prompt: &str) -> String {
|
fn input(prompt: &str) -> String {
|
||||||
|
|
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 std::{marker::PhantomData, str::FromStr};
|
||||||
|
|
||||||
use serde::{
|
use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
|
||||||
de::{DeserializeOwned, Visitor},
|
|
||||||
Deserialize, Deserializer, Serialize,
|
|
||||||
};
|
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use crate::error::{Error, Result as MxmResult};
|
use crate::error::{Error, Result as MxmResult};
|
||||||
|
@ -12,17 +9,24 @@ use crate::error::{Error, Result as MxmResult};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Resp<T> {
|
pub struct Resp<T> {
|
||||||
pub message: T,
|
pub message: Message<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct HeaderMsg {
|
pub struct Message<T> {
|
||||||
pub header: Header,
|
pub header: Header,
|
||||||
|
pub body: Option<MessageBody<T>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct BodyMsg<T> {
|
#[serde(untagged)]
|
||||||
pub body: T,
|
pub enum MessageBody<T> {
|
||||||
|
Some(T),
|
||||||
|
// "body": []
|
||||||
|
EmptyArr(Vec<()>),
|
||||||
|
// "body": {}
|
||||||
|
EmptyObj {},
|
||||||
|
EmptyStr(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
@ -33,24 +37,30 @@ pub struct Header {
|
||||||
pub hint: String,
|
pub hint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_body<T: DeserializeOwned>(response: &str) -> MxmResult<T> {
|
impl<T> Resp<T> {
|
||||||
let header = serde_json::from_str::<Resp<HeaderMsg>>(response)?
|
pub fn body_or_err(self) -> MxmResult<T> {
|
||||||
.message
|
match (self.message.body, self.message.header.status_code < 400) {
|
||||||
.header;
|
(Some(MessageBody::Some(body)), true) => Ok(body),
|
||||||
if header.status_code < 400 {
|
(_, true) => Err(Error::NoData),
|
||||||
let body = serde_json::from_str::<Resp<BodyMsg<T>>>(response)?;
|
(_, false) => {
|
||||||
Ok(body.message.body)
|
if self.message.header.status_code == 404 {
|
||||||
} else if header.status_code == 404 {
|
Err(Error::NotFound)
|
||||||
Err(Error::NotFound)
|
} else if self.message.header.status_code == 401
|
||||||
} else if header.status_code == 401 && header.hint == "renew" {
|
&& self.message.header.hint == "renew"
|
||||||
Err(Error::TokenExpired)
|
{
|
||||||
} else if header.status_code == 401 && header.hint == "captcha" {
|
Err(Error::TokenExpired)
|
||||||
Err(Error::Ratelimit)
|
} else if self.message.header.status_code == 401
|
||||||
} else {
|
&& self.message.header.hint == "captcha"
|
||||||
Err(Error::MusixmatchError {
|
{
|
||||||
status_code: header.status_code,
|
Err(Error::Ratelimit)
|
||||||
msg: header.hint,
|
} 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Account {
|
pub struct Account {
|
||||||
// pub id: String,
|
pub id: String,
|
||||||
// pub email: String,
|
pub email: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,7 +127,7 @@ where
|
||||||
{
|
{
|
||||||
struct BoolFromIntVisitor;
|
struct BoolFromIntVisitor;
|
||||||
|
|
||||||
impl Visitor<'_> for BoolFromIntVisitor {
|
impl<'de> Visitor<'de> for BoolFromIntVisitor {
|
||||||
type Value = bool;
|
type Value = bool;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
@ -232,7 +242,7 @@ where
|
||||||
n: PhantomData<N>,
|
n: PhantomData<N>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<N> Visitor<'_> for NullIfZeroVisitor<N>
|
impl<'de, N> Visitor<'de> for NullIfZeroVisitor<N>
|
||||||
where
|
where
|
||||||
N: TryFrom<u64>,
|
N: TryFrom<u64>,
|
||||||
{
|
{
|
||||||
|
@ -300,7 +310,7 @@ where
|
||||||
{
|
{
|
||||||
struct NullIfEmptyVisitor;
|
struct NullIfEmptyVisitor;
|
||||||
|
|
||||||
impl Visitor<'_> for NullIfEmptyVisitor {
|
impl<'de> Visitor<'de> for NullIfEmptyVisitor {
|
||||||
type Value = Option<String>;
|
type Value = Option<String>;
|
||||||
|
|
||||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||||
|
@ -347,7 +357,7 @@ where
|
||||||
n: PhantomData<N>,
|
n: PhantomData<N>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<N> Visitor<'_> for ParseIntVisitor<N>
|
impl<'de, N> Visitor<'de> for ParseIntVisitor<N>
|
||||||
where
|
where
|
||||||
N: FromStr + TryFrom<u64>,
|
N: FromStr + TryFrom<u64>,
|
||||||
{
|
{
|
||||||
|
@ -441,7 +451,7 @@ pub mod optional_date {
|
||||||
) -> Result<Option<Date>, D::Error> {
|
) -> Result<Option<Date>, D::Error> {
|
||||||
struct OptionalDateVisitor;
|
struct OptionalDateVisitor;
|
||||||
|
|
||||||
impl Visitor<'_> for OptionalDateVisitor {
|
impl<'de> Visitor<'de> for OptionalDateVisitor {
|
||||||
type Value = Option<Date>;
|
type Value = Option<Date>;
|
||||||
|
|
||||||
fn expecting(
|
fn expecting(
|
||||||
|
@ -501,7 +511,7 @@ pub mod optional_datetime {
|
||||||
) -> Result<Option<OffsetDateTime>, D::Error> {
|
) -> Result<Option<OffsetDateTime>, D::Error> {
|
||||||
struct OptionalDateVisitor;
|
struct OptionalDateVisitor;
|
||||||
|
|
||||||
impl Visitor<'_> for OptionalDateVisitor {
|
impl<'de> Visitor<'de> for OptionalDateVisitor {
|
||||||
type Value = Option<OffsetDateTime>;
|
type Value = Option<OffsetDateTime>;
|
||||||
|
|
||||||
fn expecting(
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use time::Date;
|
use time::Date;
|
||||||
|
@ -604,22 +565,57 @@ mod tests {
|
||||||
let json =
|
let json =
|
||||||
r#"{"message":{"header":{"status_code":401,"execute_time":0.002,"hint":"fsck"}}}"#;
|
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!(res.message.header.status_code, 401);
|
||||||
assert_eq!(status_code, 401);
|
assert_eq!(res.message.header.hint, "fsck");
|
||||||
assert_eq!(msg, "fsck");
|
assert!(res.message.body.is_none());
|
||||||
} else {
|
|
||||||
panic!("invalid error: {err}");
|
let err = res.body_or_err().unwrap_err();
|
||||||
}
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Error 401 returned by the Musixmatch API. Message: 'fsck'"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_body() {
|
fn deserialize_emptyarr_body() {
|
||||||
let json = r#"{"message":{"header":{"status_code":200,"execute_time":0.002},"body":"Hello World"}}"#;
|
let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":[]}}"#;
|
||||||
|
|
||||||
let res = parse_body::<String>(json).unwrap();
|
let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
|
||||||
assert_eq!(res, "Hello World");
|
|
||||||
|
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]
|
#[test]
|
||||||
|
@ -735,21 +731,4 @@ mod tests {
|
||||||
let res = serde_json::from_str::<S>(json_date).unwrap();
|
let res = serde_json::from_str::<S>(json_date).unwrap();
|
||||||
assert!(res.date.is_some());
|
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
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/album-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/album-get>
|
||||||
pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> {
|
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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Album>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Album>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
|
|
@ -12,13 +12,12 @@ impl Musixmatch {
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
|
||||||
pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> {
|
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 mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
let id_param = id.to_param();
|
let id_param = id.to_param();
|
||||||
url_query.append_pair(id_param.0, &id_param.1);
|
url_query.append_pair(id_param.0, &id_param.1);
|
||||||
url_query.append_pair("part", "artist_image");
|
|
||||||
url_query.finish();
|
url_query.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +40,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Artist>> {
|
) -> Result<Vec<Artist>> {
|
||||||
let mut url = self.new_url("artist.related.get");
|
let mut url = self.new_url("artist.related.get")?;
|
||||||
{
|
{
|
||||||
let mut url_query = url.query_pairs_mut();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -75,7 +74,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Artist>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -108,7 +107,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Artist>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ impl Musixmatch {
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
|
||||||
pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result<Lyrics> {
|
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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
if !q_track.is_empty() {
|
if !q_track.is_empty() {
|
||||||
|
@ -39,7 +39,7 @@ impl Musixmatch {
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
|
||||||
pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> {
|
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 mut url_query = url.query_pairs_mut();
|
||||||
let id_param = id.to_param();
|
let id_param = id.to_param();
|
||||||
|
@ -66,7 +66,7 @@ impl Musixmatch {
|
||||||
id: TrackId<'_>,
|
id: TrackId<'_>,
|
||||||
selected_language: &str,
|
selected_language: &str,
|
||||||
) -> Result<TranslationList> {
|
) -> 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 mut url_query = url.query_pairs_mut();
|
||||||
let id_param = id.to_param();
|
let id_param = id.to_param();
|
||||||
|
|
|
@ -16,7 +16,7 @@ impl Musixmatch {
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
|
||||||
pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> {
|
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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ impl Musixmatch {
|
||||||
f_subtitle_length: Option<f32>,
|
f_subtitle_length: Option<f32>,
|
||||||
f_subtitle_length_max_deviation: Option<f32>,
|
f_subtitle_length_max_deviation: Option<f32>,
|
||||||
) -> Result<Subtitle> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
if !q_track.is_empty() {
|
if !q_track.is_empty() {
|
||||||
|
@ -73,7 +73,7 @@ impl Musixmatch {
|
||||||
f_subtitle_length: Option<f32>,
|
f_subtitle_length: Option<f32>,
|
||||||
f_subtitle_length_max_deviation: Option<f32>,
|
f_subtitle_length_max_deviation: Option<f32>,
|
||||||
) -> Result<Subtitle> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
|
|
@ -26,9 +26,8 @@ impl Musixmatch {
|
||||||
q_album: &str,
|
q_album: &str,
|
||||||
translation_status: bool,
|
translation_status: bool,
|
||||||
lang_3c: bool,
|
lang_3c: bool,
|
||||||
performer_tagging: bool,
|
|
||||||
) -> Result<Track> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -41,10 +40,8 @@ impl Musixmatch {
|
||||||
if !q_album.is_empty() {
|
if !q_album.is_empty() {
|
||||||
url_query.append_pair("q_album", q_album);
|
url_query.append_pair("q_album", q_album);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut part = Vec::new();
|
|
||||||
if translation_status {
|
if translation_status {
|
||||||
part.push("track_lyrics_translation_status");
|
url_query.append_pair("part", "track_lyrics_translation_status");
|
||||||
url_query.append_pair(
|
url_query.append_pair(
|
||||||
"language_iso_code",
|
"language_iso_code",
|
||||||
match lang_3c {
|
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();
|
url_query.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,18 +73,15 @@ impl Musixmatch {
|
||||||
id: TrackId<'_>,
|
id: TrackId<'_>,
|
||||||
translation_status: bool,
|
translation_status: bool,
|
||||||
lang_3c: bool,
|
lang_3c: bool,
|
||||||
performer_tagging: bool,
|
|
||||||
) -> Result<Track> {
|
) -> 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 mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
let id_param = id.to_param();
|
let id_param = id.to_param();
|
||||||
url_query.append_pair(id_param.0, &id_param.1);
|
url_query.append_pair(id_param.0, &id_param.1);
|
||||||
|
|
||||||
let mut part = Vec::new();
|
|
||||||
if translation_status {
|
if translation_status {
|
||||||
part.push("track_lyrics_translation_status");
|
url_query.append_pair("part", "track_lyrics_translation_status");
|
||||||
url_query.append_pair(
|
url_query.append_pair(
|
||||||
"language_iso_code",
|
"language_iso_code",
|
||||||
match lang_3c {
|
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();
|
url_query.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,7 +114,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Track>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -175,7 +155,7 @@ impl Musixmatch {
|
||||||
page_size: u8,
|
page_size: u8,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<Track>> {
|
) -> 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();
|
let mut url_query = url.query_pairs_mut();
|
||||||
|
|
||||||
|
@ -204,7 +184,7 @@ impl Musixmatch {
|
||||||
/// # Reference
|
/// # Reference
|
||||||
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
|
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
|
||||||
pub async fn genres(&self) -> Result<Vec<Genre>> {
|
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?;
|
let genres = self.execute_get_request::<Genres>(&url).await?;
|
||||||
Ok(genres.music_genre_list)
|
Ok(genres.music_genre_list)
|
||||||
}
|
}
|
||||||
|
@ -367,7 +347,7 @@ impl<'a> TrackSearchQuery<'a> {
|
||||||
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
|
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
|
||||||
/// - `page`: Define the page number for paginated results, starting from 1.
|
/// - `page`: Define the page number for paginated results, starting from 1.
|
||||||
pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> {
|
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();
|
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
|
/// Error message
|
||||||
msg: String,
|
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
|
/// Client requires credentials, but none were given
|
||||||
#[error("You did not input credentials")]
|
#[error("You did not input credentials")]
|
||||||
MissingCredentials,
|
MissingCredentials,
|
||||||
|
@ -32,12 +35,12 @@ pub enum Error {
|
||||||
/// Musixmatch content not available
|
/// Musixmatch content not available
|
||||||
#[error("Unfortunately we're not authorized to show these lyrics")]
|
#[error("Unfortunately we're not authorized to show these lyrics")]
|
||||||
NotAvailable,
|
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 from the HTTP client
|
||||||
#[error("http error: {0}")]
|
#[error("http error: {0}")]
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
|
/// Unspecified error
|
||||||
|
#[error("{0}")]
|
||||||
|
Other(Cow<'static, str>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for Error {
|
impl From<reqwest::Error> for Error {
|
||||||
|
@ -47,13 +50,8 @@ impl From<reqwest::Error> for Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<serde_json::Error> for Error {
|
impl From<url::ParseError> for Error {
|
||||||
fn from(value: serde_json::Error) -> Self {
|
fn from(value: url::ParseError) -> Self {
|
||||||
Self::InvalidData(value.to_string().into())
|
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;
|
|
||||||
|
|
260
src/lib.rs
260
src/lib.rs
|
@ -8,10 +8,11 @@ pub mod models;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
pub use error::{Error, IdError};
|
pub use error::Error;
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
|
@ -28,17 +29,36 @@ use time::macros::format_description;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::api_model::parse_body;
|
use crate::api_model::Resp;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
const YMD_FORMAT: &[time::format_description::FormatItem] =
|
const YMD_FORMAT: &[time::format_description::FormatItem] =
|
||||||
format_description!("[year][month][day]");
|
format_description!("[year][month][day]");
|
||||||
|
|
||||||
const APP_ID: &str = "android-player-v1.0";
|
/// Hardcoded client configuration
|
||||||
const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/";
|
struct ClientCfg {
|
||||||
const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('";
|
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_BRAND: &str = "Google";
|
||||||
const DEFAULT_DEVICE: &str = "Pixel 6";
|
const DEFAULT_DEVICE: &str = "Pixel 6";
|
||||||
|
|
||||||
|
@ -56,6 +76,7 @@ pub struct Musixmatch {
|
||||||
/// Used to construct a new [`Musixmatch`] client.#
|
/// Used to construct a new [`Musixmatch`] client.#
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct MusixmatchBuilder {
|
pub struct MusixmatchBuilder {
|
||||||
|
client_type: ClientType,
|
||||||
user_agent: Option<String>,
|
user_agent: Option<String>,
|
||||||
brand: Option<String>,
|
brand: Option<String>,
|
||||||
device: Option<String>,
|
device: Option<String>,
|
||||||
|
@ -63,6 +84,29 @@ pub struct MusixmatchBuilder {
|
||||||
credentials: Option<Credentials>,
|
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)]
|
#[derive(Default)]
|
||||||
enum DefaultOpt<T> {
|
enum DefaultOpt<T> {
|
||||||
Some(T),
|
Some(T),
|
||||||
|
@ -85,6 +129,8 @@ struct MusixmatchRef {
|
||||||
http: Client,
|
http: Client,
|
||||||
storage: Option<Box<dyn SessionStorage>>,
|
storage: Option<Box<dyn SessionStorage>>,
|
||||||
credentials: RwLock<Option<Credentials>>,
|
credentials: RwLock<Option<Credentials>>,
|
||||||
|
client_type: ClientType,
|
||||||
|
client_cfg: ClientCfg,
|
||||||
brand: String,
|
brand: String,
|
||||||
device: String,
|
device: String,
|
||||||
usertoken: Mutex<Option<String>>,
|
usertoken: Mutex<Option<String>>,
|
||||||
|
@ -98,6 +144,7 @@ struct Credentials {
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
struct StoredSession {
|
struct StoredSession {
|
||||||
|
client_type: ClientType,
|
||||||
usertoken: String,
|
usertoken: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,8 +158,8 @@ impl MusixmatchBuilder {
|
||||||
|
|
||||||
/// Set the Musixmatch credentials
|
/// Set the Musixmatch credentials
|
||||||
///
|
///
|
||||||
/// The Musixmatch API required a free account on <https://www.musixmatch.com> to be
|
/// You have to create a free account on <https://www.musixmatch.com> to use
|
||||||
/// used. However, as of 2024, this requirement was removed.
|
/// the API.
|
||||||
///
|
///
|
||||||
/// The Musixmatch client can be constructed without any credentials.
|
/// The Musixmatch client can be constructed without any credentials.
|
||||||
/// In this case you rely on the stored session token to authenticate
|
/// In this case you rely on the stored session token to authenticate
|
||||||
|
@ -166,6 +213,12 @@ impl MusixmatchBuilder {
|
||||||
self
|
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
|
/// Set the device brand of the Musixmatch client
|
||||||
pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self {
|
pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self {
|
||||||
self.brand = Some(device_brand.into());
|
self.brand = Some(device_brand.into());
|
||||||
|
@ -186,13 +239,18 @@ impl MusixmatchBuilder {
|
||||||
/// Returns a new, configured Musixmatch client using a Reqwest client builder
|
/// Returns a new, configured Musixmatch client using a Reqwest client builder
|
||||||
pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> {
|
pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> {
|
||||||
let storage = self.storage.or_default(|| Box::<FileStorage>::default());
|
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();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap());
|
headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap());
|
||||||
|
|
||||||
let http = client_builder
|
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)
|
.gzip(true)
|
||||||
.default_headers(headers)
|
.default_headers(headers)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
@ -202,6 +260,8 @@ impl MusixmatchBuilder {
|
||||||
http,
|
http,
|
||||||
storage,
|
storage,
|
||||||
credentials: RwLock::new(self.credentials),
|
credentials: RwLock::new(self.credentials),
|
||||||
|
client_type: self.client_type,
|
||||||
|
client_cfg,
|
||||||
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
|
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
|
||||||
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
|
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
|
||||||
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
|
usertoken: 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();
|
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 now = OffsetDateTime::now_utc();
|
||||||
let guid = random_guid();
|
|
||||||
let adv_id = random_uuid();
|
|
||||||
|
|
||||||
// Get user token
|
// Get user token
|
||||||
// The get_token endpoint seems to be rate limited for 2 requests per minute
|
// The get_token endpoint seems to be rate limited for 2 requests per minute
|
||||||
let mut url = Url::parse_with_params(
|
let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get");
|
||||||
&format!("{}{}", API_URL, "token.get"),
|
let mut url = match self.inner.client_type {
|
||||||
&[
|
ClientType::Desktop => Url::parse_with_params(
|
||||||
("adv_id", adv_id.as_str()),
|
&base_url,
|
||||||
("root", "0"),
|
&[
|
||||||
("sideloaded", "0"),
|
("format", "json"),
|
||||||
("app_id", "android-player-v1.0"),
|
("user_language", "en"),
|
||||||
// App version (7.9.5)
|
("app_id", self.inner.client_cfg.app_id),
|
||||||
("build_number", "2022090901"),
|
],
|
||||||
("guid", guid.as_str()),
|
),
|
||||||
("lang", "en_US"),
|
ClientType::Android => {
|
||||||
("model", self.model_string().as_str()),
|
let guid = random_guid();
|
||||||
(
|
let adv_id = random_uuid();
|
||||||
"timestamp",
|
Url::parse_with_params(
|
||||||
now.format(&Rfc3339).unwrap_or_default().as_str(),
|
&base_url,
|
||||||
),
|
&[
|
||||||
("format", "json"),
|
("adv_id", adv_id.as_str()),
|
||||||
],
|
("root", "0"),
|
||||||
)
|
("sideloaded", "0"),
|
||||||
.unwrap();
|
("app_id", self.inner.client_cfg.app_id),
|
||||||
sign_url_with_date(&mut url, now);
|
// 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 = self.inner.http.get(url).send().await?.error_for_status()?;
|
||||||
let resp_txt = resp.text().await?;
|
let tdata = resp.json::<Resp<api_model::GetToken>>().await?;
|
||||||
let usertoken = parse_body::<api_model::GetToken>(&resp_txt)?.user_token;
|
let usertoken = tdata.body_or_err()?.user_token;
|
||||||
info!("Received new usertoken: {}****", &usertoken[0..8]);
|
info!("Received new usertoken: {}****", &usertoken[0..8]);
|
||||||
|
|
||||||
if let Some(credentials) = credentials {
|
if let Some(credentials) = credentials {
|
||||||
|
@ -288,8 +366,8 @@ impl Musixmatch {
|
||||||
usertoken: &str,
|
usertoken: &str,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
) -> Result<api_model::Account> {
|
) -> Result<api_model::Account> {
|
||||||
let mut url = new_url_from_token("credential.post", usertoken);
|
let mut url = self.new_url_from_token("credential.post", usertoken)?;
|
||||||
sign_url_with_date(&mut url, OffsetDateTime::now_utc());
|
self.sign_url_with_date(&mut url, OffsetDateTime::now_utc());
|
||||||
|
|
||||||
let api_credentials = api_model::Credentials {
|
let api_credentials = api_model::Credentials {
|
||||||
credential_list: &[api_model::CredentialWrap {
|
credential_list: &[api_model::CredentialWrap {
|
||||||
|
@ -311,14 +389,8 @@ impl Musixmatch {
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
|
|
||||||
let resp_txt = resp.text().await?;
|
let login = resp.json::<Resp<api_model::Login>>().await?.body_or_err()?;
|
||||||
let login = parse_body::<api_model::Login>(&resp_txt)?;
|
let credential = login.0.into_iter().next().ok_or(Error::NoData)?.credential;
|
||||||
let credential = login
|
|
||||||
.0
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.ok_or(Error::InvalidData("no credentials returned".into()))?
|
|
||||||
.credential;
|
|
||||||
|
|
||||||
match credential {
|
match credential {
|
||||||
api_model::LoginCredential::Account { account } => Ok(account),
|
api_model::LoginCredential::Account { account } => Ok(account),
|
||||||
|
@ -338,6 +410,7 @@ impl Musixmatch {
|
||||||
fn store_session(&self, usertoken: &str) {
|
fn store_session(&self, usertoken: &str) {
|
||||||
if let Some(storage) = &self.inner.storage {
|
if let Some(storage) = &self.inner.storage {
|
||||||
let to_store = StoredSession {
|
let to_store = StoredSession {
|
||||||
|
client_type: self.inner.client_type,
|
||||||
usertoken: usertoken.to_owned(),
|
usertoken: usertoken.to_owned(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -372,12 +445,12 @@ impl Musixmatch {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_url(&self, endpoint: &str) -> reqwest::Url {
|
fn new_url(&self, endpoint: &str) -> Result<reqwest::Url> {
|
||||||
Url::parse_with_params(
|
Url::parse_with_params(
|
||||||
&format!("{}{}", API_URL, endpoint),
|
&format!("{}{}", self.inner.client_cfg.api_url, endpoint),
|
||||||
&[("app_id", APP_ID), ("format", "json")],
|
&[("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<()> {
|
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
|
||||||
|
@ -386,7 +459,7 @@ impl Musixmatch {
|
||||||
.append_pair("usertoken", &usertoken)
|
.append_pair("usertoken", &usertoken)
|
||||||
.finish();
|
.finish();
|
||||||
|
|
||||||
sign_url_with_date(url, OffsetDateTime::now_utc());
|
self.sign_url_with_date(url, OffsetDateTime::now_utc());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -404,9 +477,9 @@ impl Musixmatch {
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.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),
|
Ok(body) => Ok(body),
|
||||||
Err(Error::TokenExpired) => {
|
Err(Error::TokenExpired) => {
|
||||||
info!("Usertoken expired, getting a new one");
|
info!("Usertoken expired, getting a new one");
|
||||||
|
@ -422,8 +495,7 @@ impl Musixmatch {
|
||||||
.await?
|
.await?
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
|
|
||||||
let resp_txt = resp.text().await?;
|
resp.json::<Resp<T>>().await?.body_or_err()
|
||||||
parse_body(&resp_txt)
|
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
|
@ -446,53 +518,53 @@ impl Musixmatch {
|
||||||
password: password.into(),
|
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 {
|
fn random_guid() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
let n = rng.random::<u64>();
|
let n = rng.gen::<u64>();
|
||||||
format!("{:016x}", n)
|
format!("{:016x}", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn random_uuid() -> String {
|
fn random_uuid() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::thread_rng();
|
||||||
format!(
|
format!(
|
||||||
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
|
||||||
rng.random::<u32>(),
|
rng.gen::<u32>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u16>(),
|
rng.gen::<u16>(),
|
||||||
rng.random::<u64>() & 0xffffffffffff,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use time::macros::datetime;
|
use time::macros::datetime;
|
||||||
|
@ -501,8 +573,12 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn t_sign_url() {
|
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();
|
let mut url = Url::parse("https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm").unwrap();
|
||||||
sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
|
mxm.sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
|
||||||
assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=cvXbedVvGneT7o4k8QG6jfk9pAM%3D%0A&signature_protocol=sha1")
|
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.
|
/// Album: an album of songs in the Musixmatch database.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct Album {
|
pub struct Album {
|
||||||
/// Unique Musixmatch Album ID
|
/// Unique Musixmatch Album ID
|
||||||
pub album_id: u64,
|
pub album_id: u64,
|
||||||
|
|
|
@ -15,7 +15,6 @@ pub(crate) struct ArtistListBody {
|
||||||
|
|
||||||
/// Artist: an artist in the Musixmatch database.
|
/// Artist: an artist in the Musixmatch database.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
/// Musixmatch Artist ID
|
/// Musixmatch Artist ID
|
||||||
pub artist_id: u64,
|
pub artist_id: u64,
|
||||||
|
@ -86,14 +85,10 @@ pub struct Artist {
|
||||||
/// End date of the artist's presence
|
/// End date of the artist's presence
|
||||||
#[serde(default, with = "crate::api_model::optional_date")]
|
#[serde(default, with = "crate::api_model::optional_date")]
|
||||||
pub end_date: Option<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)
|
/// Alternative artist name (e.g. different languages)
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct ArtistAlias {
|
pub struct ArtistAlias {
|
||||||
/// Alternative artist name
|
/// Alternative artist name
|
||||||
pub artist_alias: String,
|
pub artist_alias: String,
|
||||||
|
@ -101,7 +96,6 @@ pub struct ArtistAlias {
|
||||||
|
|
||||||
/// Artist name in another language
|
/// Artist name in another language
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct ArtistNameTranslation {
|
pub struct ArtistNameTranslation {
|
||||||
/// Artist name in another language
|
/// Artist name in another language
|
||||||
pub artist_name_translation: ArtistNameTranslationInner,
|
pub artist_name_translation: ArtistNameTranslationInner,
|
||||||
|
@ -109,7 +103,6 @@ pub struct ArtistNameTranslation {
|
||||||
|
|
||||||
/// Alternative artist name (e.g. different languages)
|
/// Alternative artist name (e.g. different languages)
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct ArtistNameTranslationInner {
|
pub struct ArtistNameTranslationInner {
|
||||||
/// Language code (e.g. "EN")
|
/// Language code (e.g. "EN")
|
||||||
///
|
///
|
||||||
|
@ -118,42 +111,3 @@ pub struct ArtistNameTranslationInner {
|
||||||
/// Translated name
|
/// Translated name
|
||||||
pub translation: String,
|
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 std::borrow::Cow;
|
||||||
|
|
||||||
use serde::{de::Visitor, Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::IdError;
|
|
||||||
|
|
||||||
/// Track identifiers from different sources
|
/// Track identifiers from different sources
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
@ -29,7 +25,7 @@ pub enum TrackId<'a> {
|
||||||
Spotify(Cow<'a, str>),
|
Spotify(Cow<'a, str>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrackId<'_> {
|
impl<'a> TrackId<'a> {
|
||||||
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
||||||
match self {
|
match self {
|
||||||
TrackId::Commontrack(id) => ("commontrack_id", id.to_string()),
|
TrackId::Commontrack(id) => ("commontrack_id", id.to_string()),
|
||||||
|
@ -54,7 +50,7 @@ pub enum ArtistId<'a> {
|
||||||
Musicbrainz(&'a str),
|
Musicbrainz(&'a str),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArtistId<'_> {
|
impl<'a> ArtistId<'a> {
|
||||||
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
||||||
match self {
|
match self {
|
||||||
ArtistId::ArtistId(id) => ("artist_id", id.to_string()),
|
ArtistId::ArtistId(id) => ("artist_id", id.to_string()),
|
||||||
|
@ -75,7 +71,7 @@ pub enum AlbumId<'a> {
|
||||||
Musicbrainz(&'a str),
|
Musicbrainz(&'a str),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AlbumId<'_> {
|
impl<'a> AlbumId<'a> {
|
||||||
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
pub(crate) fn to_param(&self) -> (&'static str, String) {
|
||||||
match self {
|
match self {
|
||||||
AlbumId::AlbumId(id) => ("album_id", id.to_string()),
|
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;
|
mod id;
|
||||||
pub use id::AlbumId;
|
pub use id::AlbumId;
|
||||||
pub use id::ArtistId;
|
pub use id::ArtistId;
|
||||||
pub use id::Fqid;
|
|
||||||
pub use id::MxmEntityType;
|
|
||||||
pub use id::SortOrder;
|
pub use id::SortOrder;
|
||||||
pub use id::TrackId;
|
pub use id::TrackId;
|
||||||
|
|
||||||
|
@ -25,12 +23,8 @@ pub use translation::TranslationMap;
|
||||||
|
|
||||||
pub(crate) mod track;
|
pub(crate) mod track;
|
||||||
pub use track::ChartName;
|
pub use track::ChartName;
|
||||||
pub use track::Performer;
|
|
||||||
pub use track::PerformerTaggingPart;
|
|
||||||
pub use track::PerformerTaggingResources;
|
|
||||||
pub use track::Track;
|
pub use track::Track;
|
||||||
pub use track::TrackLyricsTranslationStatus;
|
pub use track::TrackLyricsTranslationStatus;
|
||||||
pub use track::TrackPerformerTagging;
|
|
||||||
|
|
||||||
mod genre;
|
mod genre;
|
||||||
pub use genre::Genre;
|
pub use genre::Genre;
|
||||||
|
|
|
@ -14,7 +14,6 @@ pub(crate) struct SnippetBody {
|
||||||
///
|
///
|
||||||
/// Example: "There's not a thing that I would change"
|
/// Example: "There's not a thing that I would change"
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct Snippet {
|
pub struct Snippet {
|
||||||
/// Unique Musixmatch Snippet ID
|
/// Unique Musixmatch Snippet ID
|
||||||
pub snippet_id: u64,
|
pub snippet_id: u64,
|
||||||
|
|
|
@ -226,7 +226,7 @@ impl Subtitle {
|
||||||
/// Only works with [SubtitleFormat::Json].
|
/// Only works with [SubtitleFormat::Json].
|
||||||
pub fn to_lines(&self) -> Result<SubtitleLines> {
|
pub fn to_lines(&self) -> Result<SubtitleLines> {
|
||||||
Ok(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(),
|
lang: self.subtitle_language.to_owned(),
|
||||||
length: self.subtitle_length,
|
length: self.subtitle_length,
|
||||||
})
|
})
|
||||||
|
@ -256,7 +256,7 @@ impl TryFrom<Subtitle> for SubtitleLines {
|
||||||
impl SubtitleLines {
|
impl SubtitleLines {
|
||||||
/// Convert subtitles into the [JSON](SubtitleFormat::Json) format
|
/// Convert subtitles into the [JSON](SubtitleFormat::Json) format
|
||||||
pub fn to_json(&self) -> Result<String> {
|
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
|
/// Convert subtitles into the [LRC](SubtitleFormat::Lrc) format
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
use super::{Artist, Fqid, Genres};
|
use super::Genres;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct TrackBody {
|
pub(crate) struct TrackBody {
|
||||||
|
@ -140,8 +140,6 @@ pub struct Track {
|
||||||
/// Status of lyrics translation
|
/// Status of lyrics translation
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub track_lyrics_translation_status: Vec<TrackLyricsTranslationStatus>,
|
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)
|
/// Status of lyrics translation (language + progress)
|
||||||
|
@ -158,72 +156,6 @@ pub struct TrackLyricsTranslationStatus {
|
||||||
pub perc: f32,
|
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
|
/// Available track charts
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||||
pub enum ChartName {
|
pub enum ChartName {
|
||||||
|
|
733
tests/tests.rs
733
tests/tests.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue