Compare commits

...

66 commits

Author SHA1 Message Date
c9328afa35
chore(release): release musixmatch-cli v0.4.0 2025-12-19 14:24:56 +01:00
45c4874a62
chore(release): release musixmatch-inofficial v0.4.0 2025-12-19 14:24:35 +01:00
4d1fbabaa8
feat: add richsync support to CLI 2025-12-19 14:20:30 +01:00
e044cfcbd2
fix: mark structs non-exhaustive, add weekly charts, update URLs to new MXM documentation 2025-12-19 14:14:07 +01:00
16e4440a5d
feat: add richsync API (word-by-word lyrics) 2025-12-19 13:51:37 +01:00
5e5942beb8
chore(release): release musixmatch-cli v0.3.1 2025-12-08 10:49:57 +01:00
f6fe442e3c
chore(release): release musixmatch-inofficial v0.3.0 2025-12-08 10:48:24 +01:00
4782f4905e
test: add back artist_mbid assertion 2025-12-08 10:47:12 +01:00
c6b3382948
test: fix tests 2025-12-08 10:39:26 +01:00
e087752dd0
fix: remove serde::__private::fmt 2025-12-08 10:26:47 +01:00
80c3c38c41
ci: use cargo-nextest 2025-08-09 23:45:59 +02:00
39bbd5070a
fix: clippy lints (3) 2025-08-09 18:39:20 +02:00
09c6004ed1
fix: clippy lints (2) 2025-08-09 18:36:45 +02:00
bfe6fec115
fix: clippy lints 2025-08-09 18:34:40 +02:00
528c6f7eb1
test: fix tests 2025-08-09 15:25:23 +02:00
74d5359547
feat!: removed artist_related endpoint (discontinued) 2025-08-09 15:24:44 +02:00
59dee61a2f
fix: update signature secret 2025-08-09 15:24:02 +02:00
f73dcdd134
chore(release): release musixmatch-inofficial v0.2.1 2025-04-04 14:12:48 +02:00
04a0544ad5
test: fix tests 2025-04-04 14:07:43 +02:00
4a46e7bb1d
docs: fix docs 2025-04-04 14:07:43 +02:00
6f90033cf4
fix: parsing unset has_fan_chant field 2025-04-04 14:07:42 +02:00
7c325c4af7
chore(deps): update rust crate rand to 0.9.0 2025-04-04 14:07:42 +02:00
a3f2ffc5d9
chore(deps): update rust crate rstest to 0.25.0 2025-04-04 14:07:42 +02:00
87859e629f
chore(deps): update rust crate governor to 0.10.0 2025-04-04 14:07:42 +02:00
c90bfc647c
test: update translation track id 2025-04-04 14:07:41 +02:00
0bb886adab ci: disable renovate 2025-02-22 23:01:18 +00:00
bf68f94682
ci: renovate: preserveSemverRanges 2025-02-03 03:27:45 +01:00
7b5a6e2e50
chore(release): release musixmatch-cli v0.3.0 2025-01-16 01:48:11 +01:00
62e0308dda
chore(release): release musixmatch-inofficial v0.2.0 2025-01-16 01:47:30 +01:00
2c22f2aa33
ci: increase rate limit 2025-01-13 05:20:49 +01:00
ThetaBot
319dabeee0 fix(deps): update rust crate dirs to v6 (#7) 2025-01-13 04:14:03 +00:00
ThetaBot
6942d0eaaa chore(deps): update rust crate rstest to 0.24.0 (#6) 2025-01-13 04:09:14 +00:00
2926455376
test: fix tests 2025-01-13 05:04:52 +01:00
ThetaBot
4d26c4a72f chore(deps): update rust crate governor to 0.8.0 (#5) 2024-12-11 00:05:24 +00:00
b136bb3004
feat: add track performer tagging, artist images 2024-12-08 23:18:09 +01:00
26f4729738
chore: fix clippy lints 2024-12-07 19:50:28 +01:00
693ff34755 ci: add workdlow_dispatch 2024-12-06 20:18:08 +00:00
adcd9baf12
chore(release): release musixmatch-inofficial v0.1.2 2024-11-15 20:19:34 +01:00
ThetaBot
6a6ced1622 fix(deps): update rust crate thiserror to v2 (#4) 2024-11-15 18:56:06 +00:00
368b46fa79
test: fix missing lyrics test 2024-11-15 19:37:55 +01:00
e4cffa53ca
test: skip artist_albums test 2024-10-23 22:51:41 +02:00
ThetaBot
4bfcb79173 chore(deps): update rust crate governor to 0.7.0 (#3) 2024-10-23 20:08:45 +00:00
ThetaBot
5ef76f5a6b chore(deps): update rust crate rstest to 0.23.0 (#2) 2024-09-30 00:07:35 +00:00
424a522708
chore(release): release musixmatch-cli v0.2.0 2024-08-19 00:21:36 +02:00
38386c0132
chore(release): release musixmatch-inofficial v0.1.1 2024-08-19 00:20:39 +02:00
a95f3fcf47
feat: add msrv 2024-08-19 00:13:29 +02:00
3b69b36ae6
test: add rate limiter 2024-08-19 00:05:19 +02:00
df150d9ffd
ci: remove musixmatch credentials 2024-08-18 23:45:47 +02:00
54235e6fb6
feat!: remove MP3 feature, refactor cmd structure 2024-08-18 23:44:27 +02:00
c4bfbe563a
feat: add get album, get artist, search artist 2024-08-18 23:44:24 +02:00
dc1bea13cc
fix: use native TLS for CLI 2024-08-18 22:58:52 +02:00
bc0dd99f7d
ci: enable retries 2024-08-18 18:38:21 +02:00
c9fea762ec
test: fix tests 2024-08-18 18:35:00 +02:00
c120583bf8
test: fix tests 2024-08-18 18:32:21 +02:00
05978665de
ci: use credentials 2024-08-18 18:30:42 +02:00
f45ad3cefb
ci: enable warpproxy 2024-08-18 18:28:00 +02:00
348e9c5427
doc: update readme 2024-08-18 18:25:03 +02:00
19e209e34f
feat: add format option to mp3 subtitles cmd 2024-08-18 18:19:00 +02:00
30e2afd367
chore: change repo to codeberg 2024-08-18 18:16:07 +02:00
c7d40a75ee
ci: add renovate
Some checks failed
renovate / renovate (push) Successful in 41s
CI / Test (push) Failing after 3m27s
2024-08-18 17:39:34 +02:00
dcc25bff20
chore: update dependencies 2024-08-18 17:26:42 +02:00
1bc5ae4083
chore: update justfile 2024-08-18 16:16:26 +02:00
d2a7aed917
test: fix tests 2024-08-18 16:14:05 +02:00
dc01542515
ci: fix changelog tag pattern 2024-04-12 03:28:34 +02:00
e72d2b4363
chore: fix changelogs
All checks were successful
CI / Test (push) Successful in 1m49s
2024-04-11 13:49:36 +02:00
8afc43a097
chore(release): release musixmatch-cli v0.1.0
All checks were successful
Release / Release (push) Successful in 1m5s
CI / Test (push) Successful in 35s
2024-03-23 02:51:41 +01:00
34 changed files with 2083 additions and 555 deletions

View file

@ -1,9 +1,20 @@
name: CI
on: [push, pull_request]
on:
push:
pull_request:
workflow_dispatch:
jobs:
Test:
runs-on: cimaster-latest
services:
warpproxy:
image: thetadev256/warpproxy
env:
WARP_DEVICE_ID: ${{ secrets.WARP_DEVICE_ID }}
WARP_ACCESS_TOKEN: ${{ secrets.WARP_ACCESS_TOKEN }}
WARP_LICENSE_KEY: ${{ secrets.WARP_LICENSE_KEY }}
WARP_PRIVATE_KEY: ${{ secrets.WARP_PRIVATE_KEY }}
steps:
- name: 📦 Checkout repository
uses: actions/checkout@v3
@ -16,4 +27,25 @@ jobs:
run: cargo clippy --all -- -D warnings
- name: 🧪 Test
run: cargo test --workspace
run: cargo nextest run --config-file ~/.config/nextest.toml --profile ci --retries 2 -j 1 --workspace
env:
ALL_PROXY: "http://warpproxy:8124"
- name: Move test report
if: always()
run: mv target/nextest/ci/junit.xml junit.xml || true
- name: 💌 Upload test report
if: always()
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: test
path: |
junit.xml
- name: 🔗 Artifactview PR comment
if: ${{ always() && github.event_name == 'pull_request' }}
run: |
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}'

View file

@ -23,13 +23,8 @@ jobs:
echo END_OF_FILE
} >> "$GITHUB_ENV"
- name: 📤 Publish crate on code.thetadev.de
run: |
mkdir -p ~/.cargo
printf '[registries.thetadev]\nindex = "https://code.thetadev.de/ThetaDev/_cargo-index.git"\ntoken = "Bearer ${{ secrets.TOKEN_GITEA }}"\n' >> ~/.cargo/config.toml
sed -i "s/^musixmatch-.*=\s*{/\0 registry = \"thetadev\",/g" Cargo.toml
cargo publish --registry thetadev --package "${{ env.CRATE }}" --allow-dirty
git restore Cargo.toml
- 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

View file

@ -0,0 +1,63 @@
name: renovate
on:
push:
branches: ["main"]
paths:
- ".forgejo/workflows/renovate.yaml"
- "renovate.json"
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
jobs:
renovate:
runs-on: docker
container:
image: renovate/renovate:39
steps:
- name: Load renovate repo cache
uses: actions/cache/restore@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}
restore-keys: |
repo-cache-
- name: Run renovate
run: renovate
env:
LOG_LEVEL: debug
RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp
RENOVATE_ENDPOINT: ${{ github.server_url }}
RENOVATE_PLATFORM: gitea
RENOVATE_REPOSITORY_CACHE: 'enabled'
RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }}
GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }}
RENOVATE_GIT_AUTHOR: 'Renovate Bot <forgejo-renovate-action@forgejo.org>'
RENOVATE_X_SQLITE_PACKAGE_CACHE: true
GIT_AUTHOR_NAME: 'Renovate Bot'
GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org'
GIT_COMMITTER_NAME: 'Renovate Bot'
GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org'
OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv
- name: Save renovate repo cache
if: always() && env.RENOVATE_DRY_RUN != 'full'
uses: actions/cache/save@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}

