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
26 changed files with 692 additions and 1109 deletions

View file

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

View file

@ -1,29 +0,0 @@
name: CI
on: [push, pull_request]
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] [package]
name = "musixmatch-inofficial" name = "musixmatch-inofficial"
version = "0.1.2" version = "0.1.0"
rust-version = "1.70.0" edition = "2021"
edition.workspace = true authors = ["ThetaDev <t.testboy@gmail.com>"]
authors.workspace = true license = "MIT"
license.workspace = true
repository.workspace = true
keywords.workspace = true
description = "Inofficial client for the Musixmatch API" description = "Inofficial client for the Musixmatch API"
keywords = ["music", "lyrics"]
include = ["/src", "README.md", "CHANGELOG.md", "LICENSE"] include = ["/src", "README.md", "LICENSE"]
[workspace] [workspace]
members = [".", "cli"] members = [".", "cli"]
[workspace.package]
edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"]
license = "MIT"
repository = "https://codeberg.org/ThetaDev/musixmatch-inofficial"
keywords = ["music", "lyrics"]
categories = ["api-bindings", "multimedia"]
[workspace.dependencies]
musixmatch-inofficial = { version = "0.1.1", path = ".", default-features = false }
[features] [features]
default = ["default-tls"] default = ["default-tls"]
@ -37,30 +24,36 @@ rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"]
[dependencies] [dependencies]
reqwest = { version = "0.12.0", default-features = false, features = [ reqwest = { version = "0.11.11", default-features = false, features = [
"json", "json",
"gzip", "gzip",
] } ] }
tokio = { version = "1.20.4" } url = "2.0.0"
tokio = { version = "1.20.0" }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.85" serde_json = "1.0.85"
thiserror = "2.0.0" thiserror = "1.0.36"
log = "0.4.17" log = "0.4.17"
time = { version = "0.3.10", features = [ time = { version = "0.3.15", features = [
"macros", "macros",
"formatting", "formatting",
"serde", "serde",
"serde-well-known", "serde-well-known",
] } ] }
hmac = "0.12.0" hmac = "0.12.1"
sha1 = "0.10.0" sha1 = "0.10.5"
rand = "0.8.0" rand = "0.8.5"
base64 = "0.22.0" base64 = "0.21.0"
[dev-dependencies] [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" dotenvy = "0.15.5"
tokio = { version = "1.20.4", features = ["macros"] } tokio = { version = "1.20.0", features = ["macros"] }
futures = "0.3.21" futures = "0.3.21"
path_macro = "1.0.0" path_macro = "1.0.0"
governor = "0.7.0" 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 # 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)
This is an inofficial client for the Musixmatch API that uses the key embedded in the This is an inofficial client for the Musixmatch API that uses the key embedded in the
Musixmatch Android app. Musixmatch Android app or desktop client.
It allows you to obtain synchronized lyrics in different formats It allows you to obtain synchronized lyrics in different formats
([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>), ([LRC](<https://en.wikipedia.org/wiki/LRC_(file_format)>),
[DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song. [DFXP](https://www.w3.org/TR/ttml1/), JSON) for almost any song.
The Musixmatch API used to require a free account on <https://www.musixmatch.com> to be If you use the Android client, you need a free Musixmatch account
used. However, as of 2024, this requirement was removed and the API can be used ([you can sign up here](https://www.musixmatch.com/de/sign-up)). The desktop client can
anonymously. The client still allows you to supply credentials if Musixmatch decides to be used anonymously and is currently the default option, but since Musixmatch
close the API down again. discontinued that application, they may shut it down.
## ⚠️ Copyright disclaimer ## ⚠️ Copyright disclaimer
@ -31,8 +27,12 @@ their [commercial plans](https://developer.musixmatch.com/plans)) and use their
## Development info ## Development info
The test suite reads Musixmatch credentials from the `MUSIXMATCH_EMAIL` and You can choose which client to test by setting the `MUSIXMATCH_CLIENT` environment
`MUSIXMATCH_PASSWORD` environment variables. variable to either `Desktop` or `Android` (it defaults to Desktop).
Running the tests for the Android client requires Musixmatch credentials. The
credentials are read from the `MUSIXMATCH_EMAIL` and `MUSIXMATCH_PASSWORD` environment
variables.
To make local development easier, I have included `dotenvy` to read the credentials from To make local development easier, I have included `dotenvy` to read the credentials from
an `.env` file. Copy the `.env.example` file in the root directory, rename it to `.env` an `.env` file. Copy the `.env.example` file in the root directory, rename it to `.env`

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] [package]
name = "musixmatch-cli" name = "musixmatch-cli"
version = "0.2.0" version = "0.1.0"
rust-version = "1.70.0" edition = "2021"
edition.workspace = true authors = ["ThetaDev"]
authors.workspace = true license = "MIT"
license.workspace = true
repository.workspace = true
keywords.workspace = true
description = "Inofficial command line interface for the Musixmatch API" description = "Inofficial command line interface for the Musixmatch API"
keywords = ["music", "lyrics", "cli"]
[features] [features]
default = ["native-tls"] default = ["rustls-tls-native-roots"]
# Reqwest TLS options # Reqwest TLS options
native-tls = ["musixmatch-inofficial/native-tls"] native-tls = ["musixmatch-inofficial/native-tls"]
@ -20,10 +18,12 @@ rustls-tls-webpki-roots = ["musixmatch-inofficial/rustls-tls-webpki-roots"]
rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"] rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"]
[dependencies] [dependencies]
musixmatch-inofficial.workspace = true musixmatch-inofficial = { path = "../" }
tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.20.0", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.0.0", features = ["derive"] } id3 = "1.3.0"
anyhow = "1.0.0" mp3-duration = "0.1.10"
clap = { version = "4.0.10", features = ["derive"] }
anyhow = "1.0.65"
rpassword = "7.0.0" rpassword = "7.0.0"
dirs = "5.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::{ use std::{
io::{stdin, stdout, Write}, io::{stdin, stdout, Write},
path::PathBuf, path::PathBuf,
@ -8,8 +5,9 @@ use std::{
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use id3::{Tag, TagLike};
use musixmatch_inofficial::{ use musixmatch_inofficial::{
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap}, models::{SubtitleFormat, Track, TrackId, TranslationMap},
Musixmatch, Musixmatch,
}; };
@ -22,7 +20,18 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
/// Get lyrics text Get {
#[command(subcommand)]
command: GetCommands,
},
Mp3 {
#[command(subcommand)]
command: FileCommands,
},
}
#[derive(Subcommand)]
enum GetCommands {
Lyrics { Lyrics {
#[clap(flatten)] #[clap(flatten)]
ident: TrackIdentifiers, ident: TrackIdentifiers,
@ -33,55 +42,26 @@ enum Commands {
#[clap(long)] #[clap(long)]
bi: bool, bi: bool,
}, },
/// Get subtitles (time-synced lyrics)
Subtitles { Subtitles {
#[clap(flatten)] #[clap(flatten)]
ident: TrackIdentifiers, ident: TrackIdentifiers,
/// Track length /// Track length
#[clap(short, long)] #[clap(long, short)]
length: Option<f32>, length: Option<f32>,
/// Maximum deviation from track length (Default: 1s) /// Maximum deviation from track length (Default: 1s)
#[clap(long)] #[clap(long)]
max_deviation: Option<f32>, max_deviation: Option<f32>,
/// Subtitle format /// Subtitle format
#[clap(short, long, default_value = "lrc")] #[clap(long, default_value = "lrc")]
format: SubtitleFormatClap, format: SubtitleFormatClap,
/// Language /// Language
#[clap(long)] #[clap(long)]
lang: Option<String>, lang: Option<String>,
}, },
/// Get track metadata
Track { Track {
#[clap(flatten)] #[clap(flatten)]
ident: TrackIdentifiers, ident: TrackIdentifiers,
}, },
/// Get album metadata
Album {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Get artist metadata
Artist {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Search for Musixmatch tracks
#[group(required = true)]
Search {
/// Track name
#[clap(short, long)]
name: Option<String>,
/// Artist
#[clap(short, long)]
artist: Option<String>,
/// Lyrics
#[clap(short, long)]
lyrics: Option<String>,
/// Search query
query: Option<Vec<String>>,
},
/// Search for Musixmatch artists
SearchArtist { query: Vec<String> },
} }
#[derive(Args)] #[derive(Args)]
@ -112,38 +92,22 @@ struct TrackIdentifiers {
isrc: Option<String>, isrc: Option<String>,
} }
#[derive(Args)]
#[group(multiple = false)]
struct AlbumArtistIdentifiers {
/// Musixmatch-ID
#[clap(long)]
mxm_id: Option<u64>,
/// Musicbrainz-ID
#[clap(long)]
musicbrainz: Option<String>,
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum FileCommands { enum FileCommands {
/// Get lyrics text
Lyrics { Lyrics {
/// Music file /// Music file
#[clap(value_parser)] #[clap(value_parser)]
file: PathBuf, file: PathBuf,
}, },
/// Get subtitles (time-synced lyrics)
Subtitles { Subtitles {
/// Music file /// Music file
#[clap(value_parser)] #[clap(value_parser)]
file: PathBuf, file: PathBuf,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
format: SubtitleFormatClap,
}, },
} }
#[derive(clap::ValueEnum, Debug, Copy, Clone)] #[derive(clap::ValueEnum, Debug, Copy, Clone)]
enum SubtitleFormatClap { pub enum SubtitleFormatClap {
Lrc, Lrc,
Ttml, Ttml,
TtmlStructured, TtmlStructured,
@ -197,193 +161,164 @@ async fn run(cli: Cli) -> Result<()> {
}; };
match cli.command { match cli.command {
Commands::Lyrics { ident, lang, bi } => { Commands::Get { command } => match command {
let track_id = get_track_id(ident, &mxm).await?; GetCommands::Lyrics { ident, lang, bi } => {
let lyrics = mxm.track_lyrics(track_id.clone()).await?; let track_id = get_track_id(ident, &mxm).await?;
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
eprintln!("Lyrics ID: {}", lyrics.lyrics_id); eprintln!("Lyrics ID: {}", lyrics.lyrics_id);
eprintln!( eprintln!(
"Language: {}", "Language: {}",
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR) lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
); );
eprintln!( eprintln!(
"Copyright: {}", "Copyright: {}",
lyrics lyrics
.lyrics_copyright .lyrics_copyright
.as_deref() .as_deref()
.map(|c| c.trim()) .map(|c| c.trim())
.unwrap_or(NA_STR) .unwrap_or(NA_STR)
); );
let mut lyrics_body = lyrics.lyrics_body; let mut lyrics_body = lyrics.lyrics_body;
if let Some(lang) = lang { if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() { if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?; let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() { if tl.is_empty() {
eprintln!("Translation not found. Returning lyrics in original language."); eprintln!(
} else { "Translation not found. Returning lyrics in original language."
eprintln!("Translated to: {}", tl.lang); );
let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
} else { } else {
translated eprintln!("Translated to: {}", tl.lang);
}; let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
} else {
translated
};
}
} }
} }
eprintln!();
println!("{}", lyrics_body);
}
GetCommands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
if let Some(lang) = lang {
let mut lines = subtitles.to_lines()?;
if Some(&lang) != subtitles.subtitle_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
bail!("Translation not found")
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
lines = tm.translate_subtitles(&lines);
}
}
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
} }
GetCommands::Track { ident } => {
let track = get_track(ident, &mxm).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
},
Commands::Mp3 { command } => match command {
FileCommands::Lyrics { file } => {
let tag = Tag::read_from_path(&file)?;
eprintln!(); let title = tag.title().ok_or(anyhow!("no title"))?;
println!("{}", lyrics_body); let artist = tag.artist().ok_or(anyhow!("no artist"))?;
}
Commands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id); let lyrics = mxm.matcher_lyrics(title, artist).await?;
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
if let Some(lang) = lang { println!(
let mut lines = subtitles.to_lines()?; "Lyrics for {} by {}:\n\n{}",
title, artist, lyrics.lyrics_body
);
}
FileCommands::Subtitles { file } => {
let tag = Tag::read_from_path(&file)?;
let duration = mp3_duration::from_path(&file)?;
if Some(&lang) != subtitles.subtitle_language.as_ref() { let title = tag.title().ok_or(anyhow!("no title"))?;
let tl = mxm.track_lyrics_translation(track_id, &lang).await?; let artist = tag.artist().ok_or(anyhow!("no artist"))?;
if tl.is_empty() {
bail!("Translation not found") let subtitles = mxm
} else { .matcher_subtitle(
eprintln!("Translated to: {}", tl.lang); title,
let tm = TranslationMap::from(tl); artist,
lines = tm.translate_subtitles(&lines); SubtitleFormat::Lrc,
} Some(duration.as_secs_f32()),
} Some(1.0),
)
.await?;
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body); println!("{}", subtitles.subtitle_body);
} }
} },
Commands::Track { ident } => {
let track = get_track(ident, &mxm).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
Commands::Album { ident } => {
let id = if let Some(id) = ident.mxm_id {
AlbumId::AlbumId(id)
} else if let Some(mb) = &ident.musicbrainz {
AlbumId::Musicbrainz(mb)
} else {
bail!("no album ID specified")
};
let album = mxm.album(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Artist { ident } => {
let id = if let Some(id) = ident.mxm_id {
ArtistId::ArtistId(id)
} else if let Some(mb) = &ident.musicbrainz {
ArtistId::Musicbrainz(mb)
} else {
bail!("no artist ID specified")
};
let album = mxm.artist(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Search {
query,
name,
artist,
lyrics,
} => {
let mut sb = mxm
.track_search()
.s_track_rating(musixmatch_inofficial::models::SortOrder::Desc);
let querystr;
if let Some(q) = &query {
querystr = q.join(" ");
sb = sb.q(&querystr);
}
if let Some(n) = &name {
sb = sb.q_track(n);
}
if let Some(a) = &artist {
sb = sb.q_artist(a);
}
if let Some(l) = &lyrics {
sb = sb.q_lyrics(l);
}
let tracks = sb.send(20, 0).await?;
for t in tracks {
println!(
"{} - {} ({}) ISRC'{}' <https://musixmatch.com/lyrics/{}>",
t.track_name,
t.artist_name,
t.first_release_date.map(|d| d.year()).unwrap_or_default(),
t.track_isrc.unwrap_or_default(),
t.commontrack_vanity_id
);
}
}
Commands::SearchArtist { query } => {
let artists = mxm.artist_search(&query.join(" "), 20, 0).await?;
for a in artists {
println!(
"{} <https://musixmatch.com/artist/{}>",
a.artist_name, a.artist_vanity_id
);
}
}
}; };
Ok(()) Ok(())
} }

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 std::{marker::PhantomData, str::FromStr};
use serde::{ use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
de::{DeserializeOwned, Visitor},
Deserialize, Deserializer, Serialize,
};
use time::OffsetDateTime; use time::OffsetDateTime;
use crate::error::{Error, Result as MxmResult}; use crate::error::{Error, Result as MxmResult};
@ -12,17 +9,24 @@ use crate::error::{Error, Result as MxmResult};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Resp<T> { pub struct Resp<T> {
pub message: T, pub message: Message<T>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct HeaderMsg { pub struct Message<T> {
pub header: Header, pub header: Header,
pub body: Option<MessageBody<T>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct BodyMsg<T> { #[serde(untagged)]
pub body: T, pub enum MessageBody<T> {
Some(T),
// "body": []
EmptyArr(Vec<()>),
// "body": {}
EmptyObj {},
EmptyStr(String),
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -33,24 +37,30 @@ pub struct Header {
pub hint: String, pub hint: String,
} }
pub fn parse_body<T: DeserializeOwned>(response: &str) -> MxmResult<T> { impl<T> Resp<T> {
let header = serde_json::from_str::<Resp<HeaderMsg>>(response)? pub fn body_or_err(self) -> MxmResult<T> {
.message match (self.message.body, self.message.header.status_code < 400) {
.header; (Some(MessageBody::Some(body)), true) => Ok(body),
if header.status_code < 400 { (_, true) => Err(Error::NoData),
let body = serde_json::from_str::<Resp<BodyMsg<T>>>(response)?; (_, false) => {
Ok(body.message.body) if self.message.header.status_code == 404 {
} else if header.status_code == 404 { Err(Error::NotFound)
Err(Error::NotFound) } else if self.message.header.status_code == 401
} else if header.status_code == 401 && header.hint == "renew" { && self.message.header.hint == "renew"
Err(Error::TokenExpired) {
} else if header.status_code == 401 && header.hint == "captcha" { Err(Error::TokenExpired)
Err(Error::Ratelimit) } else if self.message.header.status_code == 401
} else { && self.message.header.hint == "captcha"
Err(Error::MusixmatchError { {
status_code: header.status_code, Err(Error::Ratelimit)
msg: header.hint, } else {
}) Err(Error::MusixmatchError {
status_code: self.message.header.status_code,
msg: self.message.header.hint,
})
}
}
}
} }
} }
@ -99,8 +109,8 @@ pub enum LoginCredential {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Account { pub struct Account {
// pub id: String, pub id: String,
// pub email: String, pub email: String,
pub name: String, pub name: String,
} }
@ -555,22 +565,57 @@ mod tests {
let json = let json =
r#"{"message":{"header":{"status_code":401,"execute_time":0.002,"hint":"fsck"}}}"#; r#"{"message":{"header":{"status_code":401,"execute_time":0.002,"hint":"fsck"}}}"#;
let err = parse_body::<SubtitleBody>(json).unwrap_err(); let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
if let Error::MusixmatchError { status_code, msg } = err { assert_eq!(res.message.header.status_code, 401);
assert_eq!(status_code, 401); assert_eq!(res.message.header.hint, "fsck");
assert_eq!(msg, "fsck"); assert!(res.message.body.is_none());
} else {
panic!("invalid error: {err}"); let err = res.body_or_err().unwrap_err();
} assert_eq!(
err.to_string(),
"Error 401 returned by the Musixmatch API. Message: 'fsck'"
);
} }
#[test] #[test]
fn deserialize_body() { fn deserialize_emptyarr_body() {
let json = r#"{"message":{"header":{"status_code":200,"execute_time":0.002},"body":"Hello World"}}"#; let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":[]}}"#;
let res = parse_body::<String>(json).unwrap(); let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
assert_eq!(res, "Hello World");
assert_eq!(res.message.header.status_code, 403);
assert_eq!(res.message.header.hint, "");
assert!(matches!(
res.message.body.as_ref().unwrap(),
MessageBody::EmptyArr(_)
));
let err = res.body_or_err().unwrap_err();
assert_eq!(
err.to_string(),
"Error 403 returned by the Musixmatch API. Message: ''"
);
}
#[test]
fn deserialize_emptyobj_body() {
let json = r#"{"message":{"header":{"status_code":403,"execute_time":0.0056290626525879},"body":{}}}"#;
let res = serde_json::from_str::<Resp<SubtitleBody>>(json).unwrap();
assert_eq!(res.message.header.status_code, 403);
assert_eq!(res.message.header.hint, "");
assert!(matches!(
res.message.body.as_ref().unwrap(),
MessageBody::EmptyObj {}
));
let err = res.body_or_err().unwrap_err();
assert_eq!(
err.to_string(),
"Error 403 returned by the Musixmatch API. Message: ''"
);
} }
#[test] #[test]

View file

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

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get> /// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> { pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> {
let mut url = self.new_url("artist.get"); let mut url = self.new_url("artist.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -40,7 +40,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.related.get"); let mut url = self.new_url("artist.related.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -74,7 +74,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.search"); let mut url = self.new_url("artist.search")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -107,7 +107,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Artist>> { ) -> Result<Vec<Artist>> {
let mut url = self.new_url("chart.artists.get"); let mut url = self.new_url("chart.artists.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@ impl Musixmatch {
translation_status: bool, translation_status: bool,
lang_3c: bool, lang_3c: bool,
) -> Result<Track> { ) -> Result<Track> {
let mut url = self.new_url("matcher.track.get"); let mut url = self.new_url("matcher.track.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -74,7 +74,7 @@ impl Musixmatch {
translation_status: bool, translation_status: bool,
lang_3c: bool, lang_3c: bool,
) -> Result<Track> { ) -> Result<Track> {
let mut url = self.new_url("track.get"); let mut url = self.new_url("track.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -114,7 +114,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Track>> { ) -> Result<Vec<Track>> {
let mut url = self.new_url("album.tracks.get"); let mut url = self.new_url("album.tracks.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -155,7 +155,7 @@ impl Musixmatch {
page_size: u8, page_size: u8,
page: u32, page: u32,
) -> Result<Vec<Track>> { ) -> Result<Vec<Track>> {
let mut url = self.new_url("chart.tracks.get"); let mut url = self.new_url("chart.tracks.get")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();
@ -184,7 +184,7 @@ impl Musixmatch {
/// # Reference /// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get> /// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
pub async fn genres(&self) -> Result<Vec<Genre>> { pub async fn genres(&self) -> Result<Vec<Genre>> {
let url = self.new_url("music.genres.get"); let url = self.new_url("music.genres.get")?;
let genres = self.execute_get_request::<Genres>(&url).await?; let genres = self.execute_get_request::<Genres>(&url).await?;
Ok(genres.music_genre_list) Ok(genres.music_genre_list)
} }
@ -347,7 +347,7 @@ impl<'a> TrackSearchQuery<'a> {
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100. /// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
/// - `page`: Define the page number for paginated results, starting from 1. /// - `page`: Define the page number for paginated results, starting from 1.
pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> { pub async fn send(&self, page_size: u8, page: u32) -> Result<Vec<Track>> {
let mut url = self.mxm.new_url("track.search"); let mut url = self.mxm.new_url("track.search")?;
{ {
let mut url_query = url.query_pairs_mut(); let mut url_query = url.query_pairs_mut();

View file

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

View file

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

View file

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

View file

@ -1,32 +1,32 @@
use std::{ use std::path::{Path, PathBuf};
num::NonZeroU32,
path::{Path, PathBuf},
sync::LazyLock,
};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use path_macro::path; use path_macro::path;
use rstest::{fixture, rstest}; use rstest::rstest;
use time::macros::{date, datetime}; use time::macros::{date, datetime};
use musixmatch_inofficial::{ use musixmatch_inofficial::{
models::{AlbumId, ArtistId, TrackId}, models::{AlbumId, ArtistId, TrackId},
Error, Musixmatch, ClientType, Error, Musixmatch,
}; };
fn testfile<P: AsRef<Path>>(name: P) -> PathBuf { #[ctor::ctor]
path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name) 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] fn new_mxm() -> Musixmatch {
async fn mxm() -> Musixmatch { let client_type = std::env::var("MUSIXMATCH_CLIENT")
static LOGIN_LOCK: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); .map(|ctype| serde_plain::from_str::<ClientType>(&ctype).expect("valid client type"))
static MXM_LIMITER: LazyLock<DefaultDirectRateLimiter> = .unwrap_or_default();
LazyLock::new(|| RateLimiter::direct(Quota::per_second(NonZeroU32::new(1).unwrap())));
MXM_LIMITER.until_ready().await; let mut mxm = Musixmatch::builder().client_type(client_type);
let mut mxm = Musixmatch::builder();
if let (Ok(email), Ok(password)) = ( if let (Ok(email), Ok(password)) = (
std::env::var("MUSIXMATCH_EMAIL"), std::env::var("MUSIXMATCH_EMAIL"),
@ -35,10 +35,11 @@ async fn mxm() -> Musixmatch {
mxm = mxm.credentials(email, password); mxm = mxm.credentials(email, password);
} }
let mxm = mxm.build().unwrap(); mxm.build().unwrap()
}
LOGIN_LOCK.get_or_try_init(|| mxm.login()).await.unwrap(); fn testfile<P: AsRef<Path>>(name: P) -> PathBuf {
mxm path!(env!("CARGO_MANIFEST_DIR") / "testfiles" / name)
} }
mod album { mod album {
@ -49,8 +50,8 @@ mod album {
#[case::id(AlbumId::AlbumId(14248253))] #[case::id(AlbumId::AlbumId(14248253))]
#[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))] #[case::musicbrainz(AlbumId::Musicbrainz("6c3cf9d8-88a8-43ed-850b-55813f01e451"))]
#[tokio::test] #[tokio::test]
async fn by_id(#[case] album_id: AlbumId<'_>, #[future] mxm: Musixmatch) { async fn by_id(#[case] album_id: AlbumId<'_>) {
let album = mxm.await.album(album_id).await.unwrap(); let album = new_mxm().album(album_id).await.unwrap();
assert_eq!(album.album_id, 14248253); assert_eq!(album.album_id, 14248253);
assert_eq!( assert_eq!(
@ -59,7 +60,7 @@ mod album {
); );
assert_eq!(album.album_name, "Gangnam Style (강남스타일)"); assert_eq!(album.album_name, "Gangnam Style (강남스타일)");
assert!(album.album_rating > 20); 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_date.unwrap(), date!(2012 - 01 - 01));
assert_eq!(album.album_release_type, AlbumType::Single); assert_eq!(album.album_release_type, AlbumType::Single);
assert_eq!(album.artist_id, 410698); assert_eq!(album.artist_id, 410698);
@ -92,25 +93,31 @@ mod album {
); );
assert_eq!(album.album_vanity_id, "410698/Gangnam-Style-Single"); assert_eq!(album.album_vanity_id, "410698/Gangnam-Style-Single");
assert!(album.updated_time > datetime!(2022-6-3 0:00 UTC)); assert!(album.updated_time > datetime!(2022-6-3 0:00 UTC));
assert_imgurl(&album.album_coverart_100x100, "/26544045.jpg"); assert_eq!(
assert_imgurl(&album.album_coverart_350x350, "/26544045_350_350.jpg"); album.album_coverart_100x100.unwrap(),
assert_imgurl(&album.album_coverart_500x500, "/26544045_500_500.jpg"); "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] #[tokio::test]
async fn album_ep(#[future] mxm: Musixmatch) { async fn album_ep() {
let album = mxm.await.album(AlbumId::AlbumId(23976123)).await.unwrap(); let album = new_mxm().album(AlbumId::AlbumId(23976123)).await.unwrap();
assert_eq!(album.album_name, "Waldbrand EP"); 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))); assert_eq!(album.album_release_date, Some(date!(2016 - 09 - 30)));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn by_id_missing(#[future] mxm: Musixmatch) { async fn by_id_missing() {
let err = mxm let err = new_mxm()
.await
.album(AlbumId::AlbumId(999999999999)) .album(AlbumId::AlbumId(999999999999))
.await .await
.unwrap_err(); .unwrap_err();
@ -118,12 +125,9 @@ mod album {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
#[ignore] async fn artist_albums() {
async fn artist_albums(#[future] mxm: Musixmatch) { let albums = new_mxm()
let albums = mxm
.await
.artist_albums(ArtistId::ArtistId(1039), None, 10, 1) .artist_albums(ArtistId::ArtistId(1039), None, 10, 1)
.await .await
.unwrap(); .unwrap();
@ -131,11 +135,9 @@ mod album {
assert_eq!(albums.len(), 10); assert_eq!(albums.len(), 10);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn artist_albums_missing(#[future] mxm: Musixmatch) { async fn artist_albums_missing() {
let err = mxm let err = new_mxm()
.await
.artist_albums(ArtistId::ArtistId(999999999999), None, 10, 1) .artist_albums(ArtistId::ArtistId(999999999999), None, 10, 1)
.await .await
.unwrap_err(); .unwrap_err();
@ -143,10 +145,9 @@ mod album {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn charts(#[future] mxm: Musixmatch) { async fn charts() {
let albums = mxm.await.chart_albums("US", 10, 1).await.unwrap(); let albums = new_mxm().chart_albums("US", 10, 1).await.unwrap();
assert_eq!(albums.len(), 10); assert_eq!(albums.len(), 10);
} }
@ -159,8 +160,8 @@ mod artist {
#[case::id(ArtistId::ArtistId(410698))] #[case::id(ArtistId::ArtistId(410698))]
#[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))] #[case::musicbrainz(ArtistId::Musicbrainz("f99b7d67-4e63-4678-aa66-4c6ac0f7d24a"))]
#[tokio::test] #[tokio::test]
async fn by_id(#[case] artist_id: ArtistId<'_>, #[future] mxm: Musixmatch) { async fn by_id(#[case] artist_id: ArtistId<'_>) {
let artist = mxm.await.artist(artist_id).await.unwrap(); let artist = new_mxm().artist(artist_id).await.unwrap();
// dbg!(&artist); // dbg!(&artist);
@ -202,11 +203,9 @@ mod artist {
assert_eq!(artist.end_date, None); assert_eq!(artist.end_date, None);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn by_id_missing(#[future] mxm: Musixmatch) { async fn by_id_missing() {
let err = mxm let err = new_mxm()
.await
.artist(ArtistId::ArtistId(999999999999)) .artist(ArtistId::ArtistId(999999999999))
.await .await
.unwrap_err(); .unwrap_err();
@ -214,11 +213,9 @@ mod artist {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn related(#[future] mxm: Musixmatch) { async fn related() {
let artists = mxm let artists = new_mxm()
.await
.artist_related(ArtistId::ArtistId(26485840), 10, 1) .artist_related(ArtistId::ArtistId(26485840), 10, 1)
.await .await
.unwrap(); .unwrap();
@ -226,11 +223,9 @@ mod artist {
assert_eq!(artists.len(), 10); assert_eq!(artists.len(), 10);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn related_missing(#[future] mxm: Musixmatch) { async fn related_missing() {
let err = mxm let err = new_mxm()
.await
.artist_related(ArtistId::ArtistId(999999999999), 10, 1) .artist_related(ArtistId::ArtistId(999999999999), 10, 1)
.await .await
.unwrap_err(); .unwrap_err();
@ -238,25 +233,20 @@ mod artist {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn search(#[future] mxm: Musixmatch) { async fn search() {
let artists = mxm let artists = new_mxm().artist_search("psy", 5, 1).await.unwrap();
.await
.artist_search("Snollebollekes", 5, 1) assert_eq!(artists.len(), 5);
.await
.unwrap();
let artist = &artists[0]; let artist = &artists[0];
assert_eq!(artist.artist_id, 25344078); assert_eq!(artist.artist_id, 410698);
assert_eq!(artist.artist_name, "Snollebollekes"); assert_eq!(artist.artist_name, "PSY");
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn search_empty(#[future] mxm: Musixmatch) { async fn search_empty() {
let artists = mxm let artists = new_mxm()
.await
.artist_search( .artist_search(
"Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz", "Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz",
5, 5,
@ -268,18 +258,16 @@ mod artist {
assert_eq!(artists.len(), 0); assert_eq!(artists.len(), 0);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn charts(#[future] mxm: Musixmatch) { async fn charts() {
let artists = mxm.await.chart_artists("US", 10, 1).await.unwrap(); let artists = new_mxm().chart_artists("US", 10, 1).await.unwrap();
assert_eq!(artists.len(), 10); assert_eq!(artists.len(), 10);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn charts_no_country(#[future] mxm: Musixmatch) { async fn charts_no_country() {
let artists = mxm.await.chart_artists("XY", 10, 1).await.unwrap(); let artists = new_mxm().chart_artists("XY", 10, 1).await.unwrap();
assert_eq!(artists.len(), 10); assert_eq!(artists.len(), 10);
} }
@ -294,13 +282,8 @@ mod track {
#[case::translation_2c(true, false)] #[case::translation_2c(true, false)]
#[case::translation_3c(true, true)] #[case::translation_3c(true, true)]
#[tokio::test] #[tokio::test]
async fn from_match( async fn from_match(#[case] translation_status: bool, #[case] lang_3c: bool) {
#[case] translation_status: bool, let track = new_mxm()
#[case] lang_3c: bool,
#[future] mxm: Musixmatch,
) {
let track = mxm
.await
.matcher_track( .matcher_track(
"Poker Face", "Poker Face",
"Lady Gaga", "Lady Gaga",
@ -313,12 +296,12 @@ mod track {
// dbg!(&track); // dbg!(&track);
assert_eq!(track.track_id, 85213841); assert_eq!(track.track_id, 15476784);
// assert_eq!( assert_eq!(
// track.track_mbid.unwrap(), track.track_mbid.unwrap(),
// "080975b0-39b1-493c-ae64-5cb3292409bb" "080975b0-39b1-493c-ae64-5cb3292409bb"
// ); );
// assert_eq!(track.track_isrc.unwrap(), "USUM70824409"); assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert!( assert!(
track.commontrack_isrcs[0] track.commontrack_isrcs[0]
.iter() .iter()
@ -326,7 +309,7 @@ mod track {
"commontrack_isrcs: {:?}", "commontrack_isrcs: {:?}",
&track.commontrack_isrcs[0], &track.commontrack_isrcs[0],
); );
assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg"); assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO");
assert!( assert!(
track track
.commontrack_spotify_ids .commontrack_spotify_ids
@ -346,7 +329,7 @@ mod track {
assert!(track.num_favourite > 50); assert!(track.num_favourite > 50);
assert!(track.lyrics_id.is_some()); assert!(track.lyrics_id.is_some());
assert_eq!(track.subtitle_id.unwrap(), 36450705); 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.album_name, "The Fame");
assert_eq!(track.artist_id, 378462); assert_eq!(track.artist_id, 378462);
assert_eq!( assert_eq!(
@ -354,10 +337,18 @@ mod track {
"650e7db6-b795-4eb5-a702-5ea2fc46c848" "650e7db6-b795-4eb5-a702-5ea2fc46c848"
); );
assert_eq!(track.artist_name, "Lady Gaga"); assert_eq!(track.artist_name, "Lady Gaga");
assert_imgurl(&track.album_coverart_100x100, "/32133892.jpg"); assert_eq!(
assert_imgurl(&track.album_coverart_350x350, "/32133892_350_350.jpg"); track.album_coverart_100x100.unwrap(),
assert_imgurl(&track.album_coverart_500x500, "/32133892_500_500.jpg"); "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg"
assert_imgurl(&track.album_coverart_800x800, "/32133892_800_800.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"); assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1");
let first_release = track.first_release_date.unwrap(); let first_release = track.first_release_date.unwrap();
assert_eq!(first_release.date(), date!(2008 - 1 - 1)); assert_eq!(first_release.date(), date!(2008 - 1 - 1));
@ -405,8 +396,8 @@ mod track {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))] #[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test] #[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { async fn from_id(#[case] track_id: TrackId<'_>) {
let track = mxm.await.track(track_id, true, false).await.unwrap(); let track = new_mxm().track(track_id, true, false).await.unwrap();
// dbg!(&track); // dbg!(&track);
@ -427,9 +418,18 @@ mod track {
assert_eq!(track.album_name, "Black Mamba"); assert_eq!(track.album_name, "Black Mamba");
assert_eq!(track.artist_id, 46970441); assert_eq!(track.artist_id, 46970441);
assert_eq!(track.artist_name, "aespa"); assert_eq!(track.artist_name, "aespa");
assert_imgurl(&track.album_coverart_100x100, "/52156772.jpg"); assert_eq!(
assert_imgurl(&track.album_coverart_350x350, "/52156772_350_350.jpg"); track.album_coverart_100x100.unwrap(),
assert_imgurl(&track.album_coverart_500x500, "/52156772_500_500.jpg"); "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"); assert_eq!(track.commontrack_vanity_id, "aespa/Black-Mamba");
let release_date = track.first_release_date.unwrap(); let release_date = track.first_release_date.unwrap();
@ -446,25 +446,20 @@ mod track {
#[case::translation_2c(true, false)] #[case::translation_2c(true, false)]
#[case::translation_3c(true, true)] #[case::translation_3c(true, true)]
#[tokio::test] #[tokio::test]
async fn from_id_translations( async fn from_id_translations(#[case] translation_status: bool, #[case] lang_3c: bool) {
#[case] translation_status: bool, let track = new_mxm()
#[case] lang_3c: bool, .track(TrackId::TrackId(15476784), translation_status, lang_3c)
#[future] mxm: Musixmatch,
) {
let track = mxm
.await
.track(TrackId::Commontrack(47672612), translation_status, lang_3c)
.await .await
.unwrap(); .unwrap();
// dbg!(&track); // dbg!(&track);
assert_eq!(track.track_id, 85213841); assert_eq!(track.track_id, 15476784);
// assert_eq!( assert_eq!(
// track.track_mbid.unwrap(), track.track_mbid.unwrap(),
// "080975b0-39b1-493c-ae64-5cb3292409bb" "080975b0-39b1-493c-ae64-5cb3292409bb"
// ); );
// assert_eq!(track.track_isrc.unwrap(), "USUM70824409"); assert_eq!(track.track_isrc.unwrap(), "USUM70824409");
assert!( assert!(
track.commontrack_isrcs[0] track.commontrack_isrcs[0]
.iter() .iter()
@ -472,7 +467,7 @@ mod track {
"commontrack_isrcs: {:?}", "commontrack_isrcs: {:?}",
&track.commontrack_isrcs[0], &track.commontrack_isrcs[0],
); );
assert_eq!(track.track_spotify_id.unwrap(), "1QV6tiMFM6fSOKOGLMHYYg"); assert_eq!(track.track_spotify_id.unwrap(), "5R8dQOPq8haW94K7mgERlO");
assert!( assert!(
track track
.commontrack_spotify_ids .commontrack_spotify_ids
@ -492,7 +487,7 @@ mod track {
assert!(track.num_favourite > 50); assert!(track.num_favourite > 50);
assert!(track.lyrics_id.is_some()); assert!(track.lyrics_id.is_some());
assert_eq!(track.subtitle_id.unwrap(), 36450705); 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.album_name, "The Fame");
assert_eq!(track.artist_id, 378462); assert_eq!(track.artist_id, 378462);
assert_eq!( assert_eq!(
@ -500,10 +495,18 @@ mod track {
"650e7db6-b795-4eb5-a702-5ea2fc46c848" "650e7db6-b795-4eb5-a702-5ea2fc46c848"
); );
assert_eq!(track.artist_name, "Lady Gaga"); assert_eq!(track.artist_name, "Lady Gaga");
assert_imgurl(&track.album_coverart_100x100, "/32133892.jpg"); assert_eq!(
assert_imgurl(&track.album_coverart_350x350, "/32133892_350_350.jpg"); track.album_coverart_100x100.unwrap(),
assert_imgurl(&track.album_coverart_500x500, "/32133892_500_500.jpg"); "https://s.mxmcdn.net/images-storage/albums/6/3/6/9/1/3/26319636.jpg"
assert_imgurl(&track.album_coverart_800x800, "/32133892_800_800.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"); assert_eq!(track.commontrack_vanity_id, "Lady-Gaga/poker-face-1");
let first_release = track.first_release_date.unwrap(); let first_release = track.first_release_date.unwrap();
assert_eq!(first_release.date(), date!(2008 - 1 - 1)); assert_eq!(first_release.date(), date!(2008 - 1 - 1));
@ -544,11 +547,9 @@ mod track {
} }
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn from_id_missing(#[future] mxm: Musixmatch) { async fn from_id_missing() {
let err = mxm let err = new_mxm()
.await
.track(TrackId::TrackId(999999999999), false, false) .track(TrackId::TrackId(999999999999), false, false)
.await .await
.unwrap_err(); .unwrap_err();
@ -556,11 +557,9 @@ mod track {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn album_tracks(#[future] mxm: Musixmatch) { async fn album_tracks() {
let tracks = mxm let tracks = new_mxm()
.await
.album_tracks(AlbumId::AlbumId(17118624), true, 20, 1) .album_tracks(AlbumId::AlbumId(17118624), true, 20, 1)
.await .await
.unwrap(); .unwrap();
@ -597,11 +596,9 @@ mod track {
}); });
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn album_missing(#[future] mxm: Musixmatch) { async fn album_missing() {
let err = mxm let err = new_mxm()
.await
.album_tracks(AlbumId::AlbumId(999999999999), false, 20, 1) .album_tracks(AlbumId::AlbumId(999999999999), false, 20, 1)
.await .await
.unwrap_err(); .unwrap_err();
@ -613,9 +610,8 @@ mod track {
#[case::top(ChartName::Top)] #[case::top(ChartName::Top)]
#[case::hot(ChartName::Hot)] #[case::hot(ChartName::Hot)]
#[tokio::test] #[tokio::test]
async fn charts(#[case] chart_name: ChartName, #[future] mxm: Musixmatch) { async fn charts(#[case] chart_name: ChartName) {
let tracks = mxm let tracks = new_mxm()
.await
.chart_tracks("US", chart_name, true, 20, 1) .chart_tracks("US", chart_name, true, 20, 1)
.await .await
.unwrap(); .unwrap();
@ -623,11 +619,9 @@ mod track {
assert_eq!(tracks.len(), 20); assert_eq!(tracks.len(), 20);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn search(#[future] mxm: Musixmatch) { async fn search() {
let tracks = mxm let tracks = new_mxm()
.await
.track_search() .track_search()
.q_artist("Lena") .q_artist("Lena")
.q_track("Satellite") .q_track("Satellite")
@ -646,11 +640,9 @@ mod track {
assert_eq!(track.artist_name, "Lena"); assert_eq!(track.artist_name, "Lena");
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn search_lyrics(#[future] mxm: Musixmatch) { async fn search_lyrics() {
let tracks = mxm let tracks = new_mxm()
.await
.track_search() .track_search()
.q_lyrics("the whole world stops and stares for a while") .q_lyrics("the whole world stops and stares for a while")
.s_track_rating(SortOrder::Desc) .s_track_rating(SortOrder::Desc)
@ -658,18 +650,16 @@ mod track {
.await .await
.unwrap(); .unwrap();
assert_gte(tracks.len(), 8, "tracks"); assert_eq!(tracks.len(), 10);
let track = &tracks[0]; let track = &tracks[0];
assert_eq!(track.track_name, "Just the Way You Are"); assert_eq!(track.track_name, "Just the Way You Are");
assert_eq!(track.artist_name, "Bruno Mars"); assert_eq!(track.artist_name, "Bruno Mars");
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn search_empty(#[future] mxm: Musixmatch) { async fn search_empty() {
let artists = mxm let artists = new_mxm()
.await
.track_search() .track_search()
.q("Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz") .q("Rindfleischettikettierungsüberwachungsaufgabenübertragungsgesetz")
.send(10, 1) .send(10, 1)
@ -679,19 +669,16 @@ mod track {
assert_eq!(artists.len(), 0); assert_eq!(artists.len(), 0);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn genres(#[future] mxm: Musixmatch) { async fn genres() {
let genres = mxm.await.genres().await.unwrap(); let genres = new_mxm().genres().await.unwrap();
assert!(genres.len() > 360); assert!(genres.len() > 360);
dbg!(&genres); // dbg!(&genres);
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn snippet(#[future] mxm: Musixmatch) { async fn snippet() {
let snippet = mxm let snippet = new_mxm()
.await
.track_snippet(TrackId::Commontrack(8874280)) .track_snippet(TrackId::Commontrack(8874280))
.await .await
.unwrap(); .unwrap();
@ -713,27 +700,22 @@ mod lyrics {
use super::*; use super::*;
#[rstest]
#[tokio::test] #[tokio::test]
async fn from_match(#[future] mxm: Musixmatch) { async fn from_match() {
let lyrics = mxm.await.matcher_lyrics("Shine", "Spektrem").await.unwrap(); let lyrics = new_mxm().matcher_lyrics("Shine", "Spektrem").await.unwrap();
// dbg!(&lyrics); // dbg!(&lyrics);
assert_eq!(lyrics.lyrics_id, 34583240); assert_eq!(lyrics.lyrics_id, 25947036);
assert!(!lyrics.instrumental); assert!(!lyrics.instrumental);
assert!(!lyrics.explicit); assert!(!lyrics.explicit);
assert!( assert!(lyrics
lyrics .lyrics_body
.lyrics_body .starts_with("Eyes, in the sky, gazing far into the night\n"));
.starts_with("Eyes in the sky gazing far into the night\n"),
"got: {}",
lyrics.lyrics_body
);
assert_eq!(lyrics.lyrics_language.unwrap(), "en"); assert_eq!(lyrics.lyrics_language.unwrap(), "en");
assert_eq!(lyrics.lyrics_language_description.unwrap(), "English"); assert_eq!(lyrics.lyrics_language_description.unwrap(), "English");
let copyright = lyrics.lyrics_copyright.unwrap(); 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)); assert!(lyrics.updated_time > datetime!(2021-6-3 0:00 UTC));
} }
@ -744,8 +726,8 @@ mod lyrics {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))] #[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test] #[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { async fn from_id(#[case] track_id: TrackId<'_>) {
let lyrics = mxm.await.track_lyrics(track_id).await.unwrap(); let lyrics = new_mxm().track_lyrics(track_id).await.unwrap();
// dbg!(&lyrics); // dbg!(&lyrics);
@ -761,11 +743,9 @@ mod lyrics {
} }
/// This track has no lyrics /// This track has no lyrics
#[rstest]
#[tokio::test] #[tokio::test]
async fn instrumental(#[future] mxm: Musixmatch) { async fn instrumental() {
let lyrics = mxm let lyrics = new_mxm()
.await
.matcher_lyrics("drivers license", "Bobby G") .matcher_lyrics("drivers license", "Bobby G")
.await .await
.unwrap(); .unwrap();
@ -781,28 +761,24 @@ mod lyrics {
} }
/// This track does not exist /// This track does not exist
#[rstest]
#[tokio::test] #[tokio::test]
async fn missing(#[future] mxm: Musixmatch) { async fn missing() {
let err = mxm let err = new_mxm()
.await .track_lyrics(TrackId::Spotify("674JwwTP7xCje81T0DRrLn".into()))
.track_lyrics(TrackId::Spotify("2gwMMr1a4aXXN5L6KC80Pu".into()))
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn download_testdata(#[future] mxm: Musixmatch) { async fn download_testdata() {
let mxm = mxm.await;
let json_path = testfile("lyrics.json"); let json_path = testfile("lyrics.json");
if json_path.exists() { if json_path.exists() {
return; return;
} }
let lyrics = mxm let lyrics = new_mxm()
.track_lyrics(TrackId::Commontrack(18576954)) .track_lyrics(TrackId::Commontrack(18576954))
.await .await
.unwrap(); .unwrap();
@ -811,16 +787,14 @@ mod lyrics {
serde_json::to_writer_pretty(BufWriter::new(json_file), &lyrics).unwrap(); serde_json::to_writer_pretty(BufWriter::new(json_file), &lyrics).unwrap();
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn download_testdata_translation(#[future] mxm: Musixmatch) { async fn download_testdata_translation() {
let mxm = mxm.await;
let json_path = testfile("translation.json"); let json_path = testfile("translation.json");
if json_path.exists() { if json_path.exists() {
return; return;
} }
let translations = mxm let translations = new_mxm()
.track_lyrics_translation(TrackId::Commontrack(18576954), "de") .track_lyrics_translation(TrackId::Commontrack(18576954), "de")
.await .await
.unwrap(); .unwrap();
@ -829,10 +803,9 @@ mod lyrics {
serde_json::to_writer_pretty(BufWriter::new(json_file), &translations).unwrap(); serde_json::to_writer_pretty(BufWriter::new(json_file), &translations).unwrap();
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn concurrency(#[future] mxm: Musixmatch) { async fn concurrency() {
let mxm = mxm.await; let mxm = new_mxm();
let album = mxm let album = mxm
.album_tracks( .album_tracks(
@ -867,11 +840,9 @@ mod subtitles {
use super::*; use super::*;
use musixmatch_inofficial::models::SubtitleFormat; use musixmatch_inofficial::models::SubtitleFormat;
#[rstest]
#[tokio::test] #[tokio::test]
async fn from_match(#[future] mxm: Musixmatch) { async fn from_match() {
let subtitle = mxm let subtitle = new_mxm()
.await
.matcher_subtitle( .matcher_subtitle(
"Shine", "Shine",
"Spektrem", "Spektrem",
@ -884,12 +855,12 @@ mod subtitles {
// dbg!(&subtitle); // 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.unwrap(), "en");
assert_eq!(subtitle.subtitle_language_description.unwrap(), "English"); assert_eq!(subtitle.subtitle_language_description.unwrap(), "English");
let copyright = subtitle.lyrics_copyright.unwrap(); let copyright = subtitle.lyrics_copyright.unwrap();
assert!(copyright.contains("Jesse Warren"), "copyright: {copyright}",); assert!(copyright.contains("Kim Jeffeson"), "copyright: {copyright}",);
assert_eq!(subtitle.subtitle_length, 316); assert_eq!(subtitle.subtitle_length, 315);
assert!(subtitle.updated_time > datetime!(2021-6-30 0:00 UTC)); assert!(subtitle.updated_time > datetime!(2021-6-30 0:00 UTC));
} }
@ -900,9 +871,8 @@ mod subtitles {
#[case::isrc(TrackId::Isrc("KRA302000590".into()))] #[case::isrc(TrackId::Isrc("KRA302000590".into()))]
#[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))] #[case::spotify(TrackId::Spotify("1t2qYCAjUAoGfeFeoBlK51".into()))]
#[tokio::test] #[tokio::test]
async fn from_id(#[case] track_id: TrackId<'_>, #[future] mxm: Musixmatch) { async fn from_id(#[case] track_id: TrackId<'_>) {
let subtitle = mxm let subtitle = new_mxm()
.await
.track_subtitle(track_id, SubtitleFormat::Json, Some(175.0), Some(1.0)) .track_subtitle(track_id, SubtitleFormat::Json, Some(175.0), Some(1.0))
.await .await
.unwrap(); .unwrap();
@ -922,11 +892,9 @@ mod subtitles {
} }
/// This track has no lyrics /// This track has no lyrics
#[rstest]
#[tokio::test] #[tokio::test]
async fn instrumental(#[future] mxm: Musixmatch) { async fn instrumental() {
let err = mxm let err = new_mxm()
.await
.matcher_subtitle( .matcher_subtitle(
"drivers license", "drivers license",
"Bobby G", "Bobby G",
@ -941,11 +909,9 @@ mod subtitles {
} }
/// This track has not been synced /// This track has not been synced
#[rstest]
#[tokio::test] #[tokio::test]
async fn unsynced(#[future] mxm: Musixmatch) { async fn unsynced() {
let err = mxm let err = new_mxm()
.await
.track_subtitle( .track_subtitle(
TrackId::Spotify("6oaWIABGL7eeiMILEDyGX1".into()), TrackId::Spotify("6oaWIABGL7eeiMILEDyGX1".into()),
SubtitleFormat::Json, SubtitleFormat::Json,
@ -959,11 +925,9 @@ mod subtitles {
} }
/// Try to get subtitles with wrong length parameter /// Try to get subtitles with wrong length parameter
#[rstest]
#[tokio::test] #[tokio::test]
async fn wrong_length(#[future] mxm: Musixmatch) { async fn wrong_length() {
let err = mxm let err = new_mxm()
.await
.track_subtitle( .track_subtitle(
TrackId::Commontrack(118480583), TrackId::Commontrack(118480583),
SubtitleFormat::Json, SubtitleFormat::Json,
@ -976,16 +940,14 @@ mod subtitles {
assert!(matches!(err, Error::NotFound)); assert!(matches!(err, Error::NotFound));
} }
#[rstest]
#[tokio::test] #[tokio::test]
async fn download_testdata(#[future] mxm: Musixmatch) { async fn download_testdata() {
let json_path = testfile("subtitles.json"); let json_path = testfile("subtitles.json");
if json_path.exists() { if json_path.exists() {
return; return;
} }
let subtitle = mxm let subtitle = new_mxm()
.await
.track_subtitle( .track_subtitle(
TrackId::Commontrack(18576954), TrackId::Commontrack(18576954),
SubtitleFormat::Json, SubtitleFormat::Json,
@ -1036,20 +998,16 @@ mod translation {
} }
} }
#[track_caller] #[tokio::test]
fn assert_imgurl(url: &Option<String>, ends_with: &str) { async fn no_credentials() {
assert!( let mxm = Musixmatch::builder()
url.as_deref().is_some_and( .client_type(ClientType::Android)
|url| url.starts_with("https://s.mxmcdn.net/images-storage/") .no_storage()
&& url.ends_with(ends_with) .build()
), .unwrap();
"expected url ending with {ends_with}\ngot {:?}", let err = mxm
url .track_lyrics(TrackId::TrackId(205688271))
); .await
} .unwrap_err();
assert!(matches!(err, Error::MissingCredentials), "error: {err}");
/// 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}");
} }