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

View file

@ -1,39 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.2.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.1.0..musixmatch-cli/v0.2.0) - 2024-08-18
### 🚀 Features
- Add format option to mp3 subtitles cmd - ([19e209e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/19e209e34f4d129a4223930bfd41e1ccf117f231))
- Add get album, get artist, search artist - ([c4bfbe5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c4bfbe563a00d399b3645dd68f03c1215ee51fdb))
- [**breaking**] Remove MP3 feature, refactor cmd structure - ([54235e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/54235e6fb61084823a6583aaa7d59b1799deb07f))
- Add msrv - ([a95f3fc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a95f3fcf478f1acda9fad12741604b6793e128c1))
### 🐛 Bug Fixes
- Use native TLS for CLI - ([dc1bea1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dc1bea13cc2a37eae7f3727dc72f865a01430a2e))
### 📚 Documentation
- Update readme - ([348e9c5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/348e9c5427e59c488d7e2f7cef9e7006a12864f2))
### 🧪 Testing
- Fix tests - ([d2a7aed](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/d2a7aed917bfcec75ce00bb49d380fbc31c47384))
- Add rate limiter - ([3b69b36](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/3b69b36ae6c945d786534e0eaa353fb737b1fb54))
### ⚙️ Miscellaneous Tasks
- Fix changelogs - ([e72d2b4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e72d2b4363a3a9a48dec8f2be9389f6cc239035c))
- Update justfile - ([1bc5ae4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/1bc5ae408343e6755e390909e7017647efcf59a1))
- Update dependencies - ([dcc25bf](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dcc25bff202becdec7101c5ce1825cd75e445f99))
- Change repo to codeberg - ([30e2afd](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/30e2afd3679d2c17a49afd523c8b8bad70f291e5))
## [v0.1.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/commits/tag/musixmatch-cli/v0.1.0) - 2024-03-23
Initial release
<!-- generated by git-cliff -->

View file

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

View file

