Compare commits

..

3 commits

Author SHA1 Message Date
9904597d8f
fix: add 60s break to CI workflow
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-03 16:32:27 +01:00
2300932afc
chore: use field steps instead of pipeline for Woodpecker 2024-02-03 16:29:14 +01:00
6b4312a6a5
feat: add Musixmatch Desktop client
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
2024-02-03 16:26:21 +01:00
32 changed files with 716 additions and 1576 deletions

View file

@ -1,2 +1,3 @@
MUSIXMATCH_CLIENT=Android
MUSIXMATCH_EMAIL=mail@example.com
MUSIXMATCH_PASSWORD=super-secret

View file

@ -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"

View file

@ -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 }}"

View file

@ -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:latest
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
View 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

View file

@ -1,45 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [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 -->

View file

@ -1,30 +1,17 @@
[package]
name = "musixmatch-inofficial"
version = "0.1.2"
rust-version = "1.70.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
keywords.workspace = true
version = "0.1.0"
edition = "2021"
authors = ["ThetaDev <t.testboy@gmail.com>"]
license = "MIT"
description = "Inofficial client for the Musixmatch API"
keywords = ["music", "lyrics"]
include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"]
include = ["/src", "README.md", "LICENSE"]
[workspace]
members = [".", "cli"]
[workspace.package]
edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"]
license = "MIT"
repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial"
keywords = ["music", "lyrics"]
categories = ["api-bindings", "multimedia"]
[workspace.dependencies]
musixmatch-inofficial = { version = "0.1.1", path = ".", default-features = false }
[features]
default = ["default-tls"]
@ -37,31 +24,36 @@ rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
[dependencies]
reqwest = { version = "0.12.0", default-features = false, features = [
reqwest = { version = "0.11.11", default-features = false, features = [
"json",
"gzip",
] }
tokio = { version = "1.20.4" }
url = "2.0.0"
tokio = { version = "1.20.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85"
thiserror = "2.0.0"
thiserror = "1.0.36"
log = "0.4.17"
time = { version = "0.3.10", features = [
time = { version = "0.3.15", features = [
"macros",
"formatting",
"serde",
"serde-well-known",
] }
hmac = "0.12.0"
sha1 = "0.10.0"
rand = "0.8.0"
base64 = "0.22.0"
hmac = "0.12.1"
sha1 = "0.10.5"
rand = "0.8.5"
base64 = "0.21.0"
[dev-dependencies]
rstest = { version = "0.23.0", default-features = false }
ctor = "0.2.0"
rstest = { version = "0.18.0", default-features = false }
env_logger = "0.11.0"
dotenvy = "0.15.5"
tokio = { version = "1.20.4", features = ["macros"] }
tokio = { version = "1.20.0", features = ["macros"] }
futures = "0.3.21"
path_macro = "1.0.0"
governor = "0.8.0"
test-log = "0.2.16"
serde_plain = "1.0.2"
[profile.release]
strip = true

View file

@ -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"

View file

@ -1,20 +1,16 @@
# musixmatch-inofficial
[![Current crates.io version](https://img.shields.io/crates/v/musixmatch-inofficial.svg)](https://crates.io/crates/musixmatch-inofficial)
[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT)
[![CI status](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml)
# Musixmatch-Inofficial
This is an inofficial client for the Musixmatch API that uses the key embedded in the
Musixmatch Android app.
Musixmatch Android app or desktop client.
It allows you to obtain synchronized lyrics in different formats
([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>),
[DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song.
The Musixmatch API used to require a free account on <https://www.musixmatch.com> to be
used. However, as of 2024, this requirement was removed and the API can be used
anonymously. The client still allows you to supply credentials if Musixmatch decides to
close the API down again.
If you use the Android client, you need a free Musixmatch account
([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can
be used anonymously and is currently the default option, but since Musixmatch
discontinued that application, they may shut it down.
## ⚠️ Copyright disclaimer
@ -31,8 +27,12 @@ their [commercial plans](https://developer.musixmatch.com/plans)) and use their
## Development info
The test suite reads Musixmatch credentials from the `MUSIXMATCH_EMAIL` and
`MUSIXMATCH_PASSWORD` environment variables.
You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment
variable to either `Desktop` or `Android` (it defaults to Desktop).
Running the tests for the Android client requires Musixmatch credentials. The
credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment
variables.
To make local development easier, I have included `dotenvy` to read the credentials from
an `.env` file. Copy the `.env.example` file in the root directory, rename it to `.env`

View file

@ -1,39 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [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 -->

View file

@ -1,16 +1,14 @@
[package]
name = "musixmatch-cli"
version = "0.2.0"
rust-version = "1.70.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
keywords.workspace = true
version = "0.1.0"
edition = "2021"
authors = ["ThetaDev"]
license = "MIT"
description = "Inofficial command line interface for the Musixmatch API"
keywords = ["music", "lyrics", "cli"]
[features]
default = ["native-tls"]
default = ["rustls-tls-native-roots"]
# Reqwest TLS options
native-tls = ["musixmatch-inofficial/native-tls"]
@ -20,10 +18,12 @@ rustls-tls-webpki-roots = ["musixmatch-inofficial/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"]
[dependencies]
musixmatch-inofficial.workspace = true
tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.0.0", features = ["derive"] }
anyhow = "1.0.0"
musixmatch-inofficial = { path = "../" }
tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
id3 = "1.3.0"
mp3-duration = "0.1.10"
clap = { version = "4.0.10", features = ["derive"] }
anyhow = "1.0.65"
rpassword = "7.0.0"
dirs = "5.0.0"
serde_json = "1.0.85"
serde_json = "1.0.91"

View file

@ -1,77 +0,0 @@
# musixmatch-cli
[![Current crates.io version](https://img.shields.io/crates/v/musixmatch-cli.svg)](https://crates.io/crates/musixmatch-cli)
[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT)
[![CI status](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml)
The Musixmatch CLI allows you to fetch lyrics, subtitles and track metadata from the
command line using the Musixmatch API.
The Musixmatch API used to require a free account on <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
...
```

View file

@ -1,6 +1,3 @@
#![doc = include_str!("../README.md")]
#![warn(missing_docs, clippy::todo)]
use std::{
io::{stdin, stdout, Write},
path::PathBuf,
@ -8,8 +5,9 @@ use std::{
use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand};
use id3::{Tag, TagLike};
use musixmatch_inofficial::{
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
models::{SubtitleFormat, Track, TrackId, TranslationMap},
Musixmatch,
};
@ -22,7 +20,18 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Get lyrics text
Get {
#[command(subcommand)]
command: GetCommands,
},
Mp3 {
#[command(subcommand)]
command: FileCommands,
},
}
#[derive(Subcommand)]
enum GetCommands {
Lyrics {
#[clap(flatten)]
ident: TrackIdentifiers,
@ -33,60 +42,26 @@ enum Commands {
#[clap(long)]
bi: bool,
},
/// Get subtitles (time-synced lyrics)
Subtitles {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Track length
#[clap(short, long)]
#[clap(long, short)]
length: Option<f32>,
/// Maximum deviation from track length (Default: 1s)
#[clap(long)]
max_deviation: Option<f32>,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
#[clap(long, default_value = "lrc")]
format: SubtitleFormatClap,
/// Language
#[clap(long)]
lang: Option<String>,
},
/// Get track metadata
Track {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Get performer tagging
Performer {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Get album metadata
Album {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Get artist metadata
Artist {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Search for Musixmatch tracks
#[group(required = true)]
Search {
/// Track name
#[clap(short, long)]
name: Option<String>,
/// Artist
#[clap(short, long)]
artist: Option<String>,
/// Lyrics
#[clap(short, long)]
lyrics: Option<String>,
/// Search query
query: Option<Vec<String>>,
},
/// Search for Musixmatch artists
SearchArtist { query: Vec<String> },
}
#[derive(Args)]
@ -117,38 +92,22 @@ struct TrackIdentifiers {
isrc: Option<String>,
}
#[derive(Args)]
#[group(multiple = false)]
struct AlbumArtistIdentifiers {
/// Musixmatch-ID
#[clap(long)]
mxm_id: Option<u64>,
/// Musicbrainz-ID
#[clap(long)]
musicbrainz: Option<String>,
}
#[derive(Subcommand)]
enum FileCommands {
/// Get lyrics text
Lyrics {
/// Music file
#[clap(value_parser)]
file: PathBuf,
},
/// Get subtitles (time-synced lyrics)
Subtitles {
/// Music file
#[clap(value_parser)]
file: PathBuf,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
format: SubtitleFormatClap,
},
}
#[derive(clap::ValueEnum, Debug, Copy, Clone)]
enum SubtitleFormatClap {
pub enum SubtitleFormatClap {
Lrc,
Ttml,
TtmlStructured,
@ -202,200 +161,164 @@ async fn run(cli: Cli) -> Result<()> {
};
match cli.command {
Commands::Lyrics { ident, lang, bi } => {
let track_id = get_track_id(ident, &mxm).await?;
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
Commands::Get { command } => match command {
GetCommands::Lyrics { ident, lang, bi } => {
let track_id = get_track_id(ident, &mxm).await?;
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
eprintln!("Lyrics ID: {}", lyrics.lyrics_id);
eprintln!(
"Language: {}",
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
);
eprintln!(
"Copyright: {}",
lyrics
.lyrics_copyright
.as_deref()
.map(|c| c.trim())
.unwrap_or(NA_STR)
);
eprintln!("Lyrics ID: {}", lyrics.lyrics_id);
eprintln!(
"Language: {}",
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
);
eprintln!(
"Copyright: {}",
lyrics
.lyrics_copyright
.as_deref()
.map(|c| c.trim())
.unwrap_or(NA_STR)
);
let mut lyrics_body = lyrics.lyrics_body;
let mut lyrics_body = lyrics.lyrics_body;
if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
eprintln!("Translation not found. Returning lyrics in original language.");
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
eprintln!(
"Translation not found. Returning lyrics in original language."
);
} else {
translated
};
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
} else {
translated
};
}
}
}
eprintln!();
println!("{}", lyrics_body);
}
GetCommands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
if let Some(lang) = lang {
let mut lines = subtitles.to_lines()?;
if Some(&lang) != subtitles.subtitle_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
bail!("Translation not found")
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
lines = tm.translate_subtitles(&lines);
}
}
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
}
GetCommands::Track { ident } => {
let track = get_track(ident, &mxm).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
},
Commands::Mp3 { command } => match command {
FileCommands::Lyrics { file } => {
let tag = Tag::read_from_path(&file)?;
eprintln!();
println!("{}", lyrics_body);
}
Commands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
let title = tag.title().ok_or(anyhow!("no title"))?;
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
let lyrics = mxm.matcher_lyrics(title, artist).await?;
if let Some(lang) = lang {
let mut lines = subtitles.to_lines()?;
println!(
"Lyrics for {} by {}:\n\n{}",
title, artist, lyrics.lyrics_body
);
}
FileCommands::Subtitles { file } => {
let tag = Tag::read_from_path(&file)?;
let duration = mp3_duration::from_path(&file)?;
if Some(&lang) != subtitles.subtitle_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
bail!("Translation not found")
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
lines = tm.translate_subtitles(&lines);
}
}
let title = tag.title().ok_or(anyhow!("no title"))?;
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
let subtitles = mxm
.matcher_subtitle(
title,
artist,
SubtitleFormat::Lrc,
Some(duration.as_secs_f32()),
Some(1.0),
)
.await?;
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
}
Commands::Track { ident } => {
let track = get_track(ident, &mxm, false).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
Commands::Performer { ident } => {
let track = get_track(ident, &mxm, true).await?;
println!(
"{}",
serde_json::to_string_pretty(&track.performer_tagging)?
)
}
Commands::Album { ident } => {
let id = if let Some(id) = ident.mxm_id {
AlbumId::AlbumId(id)
} else if let Some(mb) = &ident.musicbrainz {
AlbumId::Musicbrainz(mb)
} else {
bail!("no album ID specified")
};
let album = mxm.album(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Artist { ident } => {
let id = if let Some(id) = ident.mxm_id {
ArtistId::ArtistId(id)
} else if let Some(mb) = &ident.musicbrainz {
ArtistId::Musicbrainz(mb)
} else {
bail!("no artist ID specified")
};
let album = mxm.artist(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Search {
query,
name,
artist,
lyrics,
} => {
let mut sb = mxm
.track_search()
.s_track_rating(musixmatch_inofficial::models::SortOrder::Desc);
let querystr;
if let Some(q) = &query {
querystr = q.join(" ");
sb = sb.q(&querystr);
}
if let Some(n) = &name {
sb = sb.q_track(n);
}
if let Some(a) = &artist {
sb = sb.q_artist(a);
}
if let Some(l) = &lyrics {
sb = sb.q_lyrics(l);
}
let tracks = sb.send(20, 0).await?;
for t in tracks {
println!(
"{} - {} ({}) ISRC'{}' <https://musixmatch.com/lyrics/{}>",
t.track_name,
t.artist_name,
t.first_release_date.map(|d| d.year()).unwrap_or_default(),
t.track_isrc.unwrap_or_default(),
t.commontrack_vanity_id
);
}
}
Commands::SearchArtist { query } => {
let artists = mxm.artist_search(&query.join(" "), 20, 0).await?;
for a in artists {
println!(
"{} <https://musixmatch.com/artist/{}>",
a.artist_name, a.artist_vanity_id
);
}
}
},
};
Ok(())
}
@ -409,7 +332,6 @@ async fn get_track_or_id(
ident: TrackIdentifiers,
mxm: &Musixmatch,
translation_status: bool,
performer_tagging: bool,
) -> Result<TrackOrId<'static>> {
Ok(
match (
@ -435,15 +357,8 @@ async fn get_track_or_id(
}
(_, _, _, _, _, _, _, Some(isrc)) => TrackOrId::TrackId(TrackId::Isrc(isrc.into())),
(Some(name), Some(artist), _, _, _, _, _, _) => TrackOrId::Track(Box::new(
mxm.matcher_track(
&name,
&artist,
"",
translation_status,
true,
performer_tagging,
)
.await?,
mxm.matcher_track(&name, &artist, "", translation_status, true)
.await?,
)),
_ => bail!("no track identifier given"),
},
@ -451,23 +366,17 @@ async fn get_track_or_id(
}
async fn get_track_id(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<TrackId<'static>> {
Ok(match get_track_or_id(ident, mxm, false, false).await? {
Ok(match get_track_or_id(ident, mxm, false).await? {
TrackOrId::Track(track) => TrackId::TrackId(track.track_id),
TrackOrId::TrackId(id) => id,
})
}
async fn get_track(
ident: TrackIdentifiers,
mxm: &Musixmatch,
performer_tagging: bool,
) -> Result<Track> {
Ok(
match get_track_or_id(ident, mxm, true, performer_tagging).await? {
TrackOrId::Track(track) => *track,
TrackOrId::TrackId(id) => mxm.track(id, true, true, performer_tagging).await?,
},
)
async fn get_track(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<Track> {
Ok(match get_track_or_id(ident, mxm, true).await? {
TrackOrId::Track(track) => *track,
TrackOrId::TrackId(id) => mxm.track(id, true, true).await?,
})
}
fn input(prompt: &str) -> String {

View file

@ -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

View file

@ -1,13 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices"
],
"semanticCommits": "enabled",
"automerge": true,
"automergeStrategy": "squash",
"osvVulnerabilityAlerts": true,
"labels": ["dependency-upgrade"],
"enabledManagers": ["cargo"],
"prHourlyLimit": 5
}

View file

@ -1,9 +1,6 @@
use std::{marker::PhantomData, str::FromStr};
use serde::{
de::{DeserializeOwned, Visitor},
Deserialize, Deserializer, Serialize,
};
use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
use time::OffsetDateTime;
use crate::error::{Error, Result as MxmResult};
@ -12,17 +9,24 @@ use crate::error::{Error, Result as MxmResult};
#[derive(Debug, Deserialize)]
pub struct Resp<T> {
pub message: T,
pub message: Message<T>,
}
#[derive(Debug, Deserialize)]
pub struct HeaderMsg {
pub struct Message<T> {
pub header: Header,
pub body: Option<MessageBody<T>>,
}
#[derive(Debug, Deserialize)]
pub struct BodyMsg<T> {
pub body: T,
#[serde(untagged)]
pub enum MessageBody<T> {
Some(T),
// "body": []
EmptyArr(Vec<()>),
// "body": {}
EmptyObj {},
EmptyStr(String),
}
#[derive(Debug, Deserialize)]
@ -33,24 +37,30 @@ pub struct Header {
pub hint: String,
}
pub fn parse_body<T: DeserializeOwned>(response: &str) -> MxmResult<T> {
let header = serde_json::from_str::<Resp<HeaderMsg>>(response)?
.message
.header;
if header.status_code < 400 {
let body = serde_json::from_str::<Resp<BodyMsg<T>>>(response)?;
Ok(body.message.body)
} else if header.status_code == 404 {
Err(Error::NotFound)
} else if header.status_code == 401 && header.hint == "renew" {
Err(Error::TokenExpired)
} else if header.status_code == 401 && header.hint == "captcha" {
Err(Error::Ratelimit)
} else {
Err(Error::MusixmatchError {
status_code: header.status_code,
msg: header.hint,
})
impl<T> Resp<T> {
pub fn body_or_err(self) -> MxmResult<T> {
match (self.message.body, self.message.header.status_code < 400) {
(Some(MessageBody::Some(body)), true) => Ok(body),
(_, true) => Err(Error::NoData),
(_, false) => {
if self.message.header.status_code == 404 {
Err(Error::NotFound)
} else if self.message.header.status_code == 401
&& self.message.header.hint == "renew"
{
Err(Error::TokenExpired)
} else if self.message.header.status_code == 401
&& self.message.header.hint == "captcha"
{
Err(Error::Ratelimit)
} else {
Err(Error::MusixmatchError {
status_code: self.message.header.status_code,
msg: self.message.header.hint,
})
}
}
}
}
}
@ -99,8 +109,8 @@ pub enum LoginCredential {
#[derive(Debug, Deserialize)]
pub struct Account {
// pub id: String,
// pub email: String,
pub id: String,
pub email: String,
pub name: String,
}
@ -117,7 +127,7 @@ where
{
struct BoolFromIntVisitor;
impl Visitor<'_> for BoolFromIntVisitor {
impl<'de> Visitor<'de> for BoolFromIntVisitor {
type Value = bool;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
@ -232,7 +242,7 @@ where
n: PhantomData<N>,
}
impl<N> Visitor<'_> for NullIfZeroVisitor<N>
impl<'de, N> Visitor<'de> for NullIfZeroVisitor<N>
where
N: TryFrom<u64>,
{
@ -300,7 +310,7 @@ where
{
struct NullIfEmptyVisitor;
impl Visitor<'_> for NullIfEmptyVisitor {
impl<'de> Visitor<'de> for NullIfEmptyVisitor {
type Value = Option<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
@ -347,7 +357,7 @@ where
n: PhantomData<N>,
}
impl<N> Visitor<'_> for ParseIntVisitor<N>
impl<'de, N> Visitor<'de> for ParseIntVisitor<N>
where
N: FromStr + TryFrom<u64>,
{
@ -441,7 +451,7 @@ pub mod optional_date {
) -> Result<Option<Date>, D::Error> {
struct OptionalDateVisitor;
impl Visitor<'_> for OptionalDateVisitor {
impl<'de> Visitor<'de> for OptionalDateVisitor {
type Value = Option<Date>;
fn expecting(
@ -501,7 +511,7 @@ pub mod optional_datetime {
) -> Result<Option<OffsetDateTime>, D::Error> {
struct OptionalDateVisitor;
impl Visitor<'_> for OptionalDateVisitor {
impl<'de> Visitor<'de> for OptionalDateVisitor {
type Value = Option<OffsetDateTime>;
fn expecting(
@ -543,55 +553,6 @@ pub mod optional_datetime {
}
}
pub fn single_or_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
struct SingleOrVecVisitor<T> {
t: PhantomData<T>,
}
impl<'de, T> Visitor<'de> for SingleOrVecVisitor<T>
where
T: Deserialize<'de>,
{
type Value = Vec<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("single object or list")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut res = Vec::new();
while let Some(x) = seq.next_element()? {
res.push(x);
}
Ok(res)
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let (k1, val) = map
.next_entry::<&str, T>()?
.ok_or(serde::de::Error::missing_field("value"))?;
if let Some((k2, _)) = map.next_entry::<&str, serde::de::IgnoredAny>()? {
return Err(serde::de::Error::custom(format!(
"expected only 1 value, got keys `{k1}`, `{k2}`"
)));
}
Ok(vec![val])
}
}
deserializer.deserialize_any(SingleOrVecVisitor { t: PhantomData })
}
#[cfg(test)]
mod tests {
use time::Date;
@ -604,22 +565,57 @@ mod tests {
let json =
r#"{"message":{"header":{"status_code":401,"execute_time":0.002,"hint":"fsck"}}}"#;
let err = parse_body::<SubtitleBody>(json).unwrap_err();
let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
if let Error::MusixmatchError { status_code, msg } = err {
assert_eq!(status_code, 401);
assert_eq!(msg, "fsck");
} else {
panic!("invalid error: {err}");
}
assert_eq!(res.message.header.status_code, 401);
assert_eq!(res.message.header.hint, "fsck");
assert!(res.message.body.is_none());
let err = res.body_or_err().unwrap_err();
assert_eq!(
err.to_string(),
"Error 401 returned by the Musixmatch API. Message: 'fsck'"
);
}
#[test]
fn deserialize_body() {
let json = r#"{"message":{"header":{"status_code":200,"execute_time":0.002},"body":"Hello World"}}"#;
fn deserialize_emptyarr_body() {
let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":[]}}"#;
let res = parse_body::<String>(json).unwrap();
assert_eq!(res, "Hello World");
let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
assert_eq!(res.message.header.status_code, 403);
assert_eq!(res.message.header.hint, "");
assert!(matches!(
res.message.body.as_ref().unwrap(),
MessageBody::EmptyArr(_)
));
let err = res.body_or_err().unwrap_err();
assert_eq!(
err.to_string(),
"Error 403 returned by the Musixmatch API. Message: ''"
);
}
#[test]
fn deserialize_emptyobj_body() {
let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":{}}}"#;
let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
assert_eq!(res.message.header.status_code, 403);
assert_eq!(res.message.header.hint, "");
assert!(matches!(
res.message.body.as_ref().unwrap(),
MessageBody::EmptyObj {}
));
let err = res.body_or_err().unwrap_err();
assert_eq!(
err.to_string(),
"Error 403 returned by the Musixmatch API. Message: ''"
);
}
#[test]
@ -735,21 +731,4 @@ mod tests {
let res = serde_json::from_str::<S>(json_date).unwrap();
assert!(res.date.is_some());
}
#[test]
fn deserialize_single_or_vec() {
#[derive(Deserialize, Debug)]
struct S {
#[serde(deserialize_with = "single_or_vec")]
vec: Vec<u8>,
}
let res = serde_json::from_str::<S>(r#"{"vec": [1, 2, 3]}"#).unwrap();
assert_eq!(res.vec, [1, 2, 3]);
let res = serde_json::from_str::<S>(r#"{"vec": {"value": 1}}"#).unwrap();
assert_eq!(res.vec, [1]);
serde_json::from_str::<S>(r#"{"vec": {"value": 1, "other": "xyz"}}"#).unwrap_err();
}
}

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/album-get>
pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> {
let mut url = self.new_url("album.get");
let mut url = self.new_url("album.get")?;
{
let mut url_query = url.query_pairs_mut();
@ -43,7 +43,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Album>> {
let mut url = self.new_url("artist.albums.get");
let mut url = self.new_url("artist.albums.get")?;
{
let mut url_query = url.query_pairs_mut();
@ -80,7 +80,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Album>> {
let mut url = self.new_url("chart.albums.get");
let mut url = self.new_url("chart.albums.get")?;
{
let mut url_query = url.query_pairs_mut();

View file

@ -12,13 +12,12 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> {
let mut url = self.new_url("artist.get");
let mut url = self.new_url("artist.get")?;
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
url_query.append_pair("part", "artist_image");
url_query.finish();
}
@ -41,7 +40,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> 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();
@ -75,7 +74,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.search");
let mut url = self.new_url("artist.search")?;
{
let mut url_query = url.query_pairs_mut();
@ -108,7 +107,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Artist>> {
let mut url = self.new_url("chart.artists.get");
let mut url = self.new_url("chart.artists.get")?;
{
let mut url_query = url.query_pairs_mut();

View file

@ -14,7 +14,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result<Lyrics> {
let mut url = self.new_url("matcher.lyrics.get");
let mut url = self.new_url("matcher.lyrics.get")?;
{
let mut url_query = url.query_pairs_mut();
if !q_track.is_empty() {
@ -39,7 +39,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> {
let mut url = self.new_url("track.lyrics.get");
let mut url = self.new_url("track.lyrics.get")?;
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();
@ -66,7 +66,7 @@ impl Musixmatch {
id: TrackId<'_>,
selected_language: &str,
) -> Result<TranslationList> {
let mut url = self.new_url("crowd.track.translations.get");
let mut url = self.new_url("crowd.track.translations.get")?;
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();

View file

@ -16,7 +16,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> {
let mut url = self.new_url("track.snippet.get");
let mut url = self.new_url("track.snippet.get")?;
{
let mut url_query = url.query_pairs_mut();

View file

@ -25,7 +25,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>,
) -> Result<Subtitle> {
let mut url = self.new_url("matcher.subtitle.get");
let mut url = self.new_url("matcher.subtitle.get")?;
{
let mut url_query = url.query_pairs_mut();
if !q_track.is_empty() {
@ -73,7 +73,7 @@ impl Musixmatch {
f_subtitle_length: Option<f32>,
f_subtitle_length_max_deviation: Option<f32>,
) -> Result<Subtitle> {
let mut url = self.new_url("track.subtitle.get");
let mut url = self.new_url("track.subtitle.get")?;
{
let mut url_query = url.query_pairs_mut();

View file

@ -26,9 +26,8 @@ impl Musixmatch {
q_album: &str,
translation_status: bool,
lang_3c: bool,
performer_tagging: bool,
) -> Result<Track> {
let mut url = self.new_url("matcher.track.get");
let mut url = self.new_url("matcher.track.get")?;
{
let mut url_query = url.query_pairs_mut();
@ -41,10 +40,8 @@ impl Musixmatch {
if !q_album.is_empty() {
url_query.append_pair("q_album", q_album);
}
let mut part = Vec::new();
if translation_status {
part.push("track_lyrics_translation_status");
url_query.append_pair("part", "track_lyrics_translation_status");
url_query.append_pair(
"language_iso_code",
match lang_3c {
@ -53,13 +50,6 @@ impl Musixmatch {
},
);
}
if performer_tagging {
part.push("track_performer_tagging");
}
if !part.is_empty() {
url_query.append_pair("part", &part.join(","));
}
url_query.finish();
}
@ -83,18 +73,15 @@ impl Musixmatch {
id: TrackId<'_>,
translation_status: bool,
lang_3c: bool,
performer_tagging: bool,
) -> Result<Track> {
let mut url = self.new_url("track.get");
let mut url = self.new_url("track.get")?;
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
let mut part = Vec::new();
if translation_status {
part.push("track_lyrics_translation_status");
url_query.append_pair("part", "track_lyrics_translation_status");
url_query.append_pair(
"language_iso_code",
match lang_3c {
@ -103,13 +90,6 @@ impl Musixmatch {
},
);
}
if performer_tagging {
part.push("track_performer_tagging");
}
if !part.is_empty() {
url_query.append_pair("part", &part.join(","));
}
url_query.finish();
}
@ -134,7 +114,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Track>> {
let mut url = self.new_url("album.tracks.get");
let mut url = self.new_url("album.tracks.get")?;
{
let mut url_query = url.query_pairs_mut();
@ -175,7 +155,7 @@ impl Musixmatch {
page_size: u8,
page: u32,
) -> Result<Vec<Track>> {
let mut url = self.new_url("chart.tracks.get");
let mut url = self.new_url("chart.tracks.get")?;
{
let mut url_query = url.query_pairs_mut();
@ -204,7 +184,7 @@ impl Musixmatch {
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
pub async fn genres(&self) -> Result<Vec<Genre>> {
let url = self.new_url("music.genres.get");
let url = self.new_url("music.genres.get")?;
let genres = self.execute_get_request::<Genres>(&url).await?;
Ok(genres.music_genre_list)
}
@ -367,7 +347,7 @@ impl<'a> TrackSearchQuery<'a> {
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
/// - `page`: Define the page number for paginated results, starting from 1.
pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> {
let mut url = self.mxm.new_url("track.search");
let mut url = self.mxm.new_url("track.search")?;
{
let mut url_query = url.query_pairs_mut();

View file

@ -20,6 +20,9 @@ pub enum Error {
/// Error message
msg: String,
},
/// Musixmatch returned no data or the data that could not be deserialized
#[error("Musixmatch returned no data or data that could not be deserialized")]
NoData,
/// Client requires credentials, but none were given
#[error("You did not input credentials")]
MissingCredentials,
@ -32,12 +35,12 @@ pub enum Error {
/// Musixmatch content not available
#[error("Unfortunately we're not authorized to show these lyrics")]
NotAvailable,
/// Musixmatch returned no data or the data that could not be deserialized
#[error("JSON parsing error: {0}")]
InvalidData(Cow<'static, str>),
/// Error from the HTTP client
#[error("http error: {0}")]
Http(reqwest::Error),
/// Unspecified error
#[error("{0}")]
Other(Cow<'static, str>),
}
impl From<reqwest::Error> for Error {
@ -47,13 +50,8 @@ impl From<reqwest::Error> for Error {
}
}
impl From<serde_json::Error> for Error {
fn from(value: serde_json::Error) -> Self {
Self::InvalidData(value.to_string().into())
impl From<url::ParseError> for Error {
fn from(value: url::ParseError) -> Self {
Self::Other(format!("url parse error: {value}").into())
}
}
/// Could not parse Musixmatch FQID
#[derive(thiserror::Error, Debug)]
#[error("Could not parse Musixmatch FQID")]
pub struct IdError;

View file

@ -8,10 +8,11 @@ pub mod models;
pub mod storage;
use std::fmt::Debug;
use std::ops::Deref;
use std::path::Path;
use std::sync::{Arc, RwLock};
pub use error::{Error, IdError};
pub use error::Error;
use base64::Engine;
use hmac::{Hmac, Mac};
@ -28,17 +29,36 @@ use time::macros::format_description;
use time::OffsetDateTime;
use tokio::sync::Mutex;
use crate::api_model::parse_body;
use crate::api_model::Resp;
use crate::error::Result;
const YMD_FORMAT: &[time::format_description::FormatItem] =
format_description!("[year][month][day]");
const APP_ID: &str = "android-player-v1.0";
const API_URL: &str = "https://apic.musixmatch.com/ws/1.1/";
const SIGNATURE_SECRET: &[u8; 20] = b"967Pn4)N3&R_GBg5$b('";
/// Hardcoded client configuration
struct ClientCfg {
app_id: &'static str,
api_url: &'static str,
signature_secret: &'static [u8; 20],
user_agent: &'static str,
login: bool,
}
const DESKTOP_CLIENT: ClientCfg = ClientCfg {
app_id: "web-desktop-app-v1.0",
api_url: "https://apic-desktop.musixmatch.com/ws/1.1/",
signature_secret: b"IEJ5E8XFaHQvIQNfs7IC",
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Musixmatch/0.19.4 Chrome/58.0.3029.110 Electron/1.7.6 Safari/537.36",
login: false
};
const ANDROID_CLIENT: ClientCfg = ClientCfg {
app_id: "android-player-v1.0",
api_url: "https://apic.musixmatch.com/ws/1.1/",
signature_secret: b"967Pn4)N3&R_GBg5$b('",
user_agent: "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)",
login: true,
};
const DEFAULT_UA: &str = "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)";
const DEFAULT_BRAND: &str = "Google";
const DEFAULT_DEVICE: &str = "Pixel 6";
@ -56,6 +76,7 @@ pub struct Musixmatch {
/// Used to construct a new [`Musixmatch`] client.#
#[derive(Default)]
pub struct MusixmatchBuilder {
client_type: ClientType,
user_agent: Option<String>,
brand: Option<String>,
device: Option<String>,
@ -63,6 +84,29 @@ pub struct MusixmatchBuilder {
credentials: Option<Credentials>,
}
/// Musixmatch client type
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ClientType {
/// The desktop client is used with Musixmatch's electron-based Desktop application.
///
/// The client allows anonymous access and is currently the default option.
///
/// Since Musixmatch's desktop application is discontinued, the client may stop working in the future.
#[default]
Desktop,
/// The Android client requires a (free) Musixmatch account
Android,
}
impl From<ClientType> for ClientCfg {
fn from(value: ClientType) -> Self {
match value {
ClientType::Desktop => DESKTOP_CLIENT,
ClientType::Android => ANDROID_CLIENT,
}
}
}
#[derive(Default)]
enum DefaultOpt<T> {
Some(T),
@ -85,6 +129,8 @@ struct MusixmatchRef {
http: Client,
storage: Option<Box<dyn SessionStorage>>,
credentials: RwLock<Option<Credentials>>,
client_type: ClientType,
client_cfg: ClientCfg,
brand: String,
device: String,
usertoken: Mutex<Option<String>>,
@ -98,6 +144,7 @@ struct Credentials {
#[derive(Debug, Serialize, Deserialize)]
struct StoredSession {
client_type: ClientType,
usertoken: String,
}
@ -111,8 +158,8 @@ impl MusixmatchBuilder {
/// Set the Musixmatch credentials
///
/// The Musixmatch API required a free account on <https://www.musixmatch.com> to be
/// used. However, as of 2024, this requirement was removed.
/// You have to create a free account on <https://www.musixmatch.com> to use
/// the API.
///
/// The Musixmatch client can be constructed without any credentials.
/// In this case you rely on the stored session token to authenticate
@ -166,6 +213,12 @@ impl MusixmatchBuilder {
self
}
/// Set the client type (Desktop, Android) of the Musixmatch client
pub fn client_type(mut self, client_type: ClientType) -> Self {
self.client_type = client_type;
self
}
/// Set the device brand of the Musixmatch client
pub fn device_brand<S: Into<String>>(mut self, device_brand: S) -> Self {
self.brand = Some(device_brand.into());
@ -186,13 +239,18 @@ impl MusixmatchBuilder {
/// Returns a new, configured Musixmatch client using a Reqwest client builder
pub fn build_with_client(self, client_builder: ClientBuilder) -> Result<Musixmatch> {
let storage = self.storage.or_default(|| Box::<FileStorage>::default());
let stored_session = Musixmatch::retrieve_session(&storage);
let stored_session =
Musixmatch::retrieve_session(&storage).filter(|s| s.client_type == self.client_type);
let client_cfg = ClientCfg::from(self.client_type);
let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "AWSELBCORS=0; AWSELB=0".parse().unwrap());
let http = client_builder
.user_agent(self.user_agent.unwrap_or_else(|| DEFAULT_UA.to_owned()))
.user_agent(
self.user_agent
.unwrap_or_else(|| client_cfg.user_agent.to_owned()),
)
.gzip(true)
.default_headers(headers)
.build()?;
@ -202,6 +260,8 @@ impl MusixmatchBuilder {
http,
storage,
credentials: RwLock::new(self.credentials),
client_type: self.client_type,
client_cfg,
brand: self.brand.unwrap_or_else(|| DEFAULT_BRAND.to_owned()),
device: self.device.unwrap_or_else(|| DEFAULT_DEVICE.to_owned()),
usertoken: Mutex::new(stored_session.map(|s| s.usertoken)),
@ -235,42 +295,60 @@ impl Musixmatch {
}
}
let credentials = {
let credentials = if self.inner.client_cfg.login {
let c = self.inner.credentials.read().unwrap();
c.clone()
match c.deref() {
Some(c) => Some(c.clone()),
None => return Err(Error::MissingCredentials),
}
} else {
None
};
let now = OffsetDateTime::now_utc();
let guid = random_guid();
let adv_id = random_uuid();
// Get user token
// The get_token endpoint seems to be rate limited for 2 requests per minute
let mut url = Url::parse_with_params(
&format!("{}{}", API_URL, "token.get"),
&[
("adv_id", adv_id.as_str()),
("root", "0"),
("sideloaded", "0"),
("app_id", "android-player-v1.0"),
// App version (7.9.5)
("build_number", "2022090901"),
("guid", guid.as_str()),
("lang", "en_US"),
("model", self.model_string().as_str()),
(
"timestamp",
now.format(&Rfc3339).unwrap_or_default().as_str(),
),
("format", "json"),
],
)
.unwrap();
sign_url_with_date(&mut url, now);
let base_url = format!("{}{}", self.inner.client_cfg.api_url, "token.get");
let mut url = match self.inner.client_type {
ClientType::Desktop => Url::parse_with_params(
&base_url,
&[
("format", "json"),
("user_language", "en"),
("app_id", self.inner.client_cfg.app_id),
],
),
ClientType::Android => {
let guid = random_guid();
let adv_id = random_uuid();
Url::parse_with_params(
&base_url,
&[
("adv_id", adv_id.as_str()),
("root", "0"),
("sideloaded", "0"),
("app_id", self.inner.client_cfg.app_id),
// App version (7.9.5)
("build_number", "2022090901"),
("guid", guid.as_str()),
("lang", "en_US"),
("model", self.model_string().as_str()),
(
"timestamp",
now.format(&Rfc3339).unwrap_or_default().as_str(),
),
("format", "json"),
("user_language", "en"),
],
)
}
}?;
self.sign_url_with_date(&mut url, now);
let resp = self.inner.http.get(url).send().await?.error_for_status()?;
let resp_txt = resp.text().await?;
let usertoken = parse_body::<api_model::GetToken>(&resp_txt)?.user_token;
let tdata = resp.json::<Resp<api_model::GetToken>>().await?;
let usertoken = tdata.body_or_err()?.user_token;
info!("Received new usertoken: {}****", &usertoken[0..8]);
if let Some(credentials) = credentials {
@ -288,8 +366,8 @@ impl Musixmatch {
usertoken: &str,
credentials: &Credentials,
) -> Result<api_model::Account> {
let mut url = new_url_from_token("credential.post", usertoken);
sign_url_with_date(&mut url, OffsetDateTime::now_utc());
let mut url = self.new_url_from_token("credential.post", usertoken)?;
self.sign_url_with_date(&mut url, OffsetDateTime::now_utc());
let api_credentials = api_model::Credentials {
credential_list: &[api_model::CredentialWrap {
@ -311,14 +389,8 @@ impl Musixmatch {
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
let login = parse_body::<api_model::Login>(&resp_txt)?;
let credential = login
.0
.into_iter()
.next()
.ok_or(Error::InvalidData("no credentials returned".into()))?
.credential;
let login = resp.json::<Resp<api_model::Login>>().await?.body_or_err()?;
let credential = login.0.into_iter().next().ok_or(Error::NoData)?.credential;
match credential {
api_model::LoginCredential::Account { account } => Ok(account),
@ -338,6 +410,7 @@ impl Musixmatch {
fn store_session(&self, usertoken: &str) {
if let Some(storage) = &self.inner.storage {
let to_store = StoredSession {
client_type: self.inner.client_type,
usertoken: usertoken.to_owned(),
};
@ -372,12 +445,12 @@ impl Musixmatch {
)
}
fn new_url(&self, endpoint: &str) -> reqwest::Url {
fn new_url(&self, endpoint: &str) -> Result<reqwest::Url> {
Url::parse_with_params(
&format!("{}{}", API_URL, endpoint),
&[("app_id", APP_ID), ("format", "json")],
&format!("{}{}", self.inner.client_cfg.api_url, endpoint),
&[("app_id", self.inner.client_cfg.app_id), ("format", "json")],
)
.unwrap()
.map_err(Error::from)
}
async fn finish_url(&self, url: &mut Url, force_new_session: bool) -> Result<()> {
@ -386,7 +459,7 @@ impl Musixmatch {
.append_pair("usertoken", &usertoken)
.finish();
sign_url_with_date(url, OffsetDateTime::now_utc());
self.sign_url_with_date(url, OffsetDateTime::now_utc());
Ok(())
}
@ -404,9 +477,9 @@ impl Musixmatch {
.send()
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
let resp_obj = resp.json::<Resp<T>>().await?;
match parse_body(&resp_txt) {
match resp_obj.body_or_err() {
Ok(body) => Ok(body),
Err(Error::TokenExpired) => {
info!("Usertoken expired, getting a new one");
@ -422,8 +495,7 @@ impl Musixmatch {
.await?
.error_for_status()?;
let resp_txt = resp.text().await?;
parse_body(&resp_txt)
resp.json::<Resp<T>>().await?.body_or_err()
}
Err(e) => Err(e),
}
@ -446,6 +518,33 @@ impl Musixmatch {
password: password.into(),
});
}
fn new_url_from_token(&self, endpoint: &str, usertoken: &str) -> Result<reqwest::Url> {
Url::parse_with_params(
&format!("{}{}", self.inner.client_cfg.api_url, endpoint),
&[
("app_id", self.inner.client_cfg.app_id),
("usertoken", usertoken),
("format", "json"),
],
)
.map_err(Error::from)
}
fn sign_url_with_date(&self, url: &mut Url, date: OffsetDateTime) {
let mut mac = Hmac::<Sha1>::new_from_slice(self.inner.client_cfg.signature_secret).unwrap();
mac.update(url.as_str().as_bytes());
mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n";
url.query_pairs_mut()
.append_pair("signature", &sig_b64)
.append_pair("signature_protocol", "sha1")
.finish();
}
}
fn random_guid() -> String {
@ -466,33 +565,6 @@ fn random_uuid() -> String {
)
}
fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url {
Url::parse_with_params(
&format!("{}{}", API_URL, endpoint),
&[
("app_id", APP_ID),
("usertoken", usertoken),
("format", "json"),
],
)
.unwrap()
}
fn sign_url_with_date(url: &mut Url, date: OffsetDateTime) {
let mut mac = Hmac::<Sha1>::new_from_slice(SIGNATURE_SECRET).unwrap();
mac.update(url.as_str().as_bytes());
mac.update(date.format(YMD_FORMAT).unwrap_or_default().as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig) + "\n";
url.query_pairs_mut()
.append_pair("signature", &sig_b64)
.append_pair("signature_protocol", "sha1")
.finish();
}
#[cfg(test)]
mod tests {
use time::macros::datetime;
@ -501,8 +573,12 @@ mod tests {
#[test]
fn t_sign_url() {
let mxm = Musixmatch::builder()
.client_type(ClientType::Android)
.build()
.unwrap();
let mut url = Url::parse("https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm").unwrap();
sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
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")
}
}

View file

@ -15,7 +15,6 @@ pub(crate) struct AlbumListBody {
/// Album: an album of songs in the Musixmatch database.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Album {
/// Unique Musixmatch Album ID
pub album_id: u64,

View file

@ -15,7 +15,6 @@ pub(crate) struct ArtistListBody {
/// Artist: an artist in the Musixmatch database.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Artist {
/// Musixmatch Artist ID
pub artist_id: u64,
@ -86,14 +85,10 @@ pub struct Artist {
/// End date of the artist's presence
#[serde(default, with = "crate::api_model::optional_date")]
pub end_date: Option<Date>,
/// Pictures of the artist
#[serde(default, deserialize_with = "crate::api_model::single_or_vec")]
pub artist_image: Vec<ArtistImage>,
}
/// Alternative artist name (e.g. different languages)
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ArtistAlias {
/// Alternative artist name
pub artist_alias: String,
@ -101,7 +96,6 @@ pub struct ArtistAlias {
/// Artist name in another language
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ArtistNameTranslation {
/// Artist name in another language
pub artist_name_translation: ArtistNameTranslationInner,
@ -109,7 +103,6 @@ pub struct ArtistNameTranslation {
/// Alternative artist name (e.g. different languages)
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ArtistNameTranslationInner {
/// Language code (e.g. "EN")
///
@ -118,42 +111,3 @@ pub struct ArtistNameTranslationInner {
/// Translated name
pub translation: String,
}
/// Artist image
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ArtistImage {
/// ID of the image in the Musixmatch database
pub image_id: u64,
pub image_source_id: u32,
/// Author who created the image
#[serde(default, deserialize_with = "crate::api_model::null_if_empty")]
pub image_author: Option<String>,
/// Copyright info for the image
#[serde(default, deserialize_with = "crate::api_model::null_if_empty")]
pub image_copyright: Option<String>,
/// Image tags
#[serde(default, deserialize_with = "crate::api_model::null_if_empty")]
pub image_tags: Option<String>,
// List of image files scaled to different sizes
pub image_format_list: Vec<ImageFormatWrap>,
}
/// Image file (wrapper struct)
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ImageFormatWrap {
pub image_format: ImageFormat,
}
/// Image file
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ImageFormat {
/// URL to the image file
pub image_url: String,
/// Image width in pixels
pub width: u32,
/// Image height in pixels
pub height: u32,
}

View file

@ -1,8 +1,4 @@
use std::{borrow::Cow, convert::Infallible, fmt::Write, str::FromStr};
use serde::{de::Visitor, Deserialize, Serialize};
use crate::IdError;
use std::borrow::Cow;
/// Track identifiers from different sources
#[derive(Debug, Clone, PartialEq, Eq)]
@ -29,7 +25,7 @@ pub enum TrackId<'a> {
Spotify(Cow<'a, str>),
}
impl TrackId<'_> {
impl<'a> TrackId<'a> {
pub(crate) fn to_param(&self) -> (&'static str, String) {
match self {
TrackId::Commontrack(id) => ("commontrack_id", id.to_string()),
@ -54,7 +50,7 @@ pub enum ArtistId<'a> {
Musicbrainz(&'a str),
}
impl ArtistId<'_> {
impl<'a> ArtistId<'a> {
pub(crate) fn to_param(&self) -> (&'static str, String) {
match self {
ArtistId::ArtistId(id) => ("artist_id", id.to_string()),
@ -75,7 +71,7 @@ pub enum AlbumId<'a> {
Musicbrainz(&'a str),
}
impl AlbumId<'_> {
impl<'a> AlbumId<'a> {
pub(crate) fn to_param(&self) -> (&'static str, String) {
match self {
AlbumId::AlbumId(id) => ("album_id", id.to_string()),
@ -100,130 +96,3 @@ impl SortOrder {
}
}
}
/// Musixmatch fully qualified ID
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Fqid {
/// Numeric Musixmatch ID
pub id: u64,
/// Entity type
pub typ: MxmEntityType,
}
/// Musixmatch entity type
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[allow(missing_docs)]
#[non_exhaustive]
pub enum MxmEntityType {
Artist,
#[serde(other)]
Unknown,
}
impl std::fmt::Display for MxmEntityType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
MxmEntityType::Artist => "artist",
MxmEntityType::Unknown => "unknown",
};
f.write_str(s)
}
}
impl FromStr for MxmEntityType {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"artist" => Self::Artist,
_ => Self::Unknown,
})
}
}
impl std::fmt::Debug for MxmEntityType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self, f)
}
}
impl std::fmt::Display for Fqid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "mxm:{}:{}", self.typ, self.id)
}
}
impl std::fmt::Debug for Fqid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_char('"')?;
std::fmt::Display::fmt(&self, f)?;
f.write_char('"')
}
}
impl FromStr for Fqid {
type Err = IdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let wo_pfx = s.strip_prefix("mxm:").ok_or(IdError)?;
let (typ_s, id_s) = wo_pfx.split_once(':').ok_or(IdError)?;
let id = id_s.parse().map_err(|_| IdError)?;
let typ = typ_s.parse().unwrap();
Ok(Self { id, typ })
}
}
impl Serialize for Fqid {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Fqid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct FqidVisitor;
impl Visitor<'_> for FqidVisitor {
type Value = Fqid;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("Musixmatch FQID")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
v.parse().map_err(serde::de::Error::custom)
}
}
deserializer.deserialize_str(FqidVisitor)
}
}
#[cfg(test)]
mod tests {
use super::Fqid;
#[test]
fn serialize_fqid() {
let json = r#""mxm:artist:27853427""#;
let id = serde_json::from_str::<Fqid>(json).unwrap();
assert_eq!(
id,
Fqid {
id: 27853427,
typ: crate::models::id::MxmEntityType::Artist
}
);
assert_eq!(serde_json::to_string(&id).unwrap(), json)
}
}

View file

@ -10,8 +10,6 @@ pub use subtitle::SubtitleTime;
mod id;
pub use id::AlbumId;
pub use id::ArtistId;
pub use id::Fqid;
pub use id::MxmEntityType;
pub use id::SortOrder;
pub use id::TrackId;
@ -25,12 +23,8 @@ pub use translation::TranslationMap;
pub(crate) mod track;
pub use track::ChartName;
pub use track::Performer;
pub use track::PerformerTaggingPart;
pub use track::PerformerTaggingResources;
pub use track::Track;
pub use track::TrackLyricsTranslationStatus;
pub use track::TrackPerformerTagging;
mod genre;
pub use genre::Genre;

View file

@ -14,7 +14,6 @@ pub(crate) struct SnippetBody {
///
/// Example: "There's not a thing that I would change"
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(missing_docs)]
pub struct Snippet {
/// Unique Musixmatch Snippet ID
pub snippet_id: u64,

View file

@ -226,7 +226,7 @@ impl Subtitle {
/// Only works with [SubtitleFormat::Json].
pub fn to_lines(&self) -> Result<SubtitleLines> {
Ok(SubtitleLines {
lines: serde_json::from_str(&self.subtitle_body)?,
lines: serde_json::from_str(&self.subtitle_body).map_err(|_| Error::NoData)?,
lang: self.subtitle_language.to_owned(),
length: self.subtitle_length,
})
@ -256,7 +256,7 @@ impl TryFrom<Subtitle> for SubtitleLines {
impl SubtitleLines {
/// Convert subtitles into the [JSON](SubtitleFormat::Json) format
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(&self).map_err(Error::from)
serde_json::to_string(&self).map_err(|_| Error::NoData)
}
/// Convert subtitles into the [LRC](SubtitleFormat::Lrc) format

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use super::{Artist, Fqid, Genres};
use super::Genres;
#[derive(Debug, Deserialize)]
pub(crate) struct TrackBody {
@ -140,8 +140,6 @@ pub struct Track {
/// Status of lyrics translation
#[serde(default)]
pub track_lyrics_translation_status: Vec<TrackLyricsTranslationStatus>,
/// Lyrics parts marked with the performer who is singing them
pub performer_tagging: Option<TrackPerformerTagging>,
}
/// Status of lyrics translation (language + progress)
@ -158,73 +156,6 @@ pub struct TrackLyricsTranslationStatus {
pub perc: f32,
}
/// Lyrics parts marked with the performer who is singing them
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[allow(missing_docs)]
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
pub completed: bool,
/// True if the lyrics
pub has_unknown: bool,
/// True if the lyrics contain parts that are intended to be sung by the
/// audience during concerts
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)]
#[allow(missing_docs)]
pub struct PerformerTaggingPart {
/// Part of the lyrics text
///
/// Includes whitespace (spaces and newline characters).
pub snippet: String,
/// Unbekannt
///
/// 0-3
pub position: u32,
/// List of performers singing this part
pub performers: Vec<Performer>,
}
/// Lyrics performer
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[allow(missing_docs)]
pub struct Performer {
/// artist / unknown
#[serde(rename = "type")]
pub typ: 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)]
#[allow(missing_docs)]
pub struct PerformerTaggingResources {
/// List of artists tagged as performers
pub artists: Vec<Artist>,
}
/// Available track charts
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ChartName {

View file

@ -1,36 +1,32 @@
use std::{
num::NonZeroU32,
path::{Path, PathBuf},
sync::LazyLock,
};
use std::path::{Path, PathBuf};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use path_macro::path;
use rstest::{fixture, rstest};
use rstest::rstest;
use time::macros::{date, datetime};
use musixmatch_inofficial::{
models::{AlbumId, ArtistId, TrackId},
Error, Musixmatch,
ClientType, Error, Musixmatch,
};
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name)
#[ctor::ctor]
fn init() {
let _ = dotenvy::dotenv();
env_logger::init();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(new_mxm().login())
.unwrap();
}
#[fixture]
async fn mxm() -> Musixmatch {
static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
static MXM_LIMITER: LazyLock<DefaultDirectRateLimiter> = LazyLock::new(|| {
RateLimiter::direct(Quota::per_second(
// limit 1 request per second for CI runs
NonZeroU32::new(if std::env::var("CI").is_ok() { 1 } else { 4 }).unwrap(),
))
});
fn new_mxm() -> Musixmatch {
let client_type = std::env::var("MUSIXMATCH_CLIENT")
.map(|ctype| serde_plain::from_str::<ClientType>(&ctype).expect("valid client type"))
.unwrap_or_default();
MXM_LIMITER.until_ready().await;
let mut mxm = Musixmatch::builder();
let mut mxm = Musixmatch::builder().client_type(client_type);
if let (Ok(email), Ok(password)) = (
std::env::var("MUSIXMATCH_EMAIL"),
@ -39,10 +35,11 @@ async fn mxm() -> Musixmatch {
mxm = mxm.credentials(email, password);
}
let mxm = mxm.build().unwrap();
mxm.build().unwrap()
}
LOGIN_LOCK.get_or_try_init(|| mxm.login()).await.unwrap();
mxm
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name)
}
mod album {
@ -53,8 +50,8 @@ mod album {
#[case::id(AlbumId::AlbumId(14248253))]
#[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))]
#[tokio::test]
async fn by_id(#[case] album_id: AlbumId<'_>, #[future] mxm: Musixmatch) {
let album = mxm.await.album(album_id).await.unwrap();
async fn by_id(#[case] album_id: AlbumId<'_>) {
let album = new_mxm().album(album_id).await.unwrap();
assert_eq!(album.album_id, 14248253);
assert_eq!(
@ -63,7 +60,7 @@ mod album {
);
assert_eq!(album.album_name, "Gangnam Style (강남스타일)");
assert!(album.album_rating > 20);
assert_eq!(album.album_track_count, 0);
assert_eq!(album.album_track_count, 1);
assert_eq!(album.album_release_date.unwrap(), date!(2012 - 01 - 01));
assert_eq!(album.album_release_type, AlbumType::Single);
assert_eq!(album.artist_id, 410698);
@ -96,25 +93,31 @@ mod album {
);
assert_eq!(album.album_vanity_id, "410698/Gangnam-Style-Single");
assert!(album.updated_time > datetime!(2022-6-3 0:00 UTC));
assert_imgurl(&album.album_coverart_100x100, "/26544045.jpg");
assert_imgurl(&album.album_coverart_350x350, "/26544045_350_350.jpg");
assert_imgurl(&album.album_coverart_500x500, "/26544045_500_500.jpg");
assert_eq!(
album.album_coverart_100x100.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045.jpg"
);
assert_eq!(
album.album_coverart_350x350.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045_350_350.jpg"
);
assert_eq!(
album.album_coverart_500x500.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/5/4/0/4/4/5/26544045_500_500.jpg"
);
}
#[rstest]
#[tokio::test]
async fn album_ep(#[future] mxm: Musixmatch) {
let album = mxm.await.album(AlbumId::AlbumId(23976123)).await.unwrap();
async fn album_ep() {
let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap();
assert_eq!(album.album_name, "Waldbrand EP");
// assert_eq!(album.album_release_type, AlbumType::Ep);
assert_eq!(album.album_release_type, AlbumType::Ep);
assert_eq!(album.album_release_date, Some(date!(2016 - 09 - 30)));
}
#[rstest]
#[tokio::test]
async fn by_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn by_id_missing() {
let err = new_mxm()
.album(AlbumId::AlbumId(999999999999))
.await
.unwrap_err();
@ -122,12 +125,9 @@ mod album {
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
#[ignore]
async fn artist_albums(#[future] mxm: Musixmatch) {
let albums = mxm
.await
async fn artist_albums() {
let albums = new_mxm()
.artist_albums(ArtistId::ArtistId(1039), None, 10, 1)
.await
.unwrap();
@ -135,11 +135,9 @@ mod album {
assert_eq!(albums.len(), 10);
}
#[rstest]
#[tokio::test]
async fn artist_albums_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn artist_albums_missing() {
let err = new_mxm()
.artist_albums(ArtistId::ArtistId(999999999999), None, 10, 1)
.await
.unwrap_err();
@ -147,10 +145,9 @@ mod album {
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn charts(#[future] mxm: Musixmatch) {
let albums = mxm.await.chart_albums("US", 10, 1).await.unwrap();
async fn charts() {
let albums = new_mxm().chart_albums("US", 10, 1).await.unwrap();
assert_eq!(albums.len(), 10);
}
@ -163,8 +160,8 @@ mod artist {
#[case::id(ArtistId::ArtistId(410698))]
#[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))]
#[tokio::test]
async fn by_id(#[case] artist_id: ArtistId<'_>, #[future] mxm: Musixmatch) {
let artist = mxm.await.artist(artist_id).await.unwrap();
async fn by_id(#[case] artist_id: ArtistId<'_>) {
let artist = new_mxm().artist(artist_id).await.unwrap();
// dbg!(&artist);
@ -204,28 +201,11 @@ mod artist {
assert_eq!(artist.begin_date.unwrap(), date!(1977 - 12 - 31));
assert_eq!(artist.end_date_year, None);
assert_eq!(artist.end_date, None);
let image = artist.artist_image.first().expect("artist image");
assert_eq!(image.image_id, 20511);
let image_format = &image
.image_format_list
.iter()
.find(|img| img.image_format.height == 250 && img.image_format.width == 250)
.expect("image format 250px")
.image_format;
assert!(
image_format.image_url.starts_with(
"https://static.musixmatch.com/images-storage/mxmimages/1/1/5/0/2/20511_14.jpg?"
),
"url: {}",
image_format.image_url
);
}
#[rstest]
#[tokio::test]
async fn by_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn by_id_missing() {
let err = new_mxm()
.artist(ArtistId::ArtistId(999999999999))
.await
.unwrap_err();
@ -233,11 +213,9 @@ mod artist {
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn related(#[future] mxm: Musixmatch) {
let artists = mxm
.await
async fn related() {
let artists = new_mxm()
.artist_related(ArtistId::ArtistId(26485840), 10, 1)
.await
.unwrap();
@ -245,11 +223,9 @@ mod artist {
assert_eq!(artists.len(), 10);
}
#[rstest]
#[tokio::test]
async fn related_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn related_missing() {
let err = new_mxm()
.artist_related(ArtistId::ArtistId(999999999999), 10, 1)
.await
.unwrap_err();
@ -257,25 +233,20 @@ mod artist {
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn search(#[future] mxm: Musixmatch) {
let artists = mxm
.await
.artist_search("Snollebollekes", 5, 1)
.await
.unwrap();
async fn search() {
let artists = new_mxm().artist_search("psy", 5, 1).await.unwrap();
assert_eq!(artists.len(), 5);
let artist = &artists[0];
assert_eq!(artist.artist_id, 25344078);
assert_eq!(artist.artist_name, "Snollebollekes");
assert_eq!(artist.artist_id, 410698);
assert_eq!(artist.artist_name, "PSY");
}
#[rstest]
#[tokio::test]
async fn search_empty(#[future] mxm: Musixmatch) {
let artists = mxm
.await
async fn search_empty() {
let artists = new_mxm()
.artist_search(
"Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz",
5,
@ -287,60 +258,50 @@ mod artist {
assert_eq!(artists.len(), 0);
}
#[rstest]
#[tokio::test]
async fn charts(#[future] mxm: Musixmatch) {
let artists = mxm.await.chart_artists("US", 10, 1).await.unwrap();
async fn charts() {
let artists = new_mxm().chart_artists("US", 10, 1).await.unwrap();
assert_eq!(artists.len(), 10);
}
#[rstest]
#[tokio::test]
async fn charts_no_country(#[future] mxm: Musixmatch) {
let artists = mxm.await.chart_artists("XY", 10, 1).await.unwrap();
async fn charts_no_country() {
let artists = new_mxm().chart_artists("XY", 10, 1).await.unwrap();
assert_eq!(artists.len(), 10);
}
}
mod track {
use std::collections::HashMap;
use super::*;
use musixmatch_inofficial::models::{ChartName, MxmEntityType, SortOrder};
use musixmatch_inofficial::models::{ChartName, SortOrder};
#[rstest]
#[case::no_translation(false, false)]
#[case::translation_2c(true, false)]
#[case::translation_3c(true, true)]
#[tokio::test]
async fn from_match(
#[case] translation_status: bool,
#[case] lang_3c: bool,
#[future] mxm: Musixmatch,
) {
let track = mxm
.await
async fn from_match(#[case] translation_status: bool, #[case] lang_3c: bool) {
let track = new_mxm()
.matcher_track(
"Poker Face",
"Lady Gaga",
"The Fame",
translation_status,
lang_3c,
false,
)
.await
.unwrap();
// dbg!(&track);
assert_eq!(track.track_id, 85213841);
// assert_eq!(
// track.track_mbid.unwrap(),
// "080975b0-39b1-493c-ae64-5cb3292409bb"
// );
// assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert_eq!(track.track_id, 15476784);
assert_eq!(
track.track_mbid.unwrap(),
"080975b0-39b1-493c-ae64-5cb3292409bb"
);
assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert!(
track.commontrack_isrcs[0]
.iter()
@ -348,7 +309,7 @@ mod track {
"commontrack_isrcs: {:?}",
&track.commontrack_isrcs[0],
);
assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg");
assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO");
assert!(
track
.commontrack_spotify_ids
@ -368,7 +329,7 @@ mod track {
assert!(track.num_favourite > 50);
assert!(track.lyrics_id.is_some());
assert_eq!(track.subtitle_id.unwrap(), 36450705);
assert_eq!(track.album_id, 20960801);
assert_eq!(track.album_id, 13810402);
assert_eq!(track.album_name, "The Fame");
assert_eq!(track.artist_id, 378462);
assert_eq!(
@ -376,10 +337,18 @@ mod track {
"650e7db6-b795-4eb5-a702-5ea2fc46c848"
);
assert_eq!(track.artist_name, "Lady Gaga");
assert_imgurl(&track.album_coverart_100x100, "/32133892.jpg");
assert_imgurl(&track.album_coverart_350x350, "/32133892_350_350.jpg");
assert_imgurl(&track.album_coverart_500x500, "/32133892_500_500.jpg");
assert_imgurl(&track.album_coverart_800x800, "/32133892_800_800.jpg");
assert_eq!(
track.album_coverart_100x100.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg"
);
assert_eq!(
track.album_coverart_350x350.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_350_350.jpg"
);
assert_eq!(
track.album_coverart_500x500.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_500_500.jpg"
);
assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1");
let first_release = track.first_release_date.unwrap();
assert_eq!(first_release.date(), date!(2008 - 1 - 1));
@ -427,8 +396,8 @@ mod track {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let track = mxm.await.track(track_id, true, false, false).await.unwrap();
async fn from_id(#[case] track_id: TrackId<'_>) {
let track = new_mxm().track(track_id, true, false).await.unwrap();
// dbg!(&track);
@ -449,9 +418,18 @@ mod track {
assert_eq!(track.album_name, "Black Mamba");
assert_eq!(track.artist_id, 46970441);
assert_eq!(track.artist_name, "aespa");
assert_imgurl(&track.album_coverart_100x100, "/52156772.jpg");
assert_imgurl(&track.album_coverart_350x350, "/52156772_350_350.jpg");
assert_imgurl(&track.album_coverart_500x500, "/52156772_500_500.jpg");
assert_eq!(
track.album_coverart_100x100.unwrap(),
"https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772.jpg"
);
assert_eq!(
track.album_coverart_350x350.unwrap(),
"https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772_350_350.jpg"
);
assert_eq!(
track.album_coverart_500x500.unwrap(),
"https://s.mxmcdn.net/images-storage/albums5/2/7/7/6/5/1/52156772_500_500.jpg"
);
assert_eq!(track.commontrack_vanity_id, "aespa/Black-Mamba");
let release_date = track.first_release_date.unwrap();
@ -463,75 +441,25 @@ mod track {
assert!(first_tstatus.perc >= 0.0 && first_tstatus.perc <= 1.0);
}
#[rstest]
#[tokio::test]
#[test_log::test]
async fn performer(#[future] mxm: Musixmatch) {
let track = mxm
.await
.track(TrackId::TrackId(246372347), false, false, true)
.await
.unwrap();
let perf = track.performer_tagging.expect("performer tagging");
assert!(perf.completed);
assert!(!perf.has_unknown);
assert!(!perf.has_fan_chant);
let artists = perf
.resources
.artists
.into_iter()
.map(|a| (a.artist_id, a))
.collect::<HashMap<_, _>>();
assert_eq!(artists.len(), 2);
let sam_smith = &artists[&33491428];
let kim_petras = &artists[&26796706];
assert_eq!(sam_smith.artist_name, "Sam Smith");
assert_eq!(sam_smith.artist_image.len(), 1);
assert_eq!(kim_petras.artist_name, "Kim Petras");
assert_eq!(kim_petras.artist_image.len(), 1);
for part in perf.content {
assert!(!part.snippet.trim().is_empty(), "empty snippet");
assert_gte(part.performers.len(), 1, "part performers");
for performer in &part.performers {
let pid = performer.fqid.expect("performer id");
assert_eq!(pid.typ, MxmEntityType::Artist);
assert!(artists.contains_key(&pid.id))
}
}
}
#[rstest]
#[case::no_translation(false, false)]
#[case::translation_2c(true, false)]
#[case::translation_3c(true, true)]
#[tokio::test]
async fn from_id_translations(
#[case] translation_status: bool,
#[case] lang_3c: bool,
#[future] mxm: Musixmatch,
) {
let track = mxm
.await
.track(
TrackId::Commontrack(47672612),
translation_status,
lang_3c,
false,
)
async fn from_id_translations(#[case] translation_status: bool, #[case] lang_3c: bool) {
let track = new_mxm()
.track(TrackId::TrackId(15476784), translation_status, lang_3c)
.await
.unwrap();
// dbg!(&track);
assert_eq!(track.track_id, 85213841);
// assert_eq!(
// track.track_mbid.unwrap(),
// "080975b0-39b1-493c-ae64-5cb3292409bb"
// );
// assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert_eq!(track.track_id, 15476784);
assert_eq!(
track.track_mbid.unwrap(),
"080975b0-39b1-493c-ae64-5cb3292409bb"
);
assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert!(
track.commontrack_isrcs[0]
.iter()
@ -539,7 +467,7 @@ mod track {
"commontrack_isrcs: {:?}",
&track.commontrack_isrcs[0],
);
assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg");
assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO");
assert!(
track
.commontrack_spotify_ids
@ -559,7 +487,7 @@ mod track {
assert!(track.num_favourite > 50);
assert!(track.lyrics_id.is_some());
assert_eq!(track.subtitle_id.unwrap(), 36450705);
assert_eq!(track.album_id, 20960801);
assert_eq!(track.album_id, 13810402);
assert_eq!(track.album_name, "The Fame");
assert_eq!(track.artist_id, 378462);
assert_eq!(
@ -567,10 +495,18 @@ mod track {
"650e7db6-b795-4eb5-a702-5ea2fc46c848"
);
assert_eq!(track.artist_name, "Lady Gaga");
assert_imgurl(&track.album_coverart_100x100, "/32133892.jpg");
assert_imgurl(&track.album_coverart_350x350, "/32133892_350_350.jpg");
assert_imgurl(&track.album_coverart_500x500, "/32133892_500_500.jpg");
assert_imgurl(&track.album_coverart_800x800, "/32133892_800_800.jpg");
assert_eq!(
track.album_coverart_100x100.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg"
);
assert_eq!(
track.album_coverart_350x350.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_350_350.jpg"
);
assert_eq!(
track.album_coverart_500x500.unwrap(),
"https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636_500_500.jpg"
);
assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1");
let first_release = track.first_release_date.unwrap();
assert_eq!(first_release.date(), date!(2008 - 1 - 1));
@ -611,23 +547,19 @@ mod track {
}
}
#[rstest]
#[tokio::test]
async fn from_id_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
.track(TrackId::TrackId(999999999999), false, false, false)
async fn from_id_missing() {
let err = new_mxm()
.track(TrackId::TrackId(999999999999), false, false)
.await
.unwrap_err();
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn album_tracks(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
async fn album_tracks() {
let tracks = new_mxm()
.album_tracks(AlbumId::AlbumId(17118624), true, 20, 1)
.await
.unwrap();
@ -664,11 +596,9 @@ mod track {
});
}
#[rstest]
#[tokio::test]
async fn album_missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn album_missing() {
let err = new_mxm()
.album_tracks(AlbumId::AlbumId(999999999999), false, 20, 1)
.await
.unwrap_err();
@ -680,9 +610,8 @@ mod track {
#[case::top(ChartName::Top)]
#[case::hot(ChartName::Hot)]
#[tokio::test]
async fn charts(#[case] chart_name: ChartName, #[future] mxm: Musixmatch) {
let tracks = mxm
.await
async fn charts(#[case] chart_name: ChartName) {
let tracks = new_mxm()
.chart_tracks("US", chart_name, true, 20, 1)
.await
.unwrap();
@ -690,11 +619,9 @@ mod track {
assert_eq!(tracks.len(), 20);
}
#[rstest]
#[tokio::test]
async fn search(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
async fn search() {
let tracks = new_mxm()
.track_search()
.q_artist("Lena")
.q_track("Satellite")
@ -713,11 +640,9 @@ mod track {
assert_eq!(track.artist_name, "Lena");
}
#[rstest]
#[tokio::test]
async fn search_lyrics(#[future] mxm: Musixmatch) {
let tracks = mxm
.await
async fn search_lyrics() {
let tracks = new_mxm()
.track_search()
.q_lyrics("the whole world stops and stares for a while")
.s_track_rating(SortOrder::Desc)
@ -725,18 +650,16 @@ mod track {
.await
.unwrap();
assert_gte(tracks.len(), 8, "tracks");
assert_eq!(tracks.len(), 10);
let track = &tracks[0];
assert_eq!(track.track_name, "Just the Way You Are");
assert_eq!(track.artist_name, "Bruno Mars");
}
#[rstest]
#[tokio::test]
async fn search_empty(#[future] mxm: Musixmatch) {
let artists = mxm
.await
async fn search_empty() {
let artists = new_mxm()
.track_search()
.q("Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz")
.send(10, 1)
@ -746,19 +669,16 @@ mod track {
assert_eq!(artists.len(), 0);
}
#[rstest]
#[tokio::test]
async fn genres(#[future] mxm: Musixmatch) {
let genres = mxm.await.genres().await.unwrap();
async fn genres() {
let genres = new_mxm().genres().await.unwrap();
assert!(genres.len() > 360);
dbg!(&genres);
// dbg!(&genres);
}
#[rstest]
#[tokio::test]
async fn snippet(#[future] mxm: Musixmatch) {
let snippet = mxm
.await
async fn snippet() {
let snippet = new_mxm()
.track_snippet(TrackId::Commontrack(8874280))
.await
.unwrap();
@ -780,27 +700,22 @@ mod lyrics {
use super::*;
#[rstest]
#[tokio::test]
async fn from_match(#[future] mxm: Musixmatch) {
let lyrics = mxm.await.matcher_lyrics("Shine", "Spektrem").await.unwrap();
async fn from_match() {
let lyrics = new_mxm().matcher_lyrics("Shine", "Spektrem").await.unwrap();
// dbg!(&lyrics);
assert_eq!(lyrics.lyrics_id, 34583240);
assert_eq!(lyrics.lyrics_id, 25947036);
assert!(!lyrics.instrumental);
assert!(!lyrics.explicit);
assert!(
lyrics
.lyrics_body
.starts_with("Eyes in the sky gazing far into the night\n"),
"got: {}",
lyrics.lyrics_body
);
assert!(lyrics
.lyrics_body
.starts_with("Eyes, in the sky, gazing far into the night\n"));
assert_eq!(lyrics.lyrics_language.unwrap(), "en");
assert_eq!(lyrics.lyrics_language_description.unwrap(), "English");
let copyright = lyrics.lyrics_copyright.unwrap();
assert!(copyright.contains("Jesse Warren"), "copyright: {copyright}",);
assert!(copyright.contains("Kim Jeffeson"), "copyright: {copyright}",);
assert!(lyrics.updated_time > datetime!(2021-6-3 0:00 UTC));
}
@ -811,8 +726,8 @@ mod lyrics {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let lyrics = mxm.await.track_lyrics(track_id).await.unwrap();
async fn from_id(#[case] track_id: TrackId<'_>) {
let lyrics = new_mxm().track_lyrics(track_id).await.unwrap();
// dbg!(&lyrics);
@ -828,11 +743,9 @@ mod lyrics {
}
/// This track has no lyrics
#[rstest]
#[tokio::test]
async fn instrumental(#[future] mxm: Musixmatch) {
let lyrics = mxm
.await
async fn instrumental() {
let lyrics = new_mxm()
.matcher_lyrics("drivers license", "Bobby G")
.await
.unwrap();
@ -848,28 +761,24 @@ mod lyrics {
}
/// This track does not exist
#[rstest]
#[tokio::test]
async fn missing(#[future] mxm: Musixmatch) {
let err = mxm
.await
.track_lyrics(TrackId::Spotify("2gwMMr1a4aXXN5L6KC80Pu".into()))
async fn missing() {
let err = new_mxm()
.track_lyrics(TrackId::Spotify("674JwwTP7xCje81T0DRrLn".into()))
.await
.unwrap_err();
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn download_testdata(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
async fn download_testdata() {
let json_path = testfile("lyrics.json");
if json_path.exists() {
return;
}
let lyrics = mxm
let lyrics = new_mxm()
.track_lyrics(TrackId::Commontrack(18576954))
.await
.unwrap();
@ -878,16 +787,14 @@ mod lyrics {
serde_json::to_writer_pretty(BufWriter::new(json_file), &lyrics).unwrap();
}
#[rstest]
#[tokio::test]
async fn download_testdata_translation(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
async fn download_testdata_translation() {
let json_path = testfile("translation.json");
if json_path.exists() {
return;
}
let translations = mxm
let translations = new_mxm()
.track_lyrics_translation(TrackId::Commontrack(18576954), "de")
.await
.unwrap();
@ -896,10 +803,9 @@ mod lyrics {
serde_json::to_writer_pretty(BufWriter::new(json_file), &translations).unwrap();
}
#[rstest]
#[tokio::test]
async fn concurrency(#[future] mxm: Musixmatch) {
let mxm = mxm.await;
async fn concurrency() {
let mxm = new_mxm();
let album = mxm
.album_tracks(
@ -934,11 +840,9 @@ mod subtitles {
use super::*;
use musixmatch_inofficial::models::SubtitleFormat;
#[rstest]
#[tokio::test]
async fn from_match(#[future] mxm: Musixmatch) {
let subtitle = mxm
.await
async fn from_match() {
let subtitle = new_mxm()
.matcher_subtitle(
"Shine",
"Spektrem",
@ -951,12 +855,12 @@ mod subtitles {
// dbg!(&subtitle);
assert_eq!(subtitle.subtitle_id, 35340319);
assert_eq!(subtitle.subtitle_id, 36913312);
assert_eq!(subtitle.subtitle_language.unwrap(), "en");
assert_eq!(subtitle.subtitle_language_description.unwrap(), "English");
let copyright = subtitle.lyrics_copyright.unwrap();
assert!(copyright.contains("Jesse Warren"), "copyright: {copyright}",);
assert_eq!(subtitle.subtitle_length, 316);
assert!(copyright.contains("Kim Jeffeson"), "copyright: {copyright}",);
assert_eq!(subtitle.subtitle_length, 315);
assert!(subtitle.updated_time > datetime!(2021-6-30 0:00 UTC));
}
@ -967,9 +871,8 @@ mod subtitles {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) {
let subtitle = mxm
.await
async fn from_id(#[case] track_id: TrackId<'_>) {
let subtitle = new_mxm()
.track_subtitle(track_id, SubtitleFormat::Json, Some(175.0), Some(1.0))
.await
.unwrap();
@ -989,11 +892,9 @@ mod subtitles {
}
/// This track has no lyrics
#[rstest]
#[tokio::test]
async fn instrumental(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn instrumental() {
let err = new_mxm()
.matcher_subtitle(
"drivers license",
"Bobby G",
@ -1008,11 +909,9 @@ mod subtitles {
}
/// This track has not been synced
#[rstest]
#[tokio::test]
async fn unsynced(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn unsynced() {
let err = new_mxm()
.track_subtitle(
TrackId::Spotify("6oaWIABGL7eeiMILEDyGX1".into()),
SubtitleFormat::Json,
@ -1026,11 +925,9 @@ mod subtitles {
}
/// Try to get subtitles with wrong length parameter
#[rstest]
#[tokio::test]
async fn wrong_length(#[future] mxm: Musixmatch) {
let err = mxm
.await
async fn wrong_length() {
let err = new_mxm()
.track_subtitle(
TrackId::Commontrack(118480583),
SubtitleFormat::Json,
@ -1043,16 +940,14 @@ mod subtitles {
assert!(matches!(err, Error::NotFound));
}
#[rstest]
#[tokio::test]
async fn download_testdata(#[future] mxm: Musixmatch) {
async fn download_testdata() {
let json_path = testfile("subtitles.json");
if json_path.exists() {
return;
}
let subtitle = mxm
.await
let subtitle = new_mxm()
.track_subtitle(
TrackId::Commontrack(18576954),
SubtitleFormat::Json,
@ -1103,20 +998,16 @@ mod translation {
}
}
#[track_caller]
fn assert_imgurl(url: &Option<String>, ends_with: &str) {
assert!(
url.as_deref().is_some_and(
|url| url.starts_with("https://s.mxmcdn.net/images-storage/")
&& url.ends_with(ends_with)
),
"expected url ending with {ends_with}\ngot {:?}",
url
);
}
/// Assert that number A is greater than or equal to number B
#[track_caller]
fn assert_gte<T: PartialOrd + std::fmt::Display>(a: T, b: T, msg: &str) {
assert!(a >= b, "expected >= {b} {msg}, got {a}");
#[tokio::test]
async fn no_credentials() {
let mxm = Musixmatch::builder()
.client_type(ClientType::Android)
.no_storage()
.build()
.unwrap();
let err = mxm
.track_lyrics(TrackId::TrackId(205688271))
.await
.unwrap_err();
assert!(matches!(err, Error::MissingCredentials), "error: {err}");
}