From 16e4440a5d31cf8078c1327d8168975576d4db1f Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 19 Dec 2025 13:51:37 +0100 Subject: [PATCH 1/5] feat: add richsync API (word-by-word lyrics) --- src/apis/mod.rs | 1 + src/apis/richsync_api.rs | 45 +++++ src/models/mod.rs | 5 + src/models/richsync.rs | 83 ++++++++ testfiles/richsync.json | 422 +++++++++++++++++++++++++++++++++++++++ tests/tests.rs | 100 ++++++++++ 6 files changed, 656 insertions(+) create mode 100644 src/apis/richsync_api.rs create mode 100644 src/models/richsync.rs create mode 100644 testfiles/richsync.json diff --git a/src/apis/mod.rs b/src/apis/mod.rs index fcde0df..6efe0e9 100644 --- a/src/apis/mod.rs +++ b/src/apis/mod.rs @@ -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; diff --git a/src/apis/richsync_api.rs b/src/apis/richsync_api.rs new file mode 100644 index 0000000..2d726e3 --- /dev/null +++ b/src/apis/richsync_api.rs @@ -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 + /// + pub async fn track_richsync( + &self, + id: TrackId<'_>, + f_richsync_length: Option, + f_richsync_length_max_deviation: Option, + ) -> Result { + 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::(&url).await?; + Ok(richsync_body.richsync) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 8aef72e..01ff9a4 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -18,6 +18,11 @@ 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; diff --git a/src/models/richsync.rs b/src/models/richsync.rs new file mode 100644 index 0000000..94a4007 --- /dev/null +++ b/src/models/richsync.rs @@ -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, + /// Language name (e.g. "English") + #[serde(default, deserialize_with = "crate::api_model::null_if_empty")] + pub richsync_language_description: Option, + /// 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, + /// 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, + /// 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, Error> { + serde_json::from_str(&self.richsync_body).map_err(Error::from) + } +} diff --git a/testfiles/richsync.json b/testfiles/richsync.json new file mode 100644 index 0000000..02b14ab --- /dev/null +++ b/testfiles/richsync.json @@ -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" + } +] \ No newline at end of file diff --git a/tests/tests.rs b/tests/tests.rs index 70d1fc8..29da3eb 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1071,6 +1071,106 @@ mod translation { } } +mod richsync { + use std::{fs::File, io::BufWriter}; + + use super::*; + + #[rstest] + #[case::trackid(TrackId::TrackId(205688271))] + #[case::commontrack(TrackId::Commontrack(118480583))] + #[case::vanity(TrackId::CommontrackVanity("aespa/Black-Mamba".into()))] + #[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 richsync = mxm + .await + .track_richsync(track_id, Some(175.0), Some(1.0)) + .await + .unwrap(); + let lines = richsync.get_lines().unwrap(); + + assert_eq!(richsync.richsync_id, 8298962); + assert_eq!(richsync.richsync_language.unwrap(), "ko"); + assert_eq!(richsync.richsync_language_description.unwrap(), "korean"); + let copyright = richsync.lyrics_copyright.unwrap(); + assert!( + copyright.contains("Kenneth Scott Chesak"), + "copyright: {copyright}", + ); + assert_eq!(richsync.richsync_length, 175); + assert!(richsync.updated_time > datetime!(2022-8-27 0:00 UTC)); + + assert_eq!(lines.len(), 67); + for line in &lines { + assert!(!line.l.is_empty()); + } + } + + /// This track has no lyrics + #[rstest] + #[tokio::test] + async fn instrumental(#[future] mxm: Musixmatch) { + let err = mxm + .await + .track_richsync(TrackId::Commontrack(126454494), Some(246.0), Some(1.0)) + .await + .unwrap_err(); + + assert!(matches!(err, Error::NotFound), "got: {err:?}"); + } + + /// This track has not been synced + #[rstest] + #[tokio::test] + async fn unsynced(#[future] mxm: Musixmatch) { + let err = mxm + .await + .track_richsync( + TrackId::Spotify("6oaWIABGL7eeiMILEDyGX1".into()), + Some(213.0), + Some(1.0), + ) + .await + .unwrap_err(); + + assert!(matches!(err, Error::NotFound), "got: {err:?}"); + } + + /// Try to get subtitles with wrong length parameter + #[rstest] + #[tokio::test] + async fn wrong_length(#[future] mxm: Musixmatch) { + let err = mxm + .await + .track_richsync(TrackId::Commontrack(118480583), Some(200.0), Some(1.0)) + .await + .unwrap_err(); + + assert!(matches!(err, Error::NotFound), "got: {err:?}"); + } + + #[rstest] + #[tokio::test] + async fn download_testdata(#[future] mxm: Musixmatch) { + let json_path = testfile("richsync.json"); + if json_path.exists() { + return; + } + + let richsync = mxm + .await + .track_richsync(TrackId::Commontrack(26134491), None, None) + .await + .unwrap(); + let lines = richsync.get_lines().unwrap(); + + let json_file = File::create(json_path).unwrap(); + serde_json::to_writer_pretty(BufWriter::new(json_file), &lines).unwrap(); + } +} + #[track_caller] fn assert_imgurl(url: &Option, ends_with: &str) { assert!( From e044cfcbd231380e11c14cab31e46b651a942819 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 19 Dec 2025 14:08:52 +0100 Subject: [PATCH 2/5] fix: mark structs non-exhaustive, add weekly charts, update URLs to new MXM documentation --- README.md | 2 +- src/apis/album_api.rs | 6 +++--- src/apis/artist_api.rs | 6 +++--- src/apis/lyrics_api.rs | 8 ++++---- src/apis/snippet_api.rs | 2 +- src/apis/subtitle_api.rs | 4 ++-- src/apis/track_api.rs | 12 ++++++------ src/models/album.rs | 2 +- src/models/id.rs | 1 + src/models/mod.rs | 1 + src/models/snippet.rs | 1 + src/models/subtitle.rs | 2 ++ src/models/track.rs | 11 +++++++++++ tests/tests.rs | 2 ++ 14 files changed, 39 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index cd5c568..a67328c 100644 --- a/README.md +++ b/README.md @@ -27,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 diff --git a/src/apis/album_api.rs b/src/apis/album_api.rs index c95fead..88c175a 100644 --- a/src/apis/album_api.rs +++ b/src/apis/album_api.rs @@ -10,7 +10,7 @@ impl Musixmatch { /// - `id`: [Album ID](crate::models::AlbumId) /// /// # Reference - /// + /// pub async fn album(&self, id: AlbumId<'_>) -> Result { 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 - /// + /// 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") diff --git a/src/apis/artist_api.rs b/src/apis/artist_api.rs index 024c6bc..664c603 100644 --- a/src/apis/artist_api.rs +++ b/src/apis/artist_api.rs @@ -10,7 +10,7 @@ impl Musixmatch { /// - `id`: [Artist ID](crate::models::ArtistId) /// /// # Reference - /// + /// pub async fn artist(&self, id: ArtistId<'_>) -> Result { let mut url = self.new_url("artist.get"); { @@ -34,7 +34,7 @@ impl Musixmatch { /// - `page`: Define the page number for paginated results, starting from 1. /// /// # Reference - /// + /// pub async fn artist_search( &self, q_artist: &str, @@ -67,7 +67,7 @@ impl Musixmatch { /// - `page`: Define the page number for paginated results, starting from 1. /// /// # Reference - /// + /// pub async fn chart_artists( &self, country: &str, diff --git a/src/apis/lyrics_api.rs b/src/apis/lyrics_api.rs index d9a5038..f1e0cc2 100644 --- a/src/apis/lyrics_api.rs +++ b/src/apis/lyrics_api.rs @@ -12,7 +12,7 @@ impl Musixmatch { /// - `q_artist`: Artist of the track /// /// # Reference - /// + /// pub async fn matcher_lyrics(&self, q_track: &str, q_artist: &str) -> Result { let mut url = self.new_url("matcher.lyrics.get"); { @@ -37,7 +37,7 @@ impl Musixmatch { /// - `id`: [Track ID](crate::models::TrackId) /// /// # Reference - /// + /// pub async fn track_lyrics(&self, id: TrackId<'_>) -> Result { 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 639‑1)](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 + /// pub async fn track_lyrics_translation( &self, id: TrackId<'_>, diff --git a/src/apis/snippet_api.rs b/src/apis/snippet_api.rs index c8b3591..cf0fdfd 100644 --- a/src/apis/snippet_api.rs +++ b/src/apis/snippet_api.rs @@ -14,7 +14,7 @@ impl Musixmatch { /// - `id`: [Track ID](crate::models::TrackId) /// /// # Reference - /// + /// pub async fn track_snippet(&self, id: TrackId<'_>) -> Result { let mut url = self.new_url("track.snippet.get"); { diff --git a/src/apis/subtitle_api.rs b/src/apis/subtitle_api.rs index 904f028..656e0c2 100644 --- a/src/apis/subtitle_api.rs +++ b/src/apis/subtitle_api.rs @@ -16,7 +16,7 @@ impl Musixmatch { /// so this should be the recommended value. /// /// # Reference - /// + /// pub async fn matcher_subtitle( &self, q_track: &str, @@ -65,7 +65,7 @@ impl Musixmatch { /// so this should be the recommended value. /// /// # Reference - /// + /// pub async fn track_subtitle( &self, id: TrackId<'_>, diff --git a/src/apis/track_api.rs b/src/apis/track_api.rs index 1c25975..7ab24b3 100644 --- a/src/apis/track_api.rs +++ b/src/apis/track_api.rs @@ -18,7 +18,7 @@ impl Musixmatch { /// instead of [ISO 639‑1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) codes. /// /// # Reference - /// + /// pub async fn matcher_track( &self, q_track: &str, @@ -77,7 +77,7 @@ impl Musixmatch { /// instead of [ISO 639‑1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) codes. /// /// # Reference - /// + /// pub async fn track( &self, id: TrackId<'_>, @@ -126,7 +126,7 @@ impl Musixmatch { /// - `page`: Define the page number for paginated results, starting from 1. /// /// # Reference - /// + /// pub async fn album_tracks( &self, id: AlbumId<'_>, @@ -166,7 +166,7 @@ impl Musixmatch { /// - `page`: Define the page number for paginated results, starting from 1. /// /// # Reference - /// + /// pub async fn chart_tracks( &self, country: &str, @@ -202,7 +202,7 @@ impl Musixmatch { /// Get the list of the music genres the Musixmatch catalogue. /// /// # Reference - /// + /// pub async fn genres(&self) -> Result> { let url = self.new_url("music.genres.get"); let genres = self.execute_get_request::(&url).await?; @@ -225,7 +225,7 @@ impl Musixmatch { /// ``` /// /// # Reference - /// + /// pub fn track_search(&self) -> TrackSearchQuery<'_> { TrackSearchQuery { mxm: self.clone(), diff --git a/src/models/album.rs b/src/models/album.rs index 9d4e8f7..6389836 100644 --- a/src/models/album.rs +++ b/src/models/album.rs @@ -83,7 +83,7 @@ pub struct Album { /// Album type /// -/// Source: +/// Source: #[allow(missing_docs)] #[derive(Default, Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum AlbumType { diff --git a/src/models/id.rs b/src/models/id.rs index 3753c49..c91fe5c 100644 --- a/src/models/id.rs +++ b/src/models/id.rs @@ -103,6 +103,7 @@ impl SortOrder { /// Musixmatch fully qualified ID #[derive(Clone, Copy, PartialEq, Eq)] +#[allow(clippy::exhaustive_structs)] pub struct Fqid { /// Numeric Musixmatch ID pub id: u64, diff --git a/src/models/mod.rs b/src/models/mod.rs index 01ff9a4..f381bbe 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ //! Musixmatch API models +#![warn(clippy::exhaustive_structs)] pub(crate) mod subtitle; pub use subtitle::Subtitle; diff --git a/src/models/snippet.rs b/src/models/snippet.rs index fa9944b..05edfe8 100644 --- a/src/models/snippet.rs +++ b/src/models/snippet.rs @@ -15,6 +15,7 @@ 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, diff --git a/src/models/subtitle.rs b/src/models/subtitle.rs index 0ff3b09..a79fee3 100644 --- a/src/models/subtitle.rs +++ b/src/models/subtitle.rs @@ -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, diff --git a/src/models/track.rs b/src/models/track.rs index bfb1980..4104273 100644 --- a/src/models/track.rs +++ b/src/models/track.rs @@ -160,6 +160,7 @@ pub struct TrackLyricsTranslationStatus { /// 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 /// @@ -185,6 +186,7 @@ pub struct TrackPerformerTagging { /// Performer-tagged lyrics part #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct PerformerTaggingPart { /// Part of the lyrics text /// @@ -200,6 +202,7 @@ pub struct PerformerTaggingPart { /// Lyrics performer #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Performer { /// artist / unknown #[serde(rename = "type")] @@ -219,6 +222,7 @@ pub struct Performer { /// 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, @@ -226,11 +230,16 @@ pub struct PerformerTaggingResources { /// 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 { @@ -238,6 +247,8 @@ impl ChartName { match self { ChartName::Top => "top", ChartName::Hot => "hot", + ChartName::MxmWeekly => "mxmweekly", + ChartName::MxmWeeklyNew => "mxmweekly_new", } } } diff --git a/tests/tests.rs b/tests/tests.rs index 29da3eb..5365d6b 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -647,6 +647,8 @@ mod track { #[rstest] #[case::top(ChartName::Top)] #[case::hot(ChartName::Hot)] + #[case::weekly(ChartName::MxmWeekly)] + #[case::weekly_new(ChartName::MxmWeeklyNew)] #[tokio::test] async fn charts(#[case] chart_name: ChartName, #[future] mxm: Musixmatch) { let tracks = mxm From 4d1fbabaa8ff3268c83bd3725412e48d81b3f7c1 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 19 Dec 2025 14:20:30 +0100 Subject: [PATCH 3/5] feat: add richsync support to CLI --- cli/src/main.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cli/src/main.rs b/cli/src/main.rs index 84e26b9..da4a38c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -50,6 +50,17 @@ enum Commands { #[clap(long)] lang: Option, }, + /// Get richsync (word-by-word synced lyrics) + Richsync { + #[clap(flatten)] + ident: TrackIdentifiers, + /// Track length + #[clap(short, long)] + length: Option, + /// Maximum deviation from track length (Default: 1s) + #[clap(long)] + max_deviation: Option, + }, /// Get track metadata Track { #[clap(flatten)] @@ -318,6 +329,34 @@ async fn run(cli: Cli) -> Result<()> { 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)?) From 45c4874a62fc5a25188c6f3b94fffa5c5d8e261b Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 19 Dec 2025 14:24:35 +0100 Subject: [PATCH 4/5] chore(release): release musixmatch-inofficial v0.4.0 --- CHANGELOG.md | 11 +++++++++++ Cargo.toml | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0801b22..2f857c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. +## [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 diff --git a/Cargo.toml b/Cargo.toml index 0d3ead1..b2875fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "musixmatch-inofficial" -version = "0.3.0" +version = "0.4.0" rust-version = "1.70.0" edition.workspace = true authors.workspace = true @@ -23,7 +23,7 @@ keywords = ["music", "lyrics"] categories = ["api-bindings", "multimedia"] [workspace.dependencies] -musixmatch-inofficial = { version = "0.3.0", path = ".", default-features = false } +musixmatch-inofficial = { version = "0.4.0", path = ".", default-features = false } [features] default = ["default-tls"] From c9328afa35887f35e0cf9f46869875e64794fab3 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 19 Dec 2025 14:24:56 +0100 Subject: [PATCH 5/5] chore(release): release musixmatch-cli v0.4.0 --- cli/CHANGELOG.md | 11 +++++++++++ cli/Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9c882bc..28bdae4 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,6 +3,17 @@ 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 diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6543bc5..fea8aa0 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "musixmatch-cli" -version = "0.3.1" +version = "0.4.0" rust-version = "1.70.0" edition.workspace = true authors.workspace = true