@ -1,77 +0,0 @@
# musixmatch-cli
[![Current crates.io version](https://img.shields.io/crates/v/musixmatch-cli.svg)](https://crates.io/crates/musixmatch-cli)
[![License](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT)
[![CI status](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/workflows/ci.yaml/badge.svg?style=flat&label=CI)](https://codeberg.org/ThetaDev/musixmatch-inofficial/actions/?workflow=ci.yaml)
The Musixmatch CLI allows you to fetch lyrics, subtitles and track metadata from the
command line using the Musixmatch API.
The Musixmatch API used to require a free account on <https://www.musixmatch.com> to be
used. However, as of 2024, this requirement was removed and the API can be used
anonymously. The CLI still allows you to supply credentials if Musixmatch decides to
close the API down again.
### Get lyrics
```txt
musixmatch-cli lyrics -n shine -a spektrem
Lyrics ID: 34583240
Language: en
Copyright: Writer(s): Jesse Warren
Copyright: Ncs Music
Eyes in the sky gazing far into the night
I raise my hand to the fire, but it's no use
'Cause you can't stop it from shining through
It's true
...
```
### Get translated lyrics
Musixmatch also offers translated lyrics. You have to select a language using the
`--lang` flag. You can also set the `--bi` flag to output both the original and
translated lines.
```txt
musixmatch-cli lyrics -n shine -a spektrem --lang de --bi
Lyrics ID: 34583240
Language: en
Copyright: Writer(s): Jesse Warren
Copyright: Ncs Music
Translated to: de
Eyes in the sky gazing far into the night
> Augen starren in die weite Nacht
I raise my hand to the fire, but it's no use
> Ich hebe meine Hand in das Feuer, doch ihr geschieht nichts
'Cause you can't stop it from shining through
> Denn du kannst es nicht daran hindern, hindurch zu scheinen
It's true
> Es ist wahr
...
```
### Get subtitles (synchronized lyrics)
For most lyrics Musixmatch provides timestamps for the individual lines so you can
display them in sync during playback.
Musixmatch offers multiple subtitle formats you can select using the `--format` flag.
The available formats are: `lrc`, `ttml`, `ttml-structured`, `json`, `ebu-stl`
```txt
musixmatch-cli subtitles -n shine -a spektrem
Subtitle ID: 35340319
Language: en
Length: 316
Copyright: Writer(s): Jesse Warren
Copyright: Ncs Music
[00:59.84] Eyes in the sky gazing far into the night
[01:06.55] I raise my hand to the fire, but it's no use
[01:11.97] 'Cause you can't stop it from shining through
[01:16.07] It's true
...
```

View file

@ -1,6 +1,3 @@
#![doc = include_str!("../README.md")]
#![warn(missing_docs, clippy::todo)]
use std::{
io::{stdin, stdout, Write},
path::PathBuf,
@ -8,8 +5,9 @@ use std::{
use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand};
use id3::{Tag, TagLike};
use musixmatch_inofficial::{
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
models::{SubtitleFormat, Track, TrackId, TranslationMap},
Musixmatch,
};
@ -22,7 +20,18 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
/// Get lyrics text
Get {
#[command(subcommand)]
command: GetCommands,
},
Mp3 {
#[command(subcommand)]
command: FileCommands,
},
}
#[derive(Subcommand)]
enum GetCommands {
Lyrics {
#[clap(flatten)]
ident: TrackIdentifiers,
@ -33,55 +42,26 @@ enum Commands {
#[clap(long)]
bi: bool,
},
/// Get subtitles (time-synced lyrics)
Subtitles {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Track length
#[clap(short, long)]
#[clap(long, short)]
length: Option<f32>,
/// Maximum deviation from track length (Default: 1s)
#[clap(long)]
max_deviation: Option<f32>,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
#[clap(long, default_value = "lrc")]
format: SubtitleFormatClap,
/// Language
#[clap(long)]
lang: Option<String>,
},
/// Get track metadata
Track {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Get 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)]
@ -112,38 +92,22 @@ struct TrackIdentifiers {
isrc: Option<String>,
}
#[derive(Args)]
#[group(multiple = false)]
struct AlbumArtistIdentifiers {
/// Musixmatch-ID
#[clap(long)]
mxm_id: Option<u64>,
/// Musicbrainz-ID
#[clap(long)]
musicbrainz: Option<String>,
}
#[derive(Subcommand)]
enum FileCommands {
/// Get lyrics text
Lyrics {
/// Music file
#[clap(value_parser)]
file: PathBuf,
},
/// Get subtitles (time-synced lyrics)
Subtitles {
/// Music file
#[clap(value_parser)]
file: PathBuf,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
format: SubtitleFormatClap,
},
}
#[derive(clap::ValueEnum, Debug, Copy, Clone)]
enum SubtitleFormatClap {
pub enum SubtitleFormatClap {
Lrc,
Ttml,
TtmlStructured,
@ -197,193 +161,164 @@ async fn run(cli: Cli) -> Result<()> {
};
match cli.command {
Commands::Lyrics { ident, lang, bi } => {
let track_id = get_track_id(ident, &mxm).await?;
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
Commands::Get { command } => match command {
GetCommands::Lyrics { ident, lang, bi } => {
let track_id = get_track_id(ident, &mxm).await?;
let lyrics = mxm.track_lyrics(track_id.clone()).await?;
eprintln!("Lyrics ID: {}", lyrics.lyrics_id);
eprintln!(
"Language: {}",
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
);
eprintln!(
"Copyright: {}",
lyrics
.lyrics_copyright
.as_deref()
.map(|c| c.trim())
.unwrap_or(NA_STR)
);
eprintln!("Lyrics ID: {}", lyrics.lyrics_id);
eprintln!(
"Language: {}",
lyrics.lyrics_language.as_deref().unwrap_or(NA_STR)
);
eprintln!(
"Copyright: {}",
lyrics
.lyrics_copyright
.as_deref()
.map(|c| c.trim())
.unwrap_or(NA_STR)
);
let mut lyrics_body = lyrics.lyrics_body;
let mut lyrics_body = lyrics.lyrics_body;
if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
eprintln!("Translation not found. Returning lyrics in original language.");
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
if let Some(lang) = lang {
if Some(&lang) != lyrics.lyrics_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
eprintln!(
"Translation not found. Returning lyrics in original language."
);
} else {
translated
};
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
let translated = tm.translate_lyrics(&lyrics_body);
lyrics_body = if bi {
lyrics_body
.lines()
.zip(translated.lines())
.map(|(a, b)| {
if a == b {
a.to_string() + "\n"
} else {
format!("{a}\n> {b}\n")
}
})
.collect()
} else {
translated
};
}
}
}
eprintln!();
println!("{}", lyrics_body);
}
GetCommands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
if let Some(lang) = lang {
let mut lines = subtitles.to_lines()?;
if Some(&lang) != subtitles.subtitle_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
bail!("Translation not found")
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
lines = tm.translate_subtitles(&lines);
}
}
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
}
GetCommands::Track { ident } => {
let track = get_track(ident, &mxm).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
},
Commands::Mp3 { command } => match command {
FileCommands::Lyrics { file } => {
let tag = Tag::read_from_path(&file)?;
eprintln!();
println!("{}", lyrics_body);
}
Commands::Subtitles {
ident,
length,
max_deviation,
format,
lang,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let subtitles = mxm
.track_subtitle(
track_id.clone(),
if lang.is_some() {
SubtitleFormat::Json
} else {
format.into()
},
length,
max_deviation.or(Some(1.0)),
)
.await?;
let title = tag.title().ok_or(anyhow!("no title"))?;
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
eprintln!("Subtitle ID: {}", subtitles.subtitle_id);
eprintln!(
"Language: {}",
subtitles.subtitle_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", subtitles.subtitle_length);
eprintln!(
"Copyright: {}",
subtitles
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
let lyrics = mxm.matcher_lyrics(title, artist).await?;
if let Some(lang) = lang {
let mut lines = subtitles.to_lines()?;
println!(
"Lyrics for {} by {}:\n\n{}",
title, artist, lyrics.lyrics_body
);
}
FileCommands::Subtitles { file } => {
let tag = Tag::read_from_path(&file)?;
let duration = mp3_duration::from_path(&file)?;
if Some(&lang) != subtitles.subtitle_language.as_ref() {
let tl = mxm.track_lyrics_translation(track_id, &lang).await?;
if tl.is_empty() {
bail!("Translation not found")
} else {
eprintln!("Translated to: {}", tl.lang);
let tm = TranslationMap::from(tl);
lines = tm.translate_subtitles(&lines);
}
}
let title = tag.title().ok_or(anyhow!("no title"))?;
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
let subtitles = mxm
.matcher_subtitle(
title,
artist,
SubtitleFormat::Lrc,
Some(duration.as_secs_f32()),
Some(1.0),
)
.await?;
eprintln!();
let res = match format {
SubtitleFormatClap::Lrc => lines.to_lrc(),
SubtitleFormatClap::Ttml => lines.to_ttml(),
SubtitleFormatClap::Json => lines.to_json()?,
SubtitleFormatClap::TtmlStructured | SubtitleFormatClap::EbuStl => {
bail!("subtitle format {format:?} cant be translated")
}
};
println!("{}", res);
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
}
Commands::Track { ident } => {
let track = get_track(ident, &mxm).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(())
}

View file

@ -1,100 +0,0 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% set repo_url = "https://codeberg.org/ThetaDev/musixmatch-inofficial" %}\
{% if version %}\
{%set vname = version | split(pat="/") | last %}
{%if previous.version %}\
## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\
{% else %}\
## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\
{% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% if previous.version %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }} - \
([{{ commit.id | truncate(length=7, end="") }}]({{ repo_url }}/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}\
{% else %}
Initial release
{% endif %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", skip = true },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ message = "^ci", skip = true },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
# tag_pattern = "v[0-9].*"
# regex for skipping tags
# skip_tags = ""
# regex for ignoring tags
# ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
# limit_commits = 42

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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