View file

@ -2,7 +2,98 @@
All notable changes to this project will be documented in this file.
## v0.1.0 - 2024-03-23
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.3.0..musixmatch-inofficial/v0.4.0) - 2025-12-19
### 🚀 Features
- Add richsync API (word-by-word lyrics) - ([16e4440](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/16e4440a5d31cf8078c1327d8168975576d4db1f))
### 🐛 Bug Fixes
- Mark structs non-exhaustive, add weekly charts, update URLs to new MXM documentation - ([e044cfc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e044cfcbd231380e11c14cab31e46b651a942819))
## [v0.3.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.2.1..musixmatch-inofficial/v0.3.0) - 2025-12-08
### 🚀 Features
- [**breaking**] Removed artist_related endpoint (discontinued) - ([74d5359](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/74d5359547e0b3ffa75f5f7384ed3b7ab0e2ea11))
### 🐛 Bug Fixes
- Update signature secret - ([59dee61](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/59dee61a2fdbda6a7643aa209b9baaf25a6de0f8))
- Clippy lints - ([bfe6fec](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/bfe6fec115b9ba3b58f1e949ac7b583a95041f8f))
- Clippy lints (2) - ([09c6004](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/09c6004ed13c2f9039490c083b0e6b61dccaa884))
- Remove serde::__private::fmt - ([e087752](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e087752dd0d331093a3434e18b5ff276bf4dc7c1))
## [v0.2.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.2.0..musixmatch-inofficial/v0.2.1) - 2025-04-04
### 🐛 Bug Fixes
- Parsing unset has_fan_chant field - ([6f90033](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6f90033cf4284eff5c12a30aafb21943c1575b92))
### 📚 Documentation
- Fix docs - ([4a46e7b](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4a46e7bb1d83c6261660d403c009cdb640b301d7))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate governor to 0.10.0 - ([87859e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/87859e629f3c236ba450872b29beb7876be7ef0b))
- *(deps)* Update rust crate rstest to 0.25.0 - ([a3f2ffc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a3f2ffc5d99ddddf777b4de306bd215bd3bbf5ce))
- *(deps)* Update rust crate rand to 0.9.0 - ([7c325c4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/7c325c4af779e32059680c1cfb874f83896d7649))
## [v0.2.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.2..musixmatch-inofficial/v0.2.0) - 2025-01-16
### 🚀 Features
- Add track performer tagging, artist images - ([b136bb3](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/b136bb30040dc3ee849c26ff984884e706739235))
### ⚙️ Miscellaneous Tasks
- Fix clippy lints - ([26f4729](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/26f4729738536d735cb808fce8a8e466f2e82449))
- *(deps)* Update rust crate governor to 0.8.0 (#5) - ([4d26c4a](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4d26c4a72f617228a5e62d4d565e2c7a6f3d7f95))
- *(deps)* Update rust crate rstest to 0.24.0 (#6) - ([6942d0e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6942d0eaaa6dfa15846c7f1a09ca4165a5a4b3c3))
## [v0.1.2](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.1..musixmatch-inofficial/v0.1.2) - 2024-11-15
### 🐛 Bug Fixes
- *(deps)* Update rust crate thiserror to v2 (#4) - ([6a6ced1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6a6ced16224c6ef3d05eb6ebd0aa0bdc40a34684))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.23.0 (#2) - ([5ef76f5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/5ef76f5a6b2a3b243f847cf86e72ebe176819d7a))
- *(deps)* Update rust crate governor to 0.7.0 (#3) - ([4bfcb79](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4bfcb791733ce5ebd9d4e074c64eb23e9a768fc6))
## [v0.1.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-inofficial/v0.1.0..musixmatch-inofficial/v0.1.1) - 2024-08-18
### 🚀 Features
- Add msrv - ([a95f3fc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a95f3fcf478f1acda9fad12741604b6793e128c1))
### 📚 Documentation
- Update readme - ([348e9c5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/348e9c5427e59c488d7e2f7cef9e7006a12864f2))
### 🧪 Testing
- Fix tests - ([d2a7aed](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/d2a7aed917bfcec75ce00bb49d380fbc31c47384))
- Fix tests - ([c120583](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c120583bf861cc74fbce686b2bd88bc575270130))
- Fix tests - ([c9fea76](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c9fea762ec97a1c594e60a3b1cbc72bb786d0957))
- Add rate limiter - ([3b69b36](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/3b69b36ae6c945d786534e0eaa353fb737b1fb54))
### ⚙️ Miscellaneous Tasks
- Update justfile - ([1bc5ae4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/1bc5ae408343e6755e390909e7017647efcf59a1))
- Update dependencies - ([dcc25bf](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dcc25bff202becdec7101c5ce1825cd75e445f99))
- Change repo to codeberg - ([30e2afd](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/30e2afd3679d2c17a49afd523c8b8bad70f291e5))
## [v0.1.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/commits/tag/musixmatch-inofficial/v0.1.0) - 2024-03-23
Initial release

View file

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

View file

@ -1,5 +1,5 @@
test:
cargo test
cargo nextest run --workspace --no-fail-fast --retries 1 -j 1
release crate="musixmatch-inofficial":
#!/usr/bin/env bash
@ -10,12 +10,12 @@ release crate="musixmatch-inofficial":
CHANGELOG="CHANGELOG.md"
if [ "$CRATE" = "musixmatch-inofficial" ]; then
INCLUDES="$INCLUDES --include-path src/** --include-path tests/** --include-path testfiles/**"
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/**"
INCLUDES="$INCLUDES --include-path '$CRATE/**'"
CHANGELOG="$CRATE/$CHANGELOG"
CRATE="musixmatch-$CRATE" # Add crate name prefix
fi
@ -26,17 +26,17 @@ release crate="musixmatch-inofficial":
if git rev-parse "$TAG" >/dev/null 2>&1; then echo "version tag $TAG already exists"; exit 1; fi
CLIFF_ARGS="--tag v${VERSION} --tag-pattern ${CRATE}/* --unreleased $INCLUDES"
CLIFF_ARGS="--tag '${TAG}' --tag-pattern '${CRATE}/v*' --unreleased $INCLUDES"
echo "git-cliff $CLIFF_ARGS"
if [ -f "$CHANGELOG" ]; then
git-cliff $CLIFF_ARGS --prepend "$CHANGELOG"
eval "git-cliff $CLIFF_ARGS --prepend '$CHANGELOG'"
else
git-cliff $CLIFF_ARGS --output "$CHANGELOG"
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
fi
editor "$CHANGELOG"
git add "$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"

View file

@ -1,4 +1,8 @@
# 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
Musixmatch Android app.
@ -7,10 +11,10 @@ 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 required 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 decided to close the API
down again.
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.
## ⚠️ Copyright disclaimer
@ -23,7 +27,7 @@ commercially.
You will get in trouble if you use this client to create a public lyrics site/app. If
you want to use Musixmatch data for this purpose, you will have to give them money (see
their [commercial plans](https://developer.musixmatch.com/plans)) and use their
[official API](https://developer.musixmatch.com/documentation).
[official API](https://docs.musixmatch.com/lyrics-api).
## Development info

84
cli/CHANGELOG.md Normal file
View file

@ -0,0 +1,84 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v0.4.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.3.1..musixmatch-cli/v0.4.0) - 2025-12-19
### 🚀 Features
- Add richsync support to CLI - ([4d1fbab](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4d1fbabaa8ff3268c83bd3725412e48d81b3f7c1))
### 🐛 Bug Fixes
- Mark structs non-exhaustive, add weekly charts, update URLs to new MXM documentation - ([e044cfc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e044cfcbd231380e11c14cab31e46b651a942819))
## [v0.3.1](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.3.0..musixmatch-cli/v0.3.1) - 2025-12-08
### 🐛 Bug Fixes
- Clippy lints - ([bfe6fec](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/bfe6fec115b9ba3b58f1e949ac7b583a95041f8f))
- Clippy lints (3) - ([39bbd50](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/39bbd5070a62d865c0e276904047bb687f1ba8a8))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate governor to 0.10.0 - ([87859e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/87859e629f3c236ba450872b29beb7876be7ef0b))
- *(deps)* Update rust crate rstest to 0.25.0 - ([a3f2ffc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a3f2ffc5d99ddddf777b4de306bd215bd3bbf5ce))
- *(deps)* Update rust crate rand to 0.9.0 - ([7c325c4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/7c325c4af779e32059680c1cfb874f83896d7649))
- *(deps)* Update musixmatch to 0.3.0
## [v0.3.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.2.0..musixmatch-cli/v0.3.0) - 2025-01-16
### 🚀 Features
- Add track performer tagging, artist images - ([b136bb3](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/b136bb30040dc3ee849c26ff984884e706739235))
### 🐛 Bug Fixes
- *(deps)* Update rust crate thiserror to v2 (#4) - ([6a6ced1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6a6ced16224c6ef3d05eb6ebd0aa0bdc40a34684))
- *(deps)* Update rust crate dirs to v6 (#7) - ([319dabe](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/319dabeee018f8b5b633cf91e792b12fa18e7775))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate rstest to 0.23.0 (#2) - ([5ef76f5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/5ef76f5a6b2a3b243f847cf86e72ebe176819d7a))
- *(deps)* Update rust crate governor to 0.7.0 (#3) - ([4bfcb79](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4bfcb791733ce5ebd9d4e074c64eb23e9a768fc6))
- *(deps)* Update rust crate governor to 0.8.0 (#5) - ([4d26c4a](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/4d26c4a72f617228a5e62d4d565e2c7a6f3d7f95))
- *(deps)* Update rust crate rstest to 0.24.0 (#6) - ([6942d0e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/6942d0eaaa6dfa15846c7f1a09ca4165a5a4b3c3))
## [v0.2.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/compare/musixmatch-cli/v0.1.0..musixmatch-cli/v0.2.0) - 2024-08-18
### 🚀 Features
- Add format option to mp3 subtitles cmd - ([19e209e](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/19e209e34f4d129a4223930bfd41e1ccf117f231))
- Add get album, get artist, search artist - ([c4bfbe5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/c4bfbe563a00d399b3645dd68f03c1215ee51fdb))
- [**breaking**] Remove MP3 feature, refactor cmd structure - ([54235e6](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/54235e6fb61084823a6583aaa7d59b1799deb07f))
- Add msrv - ([a95f3fc](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/a95f3fcf478f1acda9fad12741604b6793e128c1))
### 🐛 Bug Fixes
- Use native TLS for CLI - ([dc1bea1](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dc1bea13cc2a37eae7f3727dc72f865a01430a2e))
### 📚 Documentation
- Update readme - ([348e9c5](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/348e9c5427e59c488d7e2f7cef9e7006a12864f2))
### 🧪 Testing
- Fix tests - ([d2a7aed](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/d2a7aed917bfcec75ce00bb49d380fbc31c47384))
- Add rate limiter - ([3b69b36](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/3b69b36ae6c945d786534e0eaa353fb737b1fb54))
### ⚙️ Miscellaneous Tasks
- Fix changelogs - ([e72d2b4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/e72d2b4363a3a9a48dec8f2be9389f6cc239035c))
- Update justfile - ([1bc5ae4](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/1bc5ae408343e6755e390909e7017647efcf59a1))
- Update dependencies - ([dcc25bf](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/dcc25bff202becdec7101c5ce1825cd75e445f99))
- Change repo to codeberg - ([30e2afd](https://codeberg.org/ThetaDev/musixmatch-inofficial/commit/30e2afd3679d2c17a49afd523c8b8bad70f291e5))
## [v0.1.0](https://codeberg.org/ThetaDev/musixmatch-inofficial/commits/tag/musixmatch-cli/v0.1.0) - 2024-03-23
Initial release
<!-- generated by git-cliff -->

View file

@ -1,6 +1,7 @@
[package]
name = "musixmatch-cli"
version = "0.1.0"
version = "0.4.0"
rust-version = "1.70.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
@ -9,7 +10,7 @@ keywords.workspace = true
description = "Inofficial command line interface for the Musixmatch API"
[features]
default = ["rustls-tls-native-roots"]
default = ["native-tls"]
# Reqwest TLS options
native-tls = ["musixmatch-inofficial/native-tls"]
@ -20,11 +21,9 @@ rustls-tls-native-roots = ["musixmatch-inofficial/rustls-tls-native-roots"]
[dependencies]
musixmatch-inofficial.workspace = true
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"
tokio = { version = "1.20.4", features = ["macros", "rt-multi-thread"] }
clap = { version = "4.0.0", features = ["derive"] }
anyhow = "1.0.0"
rpassword = "7.0.0"
dirs = "5.0.0"
serde_json = "1.0.91"
dirs = "6.0.0"
serde_json = "1.0.85"

77
cli/README.md Normal file
View file

@ -0,0 +1,77 @@
# 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,3 +1,6 @@
#![doc = include_str!("../README.md")]
#![warn(missing_docs, clippy::todo)]
use std::{
io::{stdin, stdout, Write},
path::PathBuf,
@ -5,9 +8,8 @@ use std::{
use anyhow::{anyhow, bail, Result};
use clap::{Args, Parser, Subcommand};
use id3::{Tag, TagLike};
use musixmatch_inofficial::{
models::{SubtitleFormat, Track, TrackId, TranslationMap},
models::{AlbumId, ArtistId, SubtitleFormat, Track, TrackId, TranslationMap},
Musixmatch,
};
@ -20,14 +22,66 @@ struct Cli {
#[derive(Subcommand)]
enum Commands {
Get {
#[command(subcommand)]
command: GetCommands,
/// Get lyrics text
Lyrics {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Language
#[clap(long)]
lang: Option<String>,
/// Bilingual
#[clap(long)]
bi: bool,
},
Mp3 {
#[command(subcommand)]
command: FileCommands,
/// Get subtitles (time-synced lyrics)
Subtitles {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Track length
#[clap(short, long)]
length: Option<f32>,
/// Maximum deviation from track length (Default: 1s)
#[clap(long)]
max_deviation: Option<f32>,
/// Subtitle format
#[clap(short, long, default_value = "lrc")]
format: SubtitleFormatClap,
/// Language
#[clap(long)]
lang: Option<String>,
},
/// Get richsync (word-by-word synced lyrics)
Richsync {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Track length
#[clap(short, long)]
length: Option<f32>,
/// Maximum deviation from track length (Default: 1s)
#[clap(long)]
max_deviation: Option<f32>,
},
/// Get track metadata
Track {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Get performer tagging
Performer {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Get album metadata
Album {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Get artist metadata
Artist {
#[clap(flatten)]
ident: AlbumArtistIdentifiers,
},
/// Search for Musixmatch tracks
#[group(required = true)]
Search {
/// Track name
@ -42,40 +96,8 @@ enum Commands {
/// Search query
query: Option<Vec<String>>,
},
}
#[derive(Subcommand)]
enum GetCommands {
Lyrics {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Language
#[clap(long)]
lang: Option<String>,
/// Bilingual
#[clap(long)]
bi: bool,
},
Subtitles {
#[clap(flatten)]
ident: TrackIdentifiers,
/// Track length
#[clap(long, short)]
length: Option<f32>,
/// Maximum deviation from track length (Default: 1s)
#[clap(long)]
max_deviation: Option<f32>,
/// Subtitle format
#[clap(long, default_value = "lrc")]
format: SubtitleFormatClap,
/// Language
#[clap(long)]
lang: Option<String>,
},
Track {
#[clap(flatten)]
ident: TrackIdentifiers,
},
/// Search for Musixmatch artists
SearchArtist { query: Vec<String> },
}
#[derive(Args)]
@ -106,22 +128,38 @@ 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)]
pub enum SubtitleFormatClap {
enum SubtitleFormatClap {
Lrc,
Ttml,
TtmlStructured,
@ -148,7 +186,7 @@ async fn main() {
let cli = Cli::parse();
run(cli).await.unwrap_or_else(|e| {
println!("Error: {}", e);
eprintln!("Error: {e}");
std::process::exit(1);
});
}
@ -175,164 +213,183 @@ async fn run(cli: Cli) -> Result<()> {
};
match cli.command {
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?;
Commands::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."
);
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()
} 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()
} else {
translated
};
}
translated
};
}
}
}
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?;
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!();
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);
}
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")
}
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)?;
let title = tag.title().ok_or(anyhow!("no title"))?;
let artist = tag.artist().ok_or(anyhow!("no artist"))?;
let lyrics = mxm.matcher_lyrics(title, artist).await?;
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)?;
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?;
};
println!("{res}");
} else {
eprintln!();
println!("{}", subtitles.subtitle_body);
}
},
}
Commands::Richsync {
ident,
length,
max_deviation,
} => {
let track_id = get_track_id(ident, &mxm).await?;
let richsync = mxm
.track_richsync(track_id.clone(), length, max_deviation.or(Some(1.0)))
.await?;
eprintln!("Richsync ID: {}", richsync.richsync_id);
eprintln!(
"Language: {}",
richsync.richsync_language.as_deref().unwrap_or(NA_STR)
);
eprintln!("Length: {}", richsync.richsync_length);
eprintln!(
"Copyright: {}",
richsync
.lyrics_copyright
.as_deref()
.map(|s| s.trim())
.unwrap_or(NA_STR)
);
eprintln!();
println!("{}", richsync.richsync_body);
}
Commands::Track { ident } => {
let track = get_track(ident, &mxm, false).await?;
println!("{}", serde_json::to_string_pretty(&track)?)
}
Commands::Performer { ident } => {
let track = get_track(ident, &mxm, true).await?;
println!(
"{}",
serde_json::to_string_pretty(&track.performer_tagging)?
)
}
Commands::Album { ident } => {
let id = if let Some(id) = ident.mxm_id {
AlbumId::AlbumId(id)
} else if let Some(mb) = &ident.musicbrainz {
AlbumId::Musicbrainz(mb)
} else {
bail!("no album ID specified")
};
let album = mxm.album(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Artist { ident } => {
let id = if let Some(id) = ident.mxm_id {
ArtistId::ArtistId(id)
} else if let Some(mb) = &ident.musicbrainz {
ArtistId::Musicbrainz(mb)
} else {
bail!("no artist ID specified")
};
let album = mxm.artist(id).await?;
println!("{}", serde_json::to_string_pretty(&album)?)
}
Commands::Search {
query,
name,
@ -369,6 +426,15 @@ async fn run(cli: Cli) -> Result<()> {
);
}
}
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(())
}
@ -382,6 +448,7 @@ async fn get_track_or_id(
ident: TrackIdentifiers,
mxm: &Musixmatch,
translation_status: bool,
performer_tagging: bool,
) -> Result<TrackOrId<'static>> {
Ok(
match (
@ -407,8 +474,15 @@ async fn get_track_or_id(
}
(_, _, _, _, _, _, _, Some(isrc)) => TrackOrId::TrackId(TrackId::Isrc(isrc.into())),
(Some(name), Some(artist), _, _, _, _, _, _) => TrackOrId::Track(Box::new(
mxm.matcher_track(&name, &artist, "", translation_status, true)
.await?,
mxm.matcher_track(
&name,
&artist,
"",
translation_status,
true,
performer_tagging,
)
.await?,
)),
_ => bail!("no track identifier given"),
},
@ -416,21 +490,27 @@ async fn get_track_or_id(
}
async fn get_track_id(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<TrackId<'static>> {
Ok(match get_track_or_id(ident, mxm, false).await? {
Ok(match get_track_or_id(ident, mxm, false, false).await? {
TrackOrId::Track(track) => TrackId::TrackId(track.track_id),
TrackOrId::TrackId(id) => id,
})
}
async fn get_track(ident: TrackIdentifiers, mxm: &Musixmatch) -> Result<Track> {
Ok(match get_track_or_id(ident, mxm, true).await? {
TrackOrId::Track(track) => *track,
TrackOrId::TrackId(id) => mxm.track(id, true, true).await?,
})
async fn get_track(
ident: TrackIdentifiers,
mxm: &Musixmatch,
performer_tagging: bool,
) -> Result<Track> {
Ok(
match get_track_or_id(ident, mxm, true, performer_tagging).await? {
TrackOrId::Track(track) => *track,
TrackOrId::TrackId(id) => mxm.track(id, true, true, performer_tagging).await?,
},
)
}
fn input(prompt: &str) -> String {
print!("{}", prompt);
print!("{prompt}");
stdout().flush().expect("Failed to flush stdout!");
@ -442,7 +522,7 @@ fn input(prompt: &str) -> String {
}
fn input_pwd(prompt: &str) -> String {
print!("{}", prompt);
print!("{prompt}");
stdout().flush().expect("Failed to flush stdout!");
rpassword::read_password().expect("Failed to read password")

View file

@ -14,12 +14,13 @@ 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://code.thetadev.de/ThetaDev/rustypipe" %}\
{% set repo_url = "https://codeberg.org/ThetaDev/musixmatch-inofficial" %}\
{% if version %}\
{%set vname = version | split(pat="/") | last %}
{%if previous.version %}\
## [{{ version }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\
## [{{ vname }}]({{ repo_url }}/compare/{{ previous.version }}..{{ version }})\
{% else %}\
## {{ version }}\
## [{{ vname }}]({{ repo_url }}/commits/tag/{{ version }})\
{% endif %} - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
@ -72,8 +73,8 @@ commit_parsers = [
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ 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" },

11
renovate.json Normal file
View file

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

View file

@ -99,8 +99,8 @@ pub enum LoginCredential {
#[derive(Debug, Deserialize)]
pub struct Account {
pub id: String,
pub email: String,
// pub id: String,
// pub email: String,
pub name: String,
}
@ -117,7 +117,7 @@ where
{
struct BoolFromIntVisitor;
impl<'de> Visitor<'de> for BoolFromIntVisitor {
impl Visitor<'_> for BoolFromIntVisitor {
type Value = bool;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
@ -232,7 +232,7 @@ where
n: PhantomData<N>,
}
impl<'de, N> Visitor<'de> for NullIfZeroVisitor<N>
impl<N> Visitor<'_> for NullIfZeroVisitor<N>
where
N: TryFrom<u64>,
{
@ -300,7 +300,7 @@ where
{
struct NullIfEmptyVisitor;
impl<'de> Visitor<'de> for NullIfEmptyVisitor {
impl Visitor<'_> for NullIfEmptyVisitor {
type Value = Option<String>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
@ -347,7 +347,7 @@ where
n: PhantomData<N>,
}
impl<'de, N> Visitor<'de> for ParseIntVisitor<N>
impl<N> Visitor<'_> for ParseIntVisitor<N>
where
N: FromStr + TryFrom<u64>,
{
@ -441,13 +441,10 @@ pub mod optional_date {
) -> Result<Option<Date>, D::Error> {
struct OptionalDateVisitor;
impl<'de> Visitor<'de> for OptionalDateVisitor {
impl Visitor<'_> for OptionalDateVisitor {
type Value = Option<Date>;
fn expecting(
&self,
formatter: &mut serde::__private::fmt::Formatter,
) -> serde::__private::fmt::Result {
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("date or empty string")
}
@ -501,13 +498,10 @@ pub mod optional_datetime {
) -> Result<Option<OffsetDateTime>, D::Error> {
struct OptionalDateVisitor;
impl<'de> Visitor<'de> for OptionalDateVisitor {
impl Visitor<'_> for OptionalDateVisitor {
type Value = Option<OffsetDateTime>;
fn expecting(
&self,
formatter: &mut serde::__private::fmt::Formatter,
) -> serde::__private::fmt::Result {
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("timestamp or empty string")
}
@ -543,6 +537,55 @@ pub mod optional_datetime {
}
}
pub fn single_or_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
struct SingleOrVecVisitor<T> {
t: PhantomData<T>,
}
impl<'de, T> Visitor<'de> for SingleOrVecVisitor<T>
where
T: Deserialize<'de>,
{
type Value = Vec<T>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("single object or list")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut res = Vec::new();
while let Some(x) = seq.next_element()? {
res.push(x);
}
Ok(res)
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let (k1, val) = map
.next_entry::<&str, T>()?
.ok_or(serde::de::Error::missing_field("value"))?;
if let Some((k2, _)) = map.next_entry::<&str, serde::de::IgnoredAny>()? {
return Err(serde::de::Error::custom(format!(
"expected only 1 value, got keys `{k1}`, `{k2}`"
)));
}
Ok(vec![val])
}
}
deserializer.deserialize_any(SingleOrVecVisitor { t: PhantomData })
}
#[cfg(test)]
mod tests {
use time::Date;
@ -686,4 +729,21 @@ mod tests {
let res = serde_json::from_str::<S>(json_date).unwrap();
assert!(res.date.is_some());
}
#[test]
fn deserialize_single_or_vec() {
#[derive(Deserialize, Debug)]
struct S {
#[serde(deserialize_with = "single_or_vec")]
vec: Vec<u8>,
}
let res = serde_json::from_str::<S>(r#"{"vec": [1, 2, 3]}"#).unwrap();
assert_eq!(res.vec, [1, 2, 3]);
let res = serde_json::from_str::<S>(r#"{"vec": {"value": 1}}"#).unwrap();
assert_eq!(res.vec, [1]);
serde_json::from_str::<S>(r#"{"vec": {"value": 1, "other": "xyz"}}"#).unwrap_err();
}
}

View file

@ -10,7 +10,7 @@ impl Musixmatch {
/// - `id`: [Album ID](crate::models::AlbumId)
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/album-get>
/// <https://docs.musixmatch.com/lyrics-api/album/album-get>
pub async fn album(&self, id: AlbumId<'_>) -> Result<Album> {
let mut url = self.new_url("album.get");
{
@ -35,7 +35,7 @@ impl Musixmatch {
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-albums-get>
/// <https://docs.musixmatch.com/lyrics-api/artist/artist-albums-get>
pub async fn artist_albums(
&self,
artist_id: ArtistId<'_>,
@ -65,7 +65,7 @@ impl Musixmatch {
.collect())
}
/// This api provides you the list of the top albums of a given country.
/// This api provides you the list of the newly released albums of a given country.
///
/// # Parameters
/// - `country`: A valid country code (default: "US")

View file

@ -10,7 +10,7 @@ impl Musixmatch {
/// - `id`: [Artist ID](crate::models::ArtistId)
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-get>
/// <https://docs.musixmatch.com/lyrics-api/artist/artist-get>
pub async fn artist(&self, id: ArtistId<'_>) -> Result<Artist> {
let mut url = self.new_url("artist.get");
{
@ -18,6 +18,7 @@ impl Musixmatch {
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
url_query.append_pair("part", "artist_image");
url_query.finish();
}
@ -25,40 +26,6 @@ impl Musixmatch {
Ok(artist_body.artist)
}
/// Get a list of artists somehow related to the one specified by its ID.
///
/// # Parameters
/// - `id`: [Artist ID](crate::models::ArtistId)
/// - `page_size`: Define the page size for paginated results. Range is 1 to 100.
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-related-get>
pub async fn artist_related(
&self,
id: ArtistId<'_>,
page_size: u8,
page: u32,
) -> Result<Vec<Artist>> {
let mut url = self.new_url("artist.related.get");
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
url_query.append_pair("page_size", &page_size.to_string());
url_query.append_pair("page", &page.to_string());
url_query.finish();
}
let artist_list_body = self.execute_get_request::<ArtistListBody>(&url).await?;
Ok(artist_list_body
.artist_list
.into_iter()
.map(|a| a.artist)
.collect())
}
/// Search for artists in the Musixmatch database.
///
/// # Parameters
@ -67,7 +34,7 @@ impl Musixmatch {
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-search>
/// <https://docs.musixmatch.com/lyrics-api/artist/artist-search>
pub async fn artist_search(
&self,
q_artist: &str,
@ -100,7 +67,7 @@ impl Musixmatch {
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/artist-chart-get>
/// <https://docs.musixmatch.com/lyrics-api/charts/chart-artists-get>
pub async fn chart_artists(
&self,
country: &str,

View file

@ -12,7 +12,7 @@ impl Musixmatch {
/// - `q_artist`: Artist of the track
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-lyrics-get>
/// <https://docs.musixmatch.com/lyrics-api/matcher/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");
{
@ -37,7 +37,7 @@ impl Musixmatch {
/// - `id`: [Track ID](crate::models::TrackId)
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-lyrics-get>
/// <https://docs.musixmatch.com/lyrics-api/track/track-lyrics-get>
pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result<Lyrics> {
let mut url = self.new_url("track.lyrics.get");
{
@ -58,9 +58,9 @@ impl Musixmatch {
/// - `id`: [Track ID](crate::models::TrackId)
/// - `selected_language`: The language of the translated lyrics
/// [(ISO 6391)](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
///
/// # Reference
/// None, the [public translation API](https://developer.musixmatch.com/documentation/api-reference/track-lyrics-translation-get)
/// is only available on commercial plans
/// <https://docs.musixmatch.com/lyrics-api/track/track-lyrics-translation-get>
pub async fn track_lyrics_translation(
&self,
id: TrackId<'_>,

View file

@ -1,6 +1,7 @@
mod album_api;
mod artist_api;
mod lyrics_api;
mod richsync_api;
mod snippet_api;
mod subtitle_api;
mod track_api;

45
src/apis/richsync_api.rs Normal file
View file

@ -0,0 +1,45 @@
use crate::error::Result;
use crate::models::richsync::{RichsyncBody, RichsyncLyrics};
use crate::models::TrackId;
use crate::Musixmatch;
impl Musixmatch {
/// Get the richsync (word-by-word synchronized lyrics) for a track specified by its ID.
///
/// # Parameters
/// - `id`: [Track ID](crate::models::TrackId)
/// - `f_richsync_length`: Optional richsync length (track duration) in seconds
/// - `f_richsync_length_max_deviation`: Optional maximum amount of seconds the richsync length
/// is allowed to deviate from the given value. The Musixmatch app sets this value to 1,
/// so this should be the recommended value.
///
/// # Reference
/// <https://docs.musixmatch.com/lyrics-api/track/track-richsync-get>
pub async fn track_richsync(
&self,
id: TrackId<'_>,
f_richsync_length: Option<f32>,
f_richsync_length_max_deviation: Option<f32>,
) -> Result<RichsyncLyrics> {
let mut url = self.new_url("track.richsync.get");
{
let mut url_query = url.query_pairs_mut();
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
if let Some(f_richsync_length) = f_richsync_length {
url_query.append_pair("f_richsync_length", &f_richsync_length.to_string());
}
if let Some(f_richsync_length_max_deviation) = f_richsync_length_max_deviation {
url_query.append_pair(
"f_richsync_length_max_deviation",
&f_richsync_length_max_deviation.to_string(),
);
}
url_query.finish();
}
let richsync_body = self.execute_get_request::<RichsyncBody>(&url).await?;
Ok(richsync_body.richsync)
}
}

View file

@ -14,7 +14,7 @@ impl Musixmatch {
/// - `id`: [Track ID](crate::models::TrackId)
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-snippet-get>
/// <https://docs.musixmatch.com/lyrics-api/track/track-snippet-get>
pub async fn track_snippet(&self, id: TrackId<'_>) -> Result<Snippet> {
let mut url = self.new_url("track.snippet.get");
{

View file

@ -16,7 +16,7 @@ impl Musixmatch {
/// so this should be the recommended value.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-subtitle-get>
/// <https://docs.musixmatch.com/lyrics-api/matcher/matcher-subtitle-get>
pub async fn matcher_subtitle(
&self,
q_track: &str,
@ -65,7 +65,7 @@ impl Musixmatch {
/// so this should be the recommended value.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-subtitle-get>
/// <https://docs.musixmatch.com/lyrics-api/track/track-subtitle-get>
pub async fn track_subtitle(
&self,
id: TrackId<'_>,

View file

@ -18,7 +18,7 @@ impl Musixmatch {
/// instead of [ISO 6391](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) codes.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/matcher-track-get>
/// <https://docs.musixmatch.com/lyrics-api/matcher/matcher-track-get>
pub async fn matcher_track(
&self,
q_track: &str,
@ -26,6 +26,7 @@ impl Musixmatch {
q_album: &str,
translation_status: bool,
lang_3c: bool,
performer_tagging: bool,
) -> Result<Track> {
let mut url = self.new_url("matcher.track.get");
{
@ -40,8 +41,10 @@ impl Musixmatch {
if !q_album.is_empty() {
url_query.append_pair("q_album", q_album);
}
let mut part = Vec::new();
if translation_status {
url_query.append_pair("part", "track_lyrics_translation_status");
part.push("track_lyrics_translation_status");
url_query.append_pair(
"language_iso_code",
match lang_3c {
@ -50,6 +53,13 @@ impl Musixmatch {
},
);
}
if performer_tagging {
part.push("track_performer_tagging");
}
if !part.is_empty() {
url_query.append_pair("part", &part.join(","));
}
url_query.finish();
}
@ -67,12 +77,13 @@ impl Musixmatch {
/// instead of [ISO 6391](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) codes.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-get>
/// <https://docs.musixmatch.com/lyrics-api/track/track-get>
pub async fn track(
&self,
id: TrackId<'_>,
translation_status: bool,
lang_3c: bool,
performer_tagging: bool,
) -> Result<Track> {
let mut url = self.new_url("track.get");
{
@ -80,8 +91,10 @@ impl Musixmatch {
let id_param = id.to_param();
url_query.append_pair(id_param.0, &id_param.1);
let mut part = Vec::new();
if translation_status {
url_query.append_pair("part", "track_lyrics_translation_status");
part.push("track_lyrics_translation_status");
url_query.append_pair(
"language_iso_code",
match lang_3c {
@ -90,6 +103,13 @@ impl Musixmatch {
},
);
}
if performer_tagging {
part.push("track_performer_tagging");
}
if !part.is_empty() {
url_query.append_pair("part", &part.join(","));
}
url_query.finish();
}
@ -106,7 +126,7 @@ impl Musixmatch {
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/album-tracks-get>
/// <https://docs.musixmatch.com/lyrics-api/album/album-tracks-get>
pub async fn album_tracks(
&self,
id: AlbumId<'_>,
@ -146,7 +166,7 @@ impl Musixmatch {
/// - `page`: Define the page number for paginated results, starting from 1.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-chart-get>
/// <https://docs.musixmatch.com/lyrics-api/charts/chart-tracks-get>
pub async fn chart_tracks(
&self,
country: &str,
@ -182,7 +202,7 @@ impl Musixmatch {
/// Get the list of the music genres the Musixmatch catalogue.
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/music-genres-get>
/// <https://docs.musixmatch.com/lyrics-api/charts/music-genres-get>
pub async fn genres(&self) -> Result<Vec<Genre>> {
let url = self.new_url("music.genres.get");
let genres = self.execute_get_request::<Genres>(&url).await?;
@ -205,8 +225,8 @@ impl Musixmatch {
/// ```
///
/// # Reference
/// <https://developer.musixmatch.com/documentation/api-reference/track-search>
pub fn track_search(&self) -> TrackSearchQuery {
/// <https://docs.musixmatch.com/lyrics-api/track/track-search>
pub fn track_search(&self) -> TrackSearchQuery<'_> {
TrackSearchQuery {
mxm: self.clone(),
q_track: None,

View file

@ -52,3 +52,8 @@ impl From<serde_json::Error> for Error {
Self::InvalidData(value.to_string().into())
}
}
/// Could not parse Musixmatch FQID
#[derive(thiserror::Error, Debug)]
#[error("Could not parse Musixmatch FQID")]
pub struct IdError;

View file

@ -11,7 +11,7 @@ use std::fmt::Debug;
use std::path::Path;
use std::sync::{Arc, RwLock};
pub use error::Error;
pub use error::{Error, IdError};
use base64::Engine;
use hmac::{Hmac, Mac};
@ -36,7 +36,7 @@ const YMD_FORMAT: &[time::format_description::FormatItem] =
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('";
const SIGNATURE_SECRET: &[u8; 29] = b"mNdca@6W7TeEcFn6*3.s97sJ*yPMd";
const DEFAULT_UA: &str = "Dalvik/2.1.0 (Linux; U; Android 13; Pixel 6 Build/T3B2.230316.003)";
const DEFAULT_BRAND: &str = "Google";
@ -247,7 +247,7 @@ impl Musixmatch {
// 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"),
&format!("{API_URL}token.get"),
&[
("adv_id", adv_id.as_str()),
("root", "0"),
@ -343,7 +343,7 @@ impl Musixmatch {
match serde_json::to_string(&to_store) {
Ok(json) => storage.write(&json),
Err(e) => error!("Could not serialize session. Error: {}", e),
Err(e) => error!("Could not serialize session. Error: {e}"),
}
}
}
@ -355,7 +355,7 @@ impl Musixmatch {
.and_then(|json| match serde_json::from_str::<StoredSession>(&json) {
Ok(session) => Some(session),
Err(e) => {
error!("Could not deserialize session. Error: {}", e);
error!("Could not deserialize session. Error: {e}");
None
}
})
@ -374,7 +374,7 @@ impl Musixmatch {
fn new_url(&self, endpoint: &str) -> reqwest::Url {
Url::parse_with_params(
&format!("{}{}", API_URL, endpoint),
&format!("{API_URL}{endpoint}"),
&[("app_id", APP_ID), ("format", "json")],
)
.unwrap()
@ -449,26 +449,26 @@ impl Musixmatch {
}
fn random_guid() -> String {
let mut rng = rand::thread_rng();
let n = rng.gen::<u64>();
format!("{:016x}", n)
let mut rng = rand::rng();
let n = rng.random::<u64>();
format!("{n:016x}")
}
fn random_uuid() -> String {
let mut rng = rand::thread_rng();
let mut rng = rand::rng();
format!(
"{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
rng.gen::<u32>(),
rng.gen::<u16>(),
rng.gen::<u16>(),
rng.gen::<u16>(),
rng.gen::<u64>() & 0xffffffffffff,
rng.random::<u32>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u16>(),
rng.random::<u64>() & 0xffffffffffff,
)
}
fn new_url_from_token(endpoint: &str, usertoken: &str) -> reqwest::Url {
Url::parse_with_params(
&format!("{}{}", API_URL, endpoint),
&format!("{API_URL}{endpoint}"),
&[
("app_id", APP_ID),
("usertoken", usertoken),
@ -503,6 +503,6 @@ mod tests {
fn t_sign_url() {
let mut url = Url::parse("https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm").unwrap();
sign_url_with_date(&mut url, datetime!(2022-09-28 0:00 UTC));
assert_eq!(url.as_str(), "https://apic.musixmatch.com/ws/1.1/track.subtitle.get?app_id=android-player-v1.0&usertoken=22092860c49e8d783b569a7bd847cd5b289bbec306f8a0bb2d3771&format=json&track_spotify_id=7Ga0ByppmSXWuKXdsD8JGL&subtitle_format=mxm&signature=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=78ywxkeXlazpevI%2BbD8E3YluLPc%3D%0A&signature_protocol=sha1")
}
}

View file

@ -15,6 +15,7 @@ pub(crate) struct AlbumListBody {
/// Album: an album of songs in the Musixmatch database.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Album {
/// Unique Musixmatch Album ID
pub album_id: u64,
@ -82,7 +83,7 @@ pub struct Album {
/// Album type
///
/// Source: <https://developer.musixmatch.com/documentation/music-meta-data>
/// Source: <https://docs.musixmatch.com/lyrics-api/musixmatch-metadata#album-type-and-release-date>
#[allow(missing_docs)]
#[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AlbumType {

View file

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

View file

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

View file

@ -1,4 +1,5 @@
//! Musixmatch API models
#![warn(clippy::exhaustive_structs)]
pub(crate) mod subtitle;
pub use subtitle::Subtitle;
@ -10,12 +11,19 @@ pub use subtitle::SubtitleTime;
mod id;
pub use id::AlbumId;
pub use id::ArtistId;
pub use id::Fqid;
pub use id::MxmEntityType;
pub use id::SortOrder;
pub use id::TrackId;
pub(crate) mod lyrics;
pub use lyrics::Lyrics;
pub(crate) mod richsync;
pub use richsync::RichsyncLine;
pub use richsync::RichsyncLyrics;
pub use richsync::RichsyncWord;
pub(crate) mod translation;
pub use translation::Translation;
pub use translation::TranslationList;
@ -23,8 +31,12 @@ pub use translation::TranslationMap;
pub(crate) mod track;
pub use track::ChartName;
pub use track::Performer;
pub use track::PerformerTaggingPart;
pub use track::PerformerTaggingResources;
pub use track::Track;
pub use track::TrackLyricsTranslationStatus;
pub use track::TrackPerformerTagging;
mod genre;
pub use genre::Genre;

83
src/models/richsync.rs Normal file
View file

@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::error::Error;
#[derive(Debug, Deserialize)]
pub(crate) struct RichsyncBody {
pub richsync: RichsyncLyrics,
}
/// Lyrics synced word-by-word
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RichsyncLyrics {
/// Unique Musixmatch richsync ID
pub richsync_id: u64,
/// Richsync JSON data
///
/// List of [`RichsyncLine`]
pub richsync_body: String,
/// Language code (e.g. "en")
///
/// Note that this field has a typo and is called `richssync_language` in the Musixmatch implementation.
#[serde(
default,
deserialize_with = "crate::api_model::null_if_empty",
alias = "richssync_language"
)]
pub richsync_language: Option<String>,
/// Language name (e.g. "English")
#[serde(default, deserialize_with = "crate::api_model::null_if_empty")]
pub richsync_language_description: Option<String>,
/// Duration of the synced track in seconds
pub richsync_length: u32,
/// Copyright text of the lyrics
///
/// Ends with a newline.
///
/// Example:
/// ```text
/// Writer(s): David Hodges
/// Copyright: Emi Blackwood Music Inc., 12.06 Publishing, Hipgnosis Sfh I Limited, Hifi Music Ip Issuer L.p.
/// ```
#[serde(default, deserialize_with = "crate::api_model::null_if_empty")]
pub lyrics_copyright: Option<String>,
/// Date and time when the lyrics were last updated
#[serde(with = "time::serde::rfc3339")]
pub updated_time: OffsetDateTime,
}
/// Line of word-by-word synchronized lyrics
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RichsyncLine {
/// Line start timestamp in seconds
pub ts: f32,
/// Line end timestamp in seconds
pub te: f32,
/// Words in the line
#[serde(default)]
pub l: Vec<RichsyncWord>,
/// Text content of the line
pub x: String,
}
/// Single word of word-by-word synchronized lyrics
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RichsyncWord {
/// Word content
pub c: String,
/// Time offset from the line timestamp in seconds
///
/// Position of the word in the track is line.ts + o.
pub o: f32,
}
impl RichsyncLyrics {
/// Get the synchronized lyrics lines
pub fn get_lines(&self) -> Result<Vec<RichsyncLine>, Error> {
serde_json::from_str(&self.richsync_body).map_err(Error::from)
}
}

View file

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

View file

@ -323,6 +323,7 @@ fn escape_xml(input: &str) -> String {
/// Single subtitle line
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct SubtitleLine {
/// Subtitle line text
pub text: String,
@ -332,6 +333,7 @@ pub struct SubtitleLine {
/// Position of a subtitle line in the track
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::exhaustive_structs)]
pub struct SubtitleTime {
/// Minute component of the timestamp
pub minutes: u32,

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use super::Genres;
use super::{Artist, Fqid, Genres};
#[derive(Debug, Deserialize)]
pub(crate) struct TrackBody {
@ -140,6 +140,8 @@ pub struct Track {
/// Status of lyrics translation
#[serde(default)]
pub track_lyrics_translation_status: Vec<TrackLyricsTranslationStatus>,
/// Lyrics parts marked with the performer who is singing them
pub performer_tagging: Option<TrackPerformerTagging>,
}
/// Status of lyrics translation (language + progress)
@ -156,13 +158,88 @@ pub struct TrackLyricsTranslationStatus {
pub perc: f32,
}
/// Lyrics parts marked with the performer who is singing them
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TrackPerformerTagging {
/// Musixmatch user ID of the user who added the performer tags
///
/// Format: `mxm:<16 byte hex>`
pub user_id: String,
/// True if the lyrics are completely tagged
#[serde(default)]
pub completed: bool,
/// True if the lyrics have unknown performers
#[serde(default)]
pub has_unknown: bool,
/// True if the lyrics contain parts that are intended to be sung by the
/// audience during concerts
#[serde(default)]
pub has_fan_chant: bool,
/// List of tagged lyrics parts
#[serde(default)]
pub content: Vec<PerformerTaggingPart>,
/// Artists (and possibly other objects) that are referenced by the tagged parts
#[serde(default)]
pub resources: PerformerTaggingResources,
}
/// Performer-tagged lyrics part
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PerformerTaggingPart {
/// Part of the lyrics text
///
/// Includes whitespace (spaces and newline characters).
pub snippet: String,
/// Unknown
///
/// Values: 0-3
pub position: u32,
/// List of performers singing this part
pub performers: Vec<Performer>,
}
/// Lyrics performer
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Performer {
/// artist / unknown
#[serde(rename = "type")]
pub typ: Option<String>,
/// Fully-qualified performer ID
pub fqid: Option<Fqid>,
/// Unbekannt
///
/// 9
pub category_id: Option<std::num::NonZeroU32>,
/// Unbekannt
///
/// 405
pub credit_role_id: Option<std::num::NonZeroU32>,
}
/// Artists (and possibly other objects) that are referenced by the tagged parts
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct PerformerTaggingResources {
/// List of artists tagged as performers
pub artists: Vec<Artist>,
}
/// Available track charts
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChartName {
/// Editorial chart
Top,
/// Most viewed lyrics in the last 2 hours
Hot,
/// Most viewed lyrics in the last 7 days
MxmWeekly,
/// Most viewed lyrics in the last 7 days, limited to new releases only.
MxmWeeklyNew,
}
impl ChartName {
@ -170,6 +247,8 @@ impl ChartName {
match self {
ChartName::Top => "top",
ChartName::Hot => "hot",
ChartName::MxmWeekly => "mxmweekly",
ChartName::MxmWeeklyNew => "mxmweekly_new",
}
}
}

422
testfiles/richsync.json Normal file
View file

@ -0,0 +1,422 @@
[
{
"ts": 3.94,
"te": 7.24,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.33
},
{
"c": "birthday",
"o": 0.66
},
{
"c": " ",
"o": 1.8
},
{
"c": "to",
"o": 1.92
},
{
"c": " ",
"o": 2.235
},
{
"c": "you",
"o": 2.55
}
],
"x": "Happy birthday to you"
},
{
"ts": 8.0399,
"te": 11.34,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.33
},
{
"c": "birthday",
"o": 0.66
},
{
"c": " ",
"o": 1.26
},
{
"c": "to",
"o": 1.86
},
{
"c": " ",
"o": 2.205
},
{
"c": "you",
"o": 2.55
}
],
"x": "Happy birthday to you"
},
{
"ts": 11.88,
"te": 15.57,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday,",
"o": 0.63
},
{
"c": " ",
"o": 1.275
},
{
"c": "happy",
"o": 1.92
},
{
"c": " ",
"o": 2.25
},
{
"c": "birthday",
"o": 2.58
}
],
"x": "Happy birthday, happy birthday"
},
{
"ts": 15.74,
"te": 19.01,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday",
"o": 0.63
},
{
"c": " ",
"o": 1.185
},
{
"c": "to",
"o": 1.74
},
{
"c": " ",
"o": 2.145
},
{
"c": "you",
"o": 2.55
}
],
"x": "Happy birthday to you"
},
{
"ts": 19.71,
"te": 23.07,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.344
},
{
"c": "birthday",
"o": 0.689
},
{
"c": " ",
"o": 1.274
},
{
"c": "to",
"o": 1.86
},
{
"c": " ",
"o": 2.22
},
{
"c": "you",
"o": 2.58
}
],
"x": "Happy birthday to you"
},
{
"ts": 23.57,
"te": 26.96,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.33
},
{
"c": "birthday",
"o": 0.66
},
{
"c": " ",
"o": 1.26
},
{
"c": "to",
"o": 1.86
},
{
"c": " ",
"o": 2.235
},
{
"c": "you",
"o": 2.61
}
],
"x": "Happy birthday to you"
},
{
"ts": 27.52,
"te": 31.21,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday,",
"o": 0.63
},
{
"c": " ",
"o": 1.29
},
{
"c": "happy",
"o": 1.95
},
{
"c": " ",
"o": 2.28
},
{
"c": "birthday",
"o": 2.61
}
],
"x": "Happy birthday, happy birthday"
},
{
"ts": 31.42,
"te": 34.42,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday",
"o": 0.63
},
{
"c": " ",
"o": 1.2
},
{
"c": "to",
"o": 1.77
},
{
"c": " ",
"o": 2.175
},
{
"c": "you",
"o": 2.58
}
],
"x": "Happy birthday to you"
},
{
"ts": 37.44,
"te": 40.59,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday",
"o": 0.63
},
{
"c": " ",
"o": 1.74
},
{
"c": "to",
"o": 1.86
},
{
"c": " ",
"o": 2.22
},
{
"c": "you",
"o": 2.58
}
],
"x": "Happy birthday to you"
},
{
"ts": 41.26,
"te": 44.38,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.3
},
{
"c": "birthday",
"o": 0.6
},
{
"c": " ",
"o": 1.2
},
{
"c": "to",
"o": 1.8
},
{
"c": " ",
"o": 2.145
},
{
"c": "you",
"o": 2.49
}
],
"x": "Happy birthday to you"
},
{
"ts": 45.17,
"te": 50.24,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday,",
"o": 0.63
},
{
"c": " ",
"o": 1.275
},
{
"c": "happy",
"o": 1.92
},
{
"c": " ",
"o": 2.264
},
{
"c": "birthday",
"o": 2.61
}
],
"x": "Happy birthday, happy birthday"
},
{
"ts": 50.99,
"te": 54.11,
"l": [
{
"c": "Happy",
"o": 0.0
},
{
"c": " ",
"o": 0.315
},
{
"c": "birthday",
"o": 0.63
},
{
"c": " ",
"o": 1.2
},
{
"c": "to",
"o": 1.77
},
{
"c": " ",
"o": 2.13
},
{
"c": "you",
"o": 2.49
}
],
"x": "Happy birthday to you"
}
]

File diff suppressed because it is too large Load diff