From 088bd875dd8b5f5c67f8649e5250b541c3e5aa3d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:09:58 +0700 Subject: [PATCH] refactor(core): move the lenient Go-style JSON decoder to core::json The case-folding, null-tolerant struct decoder (`go_deserialize!` with `field_name`, `KeySeed`, `Update` and `Field`) lived in `core::lyrics::json` although downloads, metadata and resolver payloads depend on it. Move it unchanged to a neutral `core::json` module and name the macro after its behavior, `lenient_deserialize!`. All users are inside spotiflac-core, so no compatibility re-export is kept. The lyrics module keeps only its surrogate-normalizing `decode` helper. `field_name` is now public so the extension registry decoder reuses it instead of its own copy of the same folding. Its matcher folds the key once and compares ASCII case-insensitively against the wire name, which a new test checks against the previous matcher on edge inputs (empty, long s, Kelvin sign, combining marks, non-ASCII letters, BOM, NUL). The visitor's `expecting` text is unchanged so decode errors stay byte-identical. --- rust_backend/crates/core/src/downloads.rs | 2 +- rust_backend/crates/core/src/json.rs | 160 ++++++++++++++++++ rust_backend/crates/core/src/lib.rs | 1 + rust_backend/crates/core/src/lyrics.rs | 7 +- rust_backend/crates/core/src/lyrics/config.rs | 4 +- rust_backend/crates/core/src/lyrics/json.rs | 146 +--------------- rust_backend/crates/core/src/lyrics/lrclib.rs | 2 +- rust_backend/crates/core/src/lyrics/models.rs | 62 +++---- .../crates/core/src/lyrics/payloads.rs | 30 ++-- .../crates/core/src/metadata/deezer.rs | 10 +- .../core/src/metadata/deezer/catalog.rs | 22 +-- .../crates/core/src/metadata/musicbrainz.rs | 12 +- .../crates/core/src/metadata/reenrich.rs | 4 +- .../crates/core/src/metadata/share.rs | 4 +- rust_backend/crates/core/src/resolver.rs | 26 +-- .../crates/extensions/src/repository/model.rs | 93 +++++++++- 16 files changed, 340 insertions(+), 245 deletions(-) create mode 100644 rust_backend/crates/core/src/json.rs diff --git a/rust_backend/crates/core/src/downloads.rs b/rust_backend/crates/core/src/downloads.rs index 773c5399..4e0d5832 100644 --- a/rust_backend/crates/core/src/downloads.rs +++ b/rust_backend/crates/core/src/downloads.rs @@ -65,7 +65,7 @@ pub struct DownloadRequest { pub network_concurrency_limit: i64, } -crate::lyrics::json::go_deserialize!(DownloadRequest { +crate::json::lenient_deserialize!(DownloadRequest { "contract_version" => contract_version, "isrc" => isrc, "service" => service, "download_provider" => download_provider, "provider_track_id" => provider_track_id, "spotify_id" => spotify_id, "track_name" => track_name, "artist_name" => artist_name, diff --git a/rust_backend/crates/core/src/json.rs b/rust_backend/crates/core/src/json.rs new file mode 100644 index 00000000..de367951 --- /dev/null +++ b/rust_backend/crates/core/src/json.rs @@ -0,0 +1,160 @@ +//! Lenient typed JSON decoding with Go's `encoding/json` struct rules: object +//! keys match fields case-insensitively (with Go's `ſ` and Kelvin sign folding), +//! a later duplicate key wins, and a null scalar keeps the earlier value. + +use serde::de::{DeserializeSeed, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::borrow::Cow; + +/// Folds a JSON object key the way Go matches it against struct field names. +pub fn field_name(name: &str) -> String { + name.chars() + .map(|ch| match ch { + 'ſ' => 's', + '\u{212A}' => 'k', // KELVIN SIGN + _ => ch.to_ascii_lowercase(), + }) + .collect() +} + +struct KeyVisitor; + +impl<'de> Visitor<'de> for KeyVisitor { + type Value = Cow<'de, str>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a string key") + } + + fn visit_borrowed_str(self, value: &'de str) -> Result { + Ok(Cow::Borrowed(value)) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Cow::Owned(value.into())) + } + + fn visit_string(self, value: String) -> Result { + Ok(Cow::Owned(value)) + } +} + +pub(crate) struct KeySeed; + +impl<'de> DeserializeSeed<'de> for KeySeed { + type Value = Cow<'de, str>; + + fn deserialize>(self, decoder: D) -> Result { + decoder.deserialize_str(KeyVisitor) + } +} + +pub(crate) trait Update<'de> { + fn update>(&mut self, decoder: D) -> Result<(), D::Error>; +} + +macro_rules! scalar { + ($($type:ty),*) => {$( + impl<'de> Update<'de> for $type { + fn update>(&mut self, decoder: D) -> Result<(), D::Error> { + // A later null does not overwrite an already decoded scalar. + if let Some(value) = Option::::deserialize(decoder)? { *self = value; } + Ok(()) + } + } + )*}; +} +scalar!(String, bool, i64, isize, f64); + +impl<'de, T: Deserialize<'de>> Update<'de> for Option { + fn update>(&mut self, decoder: D) -> Result<(), D::Error> { + *self = Self::deserialize(decoder)?; + Ok(()) + } +} + +pub(crate) struct Field<'a, T>(pub &'a mut T); + +impl<'de, T: Update<'de>> DeserializeSeed<'de> for Field<'_, T> { + type Value = (); + fn deserialize>(self, decoder: D) -> Result<(), D::Error> { + self.0.update(decoder) + } +} + +// The `expecting` text predates this module's move out of the lyrics code and +// is kept so decode error messages stay byte-identical. +macro_rules! lenient_deserialize { + ($name:ident { $($key:literal => $field:ident,)* }) => { + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize>(decoder: D) -> Result { + let mut result = Self::default(); + $crate::json::Update::update(&mut result, decoder)?; + Ok(result) + } + } + impl<'de> $crate::json::Update<'de> for $name { + fn update>(&mut self, decoder: D) -> Result<(), D::Error> { + struct Visitor<'a>(&'a mut $name); + impl<'de> serde::de::Visitor<'de> for Visitor<'_> { + type Value = (); + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a lyrics object or null") + } + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } + fn visit_map>(self, mut map: M) -> Result<(), M::Error> { + while let Some(key) = map.next_key_seed($crate::json::KeySeed)? { + let raw_key = key.as_ref(); + let folded; + let lookup = match raw_key { + $($key => raw_key,)* + _ => { + folded = $crate::json::field_name(raw_key); + folded.as_str() + } + }; + match lookup { + $($key => map.next_value_seed($crate::json::Field(&mut self.0.$field))?,)* + _ => { map.next_value::()?; } + } + } + Ok(()) + } + } + decoder.deserialize_any(Visitor(self)) + } + } + }; +} +pub(crate) use lenient_deserialize; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_seed_borrows_canonical_keys_and_owns_escaped_keys() { + let mut decoder = serde_json::Deserializer::from_str(r#""source""#); + assert!(matches!( + KeySeed.deserialize(&mut decoder).unwrap(), + Cow::Borrowed("source") + )); + + // `"so` + backslash + `u0075rce"`: the key spells `u` as a JSON escape. + let escaped = concat!(r#""so\"#, r#"u0075rce""#); + let mut decoder = serde_json::Deserializer::from_str(escaped); + assert!(matches!( + KeySeed.deserialize(&mut decoder).unwrap(), + Cow::Owned(value) if value == "source" + )); + } + + #[test] + fn field_name_folds_go_special_letters() { + assert_eq!(field_name("ſOURCE"), "source"); + assert_eq!(field_name("\u{212A}"), "k"); + assert_eq!(field_name("K"), "k"); + } +} diff --git a/rust_backend/crates/core/src/lib.rs b/rust_backend/crates/core/src/lib.rs index 569def68..f00f1fd0 100644 --- a/rust_backend/crates/core/src/lib.rs +++ b/rust_backend/crates/core/src/lib.rs @@ -12,6 +12,7 @@ pub mod cue; pub mod downloads; pub mod filename; pub mod isrc; +pub mod json; pub mod lyrics; pub mod matching; pub mod media; diff --git a/rust_backend/crates/core/src/lyrics.rs b/rust_backend/crates/core/src/lyrics.rs index e896a6d0..f03bf1a8 100644 --- a/rust_backend/crates/core/src/lyrics.rs +++ b/rust_backend/crates/core/src/lyrics.rs @@ -10,6 +10,7 @@ pub mod matching; pub mod models; pub mod payloads; +use crate::json::lenient_deserialize; use serde::{Deserialize, Serialize}; /// HTTP providers use Go's streaming JSON decoder: decode the first value, @@ -49,7 +50,7 @@ pub struct LyricsWord { pub end_time_ms: i64, } -json::go_deserialize!(LyricsWord { +lenient_deserialize!(LyricsWord { "text" => text, "starttimems" => start_time_ms, "endtimems" => end_time_ms, @@ -69,7 +70,7 @@ pub struct LyricsLine { pub translation: Option, } -json::go_deserialize!(LyricsLine { +lenient_deserialize!(LyricsLine { "starttimems" => start_time_ms, "words" => words, "endtimems" => end_time_ms, @@ -90,7 +91,7 @@ pub struct LyricsResponse { pub source: String, } -json::go_deserialize!(LyricsResponse { +lenient_deserialize!(LyricsResponse { "lines" => lines, "synctype" => sync_type, "instrumental" => instrumental, diff --git a/rust_backend/crates/core/src/lyrics/config.rs b/rust_backend/crates/core/src/lyrics/config.rs index 1b3919fe..a84be7cf 100644 --- a/rust_backend/crates/core/src/lyrics/config.rs +++ b/rust_backend/crates/core/src/lyrics/config.rs @@ -36,7 +36,7 @@ pub struct FetchOptions { pub musixmatch_language: String, } -super::json::go_deserialize!(FetchOptions { +crate::json::lenient_deserialize!(FetchOptions { "include_translation_netease" => include_translation_netease, "include_romanization_netease" => include_romanization_netease, "multi_person_word_by_word" => multi_person_word_by_word, @@ -60,7 +60,7 @@ impl FetchOptions { /// Go unmarshals partial settings into the current options. Commit only a /// fully valid document, including duplicate fields and null scalar values. pub fn update_json(&mut self, raw: &str) -> Result<(), serde_json::Error> { - use super::json::Update; + use crate::json::Update; if raw.trim().is_empty() { return Ok(()); } diff --git a/rust_backend/crates/core/src/lyrics/json.rs b/rust_backend/crates/core/src/lyrics/json.rs index 220f467d..c9c10b67 100644 --- a/rust_backend/crates/core/src/lyrics/json.rs +++ b/rust_backend/crates/core/src/lyrics/json.rs @@ -1,158 +1,16 @@ //! Go's typed provider JSON accepts case-insensitive fields and null scalars. +//! The lenient struct decoder lives in [`crate::json`]. -use serde::de::{DeserializeOwned, DeserializeSeed, Visitor}; -use serde::{Deserialize, Deserializer}; -use std::borrow::Cow; +use serde::de::DeserializeOwned; pub(super) fn decode(raw: &str) -> Result { serde_json::from_str(&crate::text::json_surrogates(raw)) } -pub(crate) fn field_name(name: &str) -> String { - name.chars() - .map(|ch| match ch { - 'ſ' => 's', - 'K' => 'k', - _ => ch.to_ascii_lowercase(), - }) - .collect() -} - -struct KeyVisitor; - -impl<'de> Visitor<'de> for KeyVisitor { - type Value = Cow<'de, str>; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a string key") - } - - fn visit_borrowed_str(self, value: &'de str) -> Result { - Ok(Cow::Borrowed(value)) - } - - fn visit_str(self, value: &str) -> Result { - Ok(Cow::Owned(value.into())) - } - - fn visit_string(self, value: String) -> Result { - Ok(Cow::Owned(value)) - } -} - -pub(crate) struct KeySeed; - -impl<'de> DeserializeSeed<'de> for KeySeed { - type Value = Cow<'de, str>; - - fn deserialize>(self, decoder: D) -> Result { - decoder.deserialize_str(KeyVisitor) - } -} - -pub(crate) trait Update<'de> { - fn update>(&mut self, decoder: D) -> Result<(), D::Error>; -} - -macro_rules! scalar { - ($($type:ty),*) => {$( - impl<'de> Update<'de> for $type { - fn update>(&mut self, decoder: D) -> Result<(), D::Error> { - // A later null does not overwrite an already decoded scalar. - if let Some(value) = Option::::deserialize(decoder)? { *self = value; } - Ok(()) - } - } - )*}; -} -scalar!(String, bool, i64, isize, f64); - -impl<'de, T: Deserialize<'de>> Update<'de> for Option { - fn update>(&mut self, decoder: D) -> Result<(), D::Error> { - *self = Self::deserialize(decoder)?; - Ok(()) - } -} - -pub(crate) struct Field<'a, T>(pub &'a mut T); - -impl<'de, T: Update<'de>> DeserializeSeed<'de> for Field<'_, T> { - type Value = (); - fn deserialize>(self, decoder: D) -> Result<(), D::Error> { - self.0.update(decoder) - } -} - -macro_rules! go_deserialize { - ($name:ident { $($key:literal => $field:ident,)* }) => { - impl<'de> serde::Deserialize<'de> for $name { - fn deserialize>(decoder: D) -> Result { - let mut result = Self::default(); - $crate::lyrics::json::Update::update(&mut result, decoder)?; - Ok(result) - } - } - impl<'de> $crate::lyrics::json::Update<'de> for $name { - fn update>(&mut self, decoder: D) -> Result<(), D::Error> { - struct Visitor<'a>(&'a mut $name); - impl<'de> serde::de::Visitor<'de> for Visitor<'_> { - type Value = (); - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a lyrics object or null") - } - fn visit_unit(self) -> Result<(), E> { - Ok(()) - } - fn visit_map>(self, mut map: M) -> Result<(), M::Error> { - while let Some(key) = map.next_key_seed($crate::lyrics::json::KeySeed)? { - let raw_key = key.as_ref(); - let folded; - let lookup = match raw_key { - $($key => raw_key,)* - _ => { - folded = $crate::lyrics::json::field_name(raw_key); - folded.as_str() - } - }; - match lookup { - $($key => map.next_value_seed($crate::lyrics::json::Field(&mut self.0.$field))?,)* - _ => { map.next_value::()?; } - } - } - Ok(()) - } - } - decoder.deserialize_any(Visitor(self)) - } - } - }; -} -pub(crate) use go_deserialize; - #[cfg(test)] mod tests { - use super::*; - - #[test] - fn key_seed_borrows_canonical_keys_and_owns_escaped_keys() { - let mut decoder = serde_json::Deserializer::from_str(r#""source""#); - assert!(matches!( - KeySeed.deserialize(&mut decoder).unwrap(), - Cow::Borrowed("source") - )); - - let mut decoder = serde_json::Deserializer::from_str(r#""so\u0075rce""#); - assert!(matches!( - KeySeed.deserialize(&mut decoder).unwrap(), - Cow::Owned(value) if value == "source" - )); - } - #[test] fn field_folding_preserves_special_letters_and_null_duplicates() { - assert_eq!(field_name("ſOURCE"), "source"); - assert_eq!(field_name("K"), "k"); - let response: crate::lyrics::LyricsResponse = serde_json::from_str( r#"{"plainlyrics":"kept","PLAINLYRICS":null,"ſource":"old","SOURCE":"new"}"#, ) diff --git a/rust_backend/crates/core/src/lyrics/lrclib.rs b/rust_backend/crates/core/src/lyrics/lrclib.rs index caa9f545..11329156 100644 --- a/rust_backend/crates/core/src/lyrics/lrclib.rs +++ b/rust_backend/crates/core/src/lyrics/lrclib.rs @@ -15,7 +15,7 @@ pub struct LrcLibResponse { pub synced_lyrics: String, } -super::json::go_deserialize!(LrcLibResponse { +crate::json::lenient_deserialize!(LrcLibResponse { "id" => id, "name" => name, "trackname" => track_name, "artistname" => artist_name, "albumname" => album_name, "duration" => duration, "instrumental" => instrumental, "plainlyrics" => plain_lyrics, diff --git a/rust_backend/crates/core/src/lyrics/models.rs b/rust_backend/crates/core/src/lyrics/models.rs index bc8aa535..bd20d575 100644 --- a/rust_backend/crates/core/src/lyrics/models.rs +++ b/rust_backend/crates/core/src/lyrics/models.rs @@ -1,13 +1,13 @@ //! Typed built-in search schemas, retaining Go field folding and null behavior. -use super::json::go_deserialize; +use crate::json::lenient_deserialize; use std::collections::BTreeMap; #[derive(Clone, Debug, Default)] pub struct Artist { pub name: String, } -go_deserialize!(Artist { "name" => name, }); +lenient_deserialize!(Artist { "name" => name, }); #[derive(Clone, Debug, Default)] pub struct SpotifySong { @@ -16,7 +16,7 @@ pub struct SpotifySong { pub artist_name: String, pub duration: String, } -go_deserialize!(SpotifySong { "trackid" => track_id, "name" => name, "artistname" => artist_name, "duration" => duration, }); +lenient_deserialize!(SpotifySong { "trackid" => track_id, "name" => name, "artistname" => artist_name, "duration" => duration, }); #[derive(Clone, Debug, Default)] pub struct YouTubeSong { @@ -25,7 +25,7 @@ pub struct YouTubeSong { pub author: String, pub duration: String, } -go_deserialize!(YouTubeSong { "videoid" => video_id, "title" => title, "author" => author, "duration" => duration, }); +lenient_deserialize!(YouTubeSong { "videoid" => video_id, "title" => title, "author" => author, "duration" => duration, }); #[derive(Clone, Debug, Default)] pub struct NeteaseSong { @@ -33,13 +33,13 @@ pub struct NeteaseSong { pub id: i64, pub artists: Option>, } -go_deserialize!(NeteaseSong { "name" => name, "id" => id, "artists" => artists, }); +lenient_deserialize!(NeteaseSong { "name" => name, "id" => id, "artists" => artists, }); #[derive(Clone, Debug, Default)] pub struct NeteaseResults { pub songs: Option>, pub song_count: isize, } -go_deserialize!(NeteaseResults { "songs" => songs, "songcount" => song_count, }); +lenient_deserialize!(NeteaseResults { "songs" => songs, "songcount" => song_count, }); #[derive(Clone, Debug, Default)] pub struct NeteaseSearch { pub result: NeteaseResults, @@ -47,12 +47,12 @@ pub struct NeteaseSearch { pub message: String, pub msg: String, } -go_deserialize!(NeteaseSearch { "result" => result, "code" => code, "message" => message, "msg" => msg, }); +lenient_deserialize!(NeteaseSearch { "result" => result, "code" => code, "message" => message, "msg" => msg, }); #[derive(Clone, Debug, Default)] pub struct LyricField { pub lyric: String, } -go_deserialize!(LyricField { "lyric" => lyric, }); +lenient_deserialize!(LyricField { "lyric" => lyric, }); #[derive(Clone, Debug, Default)] pub struct NeteaseLyrics { pub lrc: Option, @@ -60,23 +60,23 @@ pub struct NeteaseLyrics { pub romalrc: Option, pub code: isize, } -go_deserialize!(NeteaseLyrics { "lrc" => lrc, "tlyric" => tlyric, "romalrc" => romalrc, "code" => code, }); +lenient_deserialize!(NeteaseLyrics { "lrc" => lrc, "tlyric" => tlyric, "romalrc" => romalrc, "code" => code, }); #[derive(Clone, Debug, Default)] pub struct AppleSongId { pub id: String, } -go_deserialize!(AppleSongId { "id" => id, }); +lenient_deserialize!(AppleSongId { "id" => id, }); #[derive(Clone, Debug, Default)] pub struct AppleSongs { pub data: Option>, } -go_deserialize!(AppleSongs { "data" => data, }); +lenient_deserialize!(AppleSongs { "data" => data, }); #[derive(Clone, Debug, Default)] pub struct AppleResults { pub songs: Option, } -go_deserialize!(AppleResults { "songs" => songs, }); +lenient_deserialize!(AppleResults { "songs" => songs, }); #[derive(Clone, Debug, Default)] pub struct AppleAttributes { pub name: String, @@ -84,23 +84,23 @@ pub struct AppleAttributes { pub album_name: String, pub duration_in_millis: isize, } -go_deserialize!(AppleAttributes { "name" => name, "artistname" => artist_name, "albumname" => album_name, "durationinmillis" => duration_in_millis, }); +lenient_deserialize!(AppleAttributes { "name" => name, "artistname" => artist_name, "albumname" => album_name, "durationinmillis" => duration_in_millis, }); #[derive(Clone, Debug, Default)] pub struct AppleSong { pub attributes: AppleAttributes, } -go_deserialize!(AppleSong { "attributes" => attributes, }); +lenient_deserialize!(AppleSong { "attributes" => attributes, }); #[derive(Clone, Debug, Default)] pub struct AppleResources { pub songs: Option>, } -go_deserialize!(AppleResources { "songs" => songs, }); +lenient_deserialize!(AppleResources { "songs" => songs, }); #[derive(Clone, Debug, Default)] pub struct AppleSearch { pub results: AppleResults, pub resources: Option, } -go_deserialize!(AppleSearch { "results" => results, "resources" => resources, }); +lenient_deserialize!(AppleSearch { "results" => results, "resources" => resources, }); #[derive(Clone, Debug, Default)] pub struct QqSong { @@ -110,13 +110,13 @@ pub struct QqSong { pub interval: isize, pub singer: Option>, } -go_deserialize!(QqSong { "mid" => mid, "id" => id, "name" => name, "interval" => interval, "singer" => singer, }); +lenient_deserialize!(QqSong { "mid" => mid, "id" => id, "name" => name, "interval" => interval, "singer" => singer, }); #[derive(Clone, Debug, Default)] pub struct QqSongDetail { pub code: isize, pub data: Option>, } -go_deserialize!(QqSongDetail { "code" => code, "data" => data, }); +lenient_deserialize!(QqSongDetail { "code" => code, "data" => data, }); /// Search suggestions carry IDs as strings and singers joined by `/`. #[derive(Clone, Debug, Default)] pub struct QqSuggestion { @@ -125,30 +125,30 @@ pub struct QqSuggestion { pub name: String, pub singer: String, } -go_deserialize!(QqSuggestion { "id" => id, "mid" => mid, "name" => name, "singer" => singer, }); +lenient_deserialize!(QqSuggestion { "id" => id, "mid" => mid, "name" => name, "singer" => singer, }); #[derive(Clone, Debug, Default)] pub struct QqSuggestedSongs { pub itemlist: Option>, } -go_deserialize!(QqSuggestedSongs { "itemlist" => itemlist, }); +lenient_deserialize!(QqSuggestedSongs { "itemlist" => itemlist, }); #[derive(Clone, Debug, Default)] pub struct QqSuggestionData { pub song: QqSuggestedSongs, } -go_deserialize!(QqSuggestionData { "song" => song, }); +lenient_deserialize!(QqSuggestionData { "song" => song, }); #[derive(Clone, Debug, Default)] pub struct QqSearch { pub code: isize, pub data: QqSuggestionData, } -go_deserialize!(QqSearch { "code" => code, "data" => data, }); +lenient_deserialize!(QqSearch { "code" => code, "data" => data, }); #[derive(Clone, Debug, Default)] pub struct QqLyrics { pub retcode: isize, pub code: isize, pub lyric: String, } -go_deserialize!(QqLyrics { "retcode" => retcode, "code" => code, "lyric" => lyric, }); +lenient_deserialize!(QqLyrics { "retcode" => retcode, "code" => code, "lyric" => lyric, }); #[derive(Clone, Debug, Default)] pub struct KugouSong { @@ -158,7 +158,7 @@ pub struct KugouSong { pub singer: String, pub duration: f64, } -go_deserialize!(KugouSong { "id" => id, "accesskey" => accesskey, "song" => song, "singer" => singer, "duration" => duration, }); +lenient_deserialize!(KugouSong { "id" => id, "accesskey" => accesskey, "song" => song, "singer" => singer, "duration" => duration, }); #[derive(Clone, Debug, Default)] pub struct KugouSearch { pub status: isize, @@ -166,7 +166,7 @@ pub struct KugouSearch { pub errmsg: String, pub candidates: Option>, } -go_deserialize!(KugouSearch { "status" => status, "errcode" => errcode, "errmsg" => errmsg, "candidates" => candidates, }); +lenient_deserialize!(KugouSearch { "status" => status, "errcode" => errcode, "errmsg" => errmsg, "candidates" => candidates, }); #[derive(Clone, Debug, Default)] pub struct KugouLyrics { pub status: isize, @@ -174,7 +174,7 @@ pub struct KugouLyrics { pub info: String, pub content: String, } -go_deserialize!(KugouLyrics { "status" => status, "error_code" => error_code, "info" => info, "content" => content, }); +lenient_deserialize!(KugouLyrics { "status" => status, "error_code" => error_code, "info" => info, "content" => content, }); #[derive(Clone, Debug, Default)] pub struct GeniusSong { @@ -183,25 +183,25 @@ pub struct GeniusSong { pub primary_artist_names: String, pub url: String, } -go_deserialize!(GeniusSong { "title" => title, "artist_names" => artist_names, "primary_artist_names" => primary_artist_names, "url" => url, }); +lenient_deserialize!(GeniusSong { "title" => title, "artist_names" => artist_names, "primary_artist_names" => primary_artist_names, "url" => url, }); #[derive(Clone, Debug, Default)] pub struct GeniusHit { pub kind: String, pub result: GeniusSong, } -go_deserialize!(GeniusHit { "type" => kind, "result" => result, }); +lenient_deserialize!(GeniusHit { "type" => kind, "result" => result, }); #[derive(Clone, Debug, Default)] pub struct GeniusSection { pub hits: Option>, } -go_deserialize!(GeniusSection { "hits" => hits, }); +lenient_deserialize!(GeniusSection { "hits" => hits, }); #[derive(Clone, Debug, Default)] pub struct GeniusResults { pub sections: Option>, } -go_deserialize!(GeniusResults { "sections" => sections, }); +lenient_deserialize!(GeniusResults { "sections" => sections, }); #[derive(Clone, Debug, Default)] pub struct GeniusSearch { pub response: GeniusResults, } -go_deserialize!(GeniusSearch { "response" => response, }); +lenient_deserialize!(GeniusSearch { "response" => response, }); diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index a5bdf592..67a20907 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -1,4 +1,5 @@ use super::{LyricsResponse, LyricsWord, json, lrc}; +use crate::json::{field_name, lenient_deserialize}; use regex::Regex; use serde::Serialize; use serde_json::value::RawValue; @@ -15,7 +16,7 @@ pub struct PaxDetail { pub timestamp: Option, pub endtime: Option, } -json::go_deserialize!(PaxDetail { +lenient_deserialize!(PaxDetail { "text" => text, "part" => part, "timestamp" => timestamp, "endtime" => endtime, }); @@ -33,7 +34,7 @@ pub struct PaxLine { #[serde(skip_serializing_if = "String::is_empty")] pub agent: String, } -json::go_deserialize!(PaxLine { +lenient_deserialize!(PaxLine { "text" => text, "timestamp" => timestamp, "oppositeturn" => opposite_turn, "background" => background, "backgroundtext" => background_text, "endtime" => endtime, "backgroundlines" => background_lines, @@ -45,13 +46,13 @@ struct AppleAgent { id: String, kind: String, } -json::go_deserialize!(AppleAgent { "id" => id, "type" => kind, }); +lenient_deserialize!(AppleAgent { "id" => id, "type" => kind, }); #[derive(Default)] struct AppleMetadata { agents: Option>, } -json::go_deserialize!(AppleMetadata { "agents" => agents, }); +lenient_deserialize!(AppleMetadata { "agents" => agents, }); #[derive(Default)] struct ApplePayload { @@ -63,7 +64,7 @@ struct ApplePayload { ttml_content: String, metadata: Option, } -json::go_deserialize!(ApplePayload { +lenient_deserialize!(ApplePayload { "type" => kind, "content" => content, "elrc" => elrc, "elrcmultiperson" => elrc_multi_person, "plain" => plain, "ttmlcontent" => ttml_content, "metadata" => metadata, @@ -168,7 +169,7 @@ struct ProxyPayload { lyrics_text: String, plain_lyrics: String, } -json::go_deserialize!(ProxyPayload { +lenient_deserialize!(ProxyPayload { "type" => kind, "content" => content, "lyrics" => lyrics, "lyrics_text" => lyrics_text, "plain_lyrics" => plain_lyrics, }); @@ -204,7 +205,7 @@ pub fn format_pax_content( if index > 0 { output.push('\n'); } - if json::field_name(kind) == "syllable" { + if field_name(kind) == "syllable" { output.push_str(&lrc::timestamp(line.timestamp as i64)); if multi_person { output.push_str(if line.opposite_turn { "v2:" } else { "v1:" }); @@ -259,7 +260,7 @@ fn format_apple_payload( multi_person: bool, word_timing: bool, ) -> Result { - let kind = json::field_name(&payload.kind); + let kind = field_name(&payload.kind); if kind == "unsynced" && !payload.plain.trim().is_empty() { return Ok(payload.plain.trim().into()); } @@ -380,7 +381,7 @@ pub fn apple_supplements(raw: &str, lyrics: &mut LyricsResponse) { struct Payload { metadata: Option>, } - json::go_deserialize!(Payload { "metadata" => metadata, }); + lenient_deserialize!(Payload { "metadata" => metadata, }); let metadata = json::decode::(raw) .ok() @@ -589,7 +590,7 @@ pub struct KpoeSyllable { pub duration: f64, pub is_background: bool, } -json::go_deserialize!(KpoeSyllable { +lenient_deserialize!(KpoeSyllable { "text" => text, "time" => time, "duration" => duration, "isbackground" => is_background, }); @@ -600,7 +601,7 @@ pub struct KpoeLine { pub text: String, pub syllabus: Option>, } -json::go_deserialize!(KpoeLine { +lenient_deserialize!(KpoeLine { "time" => time, "duration" => duration, "text" => text, "syllabus" => syllabus, }); @@ -610,13 +611,10 @@ pub struct KpoeResponse { pub kind: String, pub lyrics: Option>, } -json::go_deserialize!(KpoeResponse { "type" => kind, "lyrics" => lyrics, }); +lenient_deserialize!(KpoeResponse { "type" => kind, "lyrics" => lyrics, }); pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: bool) -> String { - let words = matches!( - json::field_name(&response.kind).as_str(), - "word" | "syllable" - ); + let words = matches!(field_name(&response.kind).as_str(), "word" | "syllable"); let mut lines = Vec::new(); let append = |details: &[&KpoeSyllable]| { let mut result = String::new(); diff --git a/rust_backend/crates/core/src/metadata/deezer.rs b/rust_backend/crates/core/src/metadata/deezer.rs index cdd44f69..58d82777 100644 --- a/rust_backend/crates/core/src/metadata/deezer.rs +++ b/rust_backend/crates/core/src/metadata/deezer.rs @@ -1,5 +1,5 @@ use super::TrackMetadata; -use crate::lyrics::json::go_deserialize; +use crate::json::lenient_deserialize; mod catalog; pub use catalog::*; @@ -20,7 +20,7 @@ pub struct Track { pub album: Album, pub contributors: Option>, } -go_deserialize!(Track { +lenient_deserialize!(Track { "id" => id, "title" => title, "duration" => duration, "track_position" => track_position, "disk_number" => disk_number, "isrc" => isrc, "link" => link, "release_date" => release_date, "explicit_lyrics" => explicit_lyrics, "explicit_content_lyrics" => explicit_content_lyrics, @@ -37,7 +37,7 @@ pub struct Artist { pub picture_xl: String, pub nb_fan: isize, } -go_deserialize!(Artist { +lenient_deserialize!(Artist { "id" => id, "name" => name, "picture" => picture, "picture_medium" => picture_medium, "picture_big" => picture_big, "picture_xl" => picture_xl, "nb_fan" => nb_fan, }); @@ -53,7 +53,7 @@ pub struct Album { pub release_date: String, pub record_type: String, } -go_deserialize!(Album { +lenient_deserialize!(Album { "id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium, "cover_big" => cover_big, "cover_xl" => cover_xl, "release_date" => release_date, "record_type" => record_type, }); @@ -62,7 +62,7 @@ go_deserialize!(Album { pub struct Tracks { pub data: Option>, } -go_deserialize!(Tracks { "data" => data, }); +lenient_deserialize!(Tracks { "data" => data, }); impl Track { pub fn artist_display(&self) -> String { diff --git a/rust_backend/crates/core/src/metadata/deezer/catalog.rs b/rust_backend/crates/core/src/metadata/deezer/catalog.rs index 6213986e..a0e228e3 100644 --- a/rust_backend/crates/core/src/metadata/deezer/catalog.rs +++ b/rust_backend/crates/core/src/metadata/deezer/catalog.rs @@ -1,25 +1,25 @@ use super::{Artist, Track, Tracks, best_image}; -use crate::lyrics::json::go_deserialize; +use crate::json::lenient_deserialize; #[derive(Clone, Debug, Default)] pub struct TrackPage { pub data: Option>, pub next: String, } -go_deserialize!(TrackPage { "data" => data, "next" => next, }); +lenient_deserialize!(TrackPage { "data" => data, "next" => next, }); #[derive(Clone, Debug, Default)] pub struct Genre { pub id: isize, pub name: String, } -go_deserialize!(Genre { "id" => id, "name" => name, }); +lenient_deserialize!(Genre { "id" => id, "name" => name, }); #[derive(Clone, Debug, Default)] pub struct Genres { pub data: Option>, } -go_deserialize!(Genres { "data" => data, }); +lenient_deserialize!(Genres { "data" => data, }); impl Genres { pub fn display(&self) -> String { @@ -51,7 +51,7 @@ pub struct FullAlbum { pub contributors: Option>, pub tracks: Tracks, } -go_deserialize!(FullAlbum { +lenient_deserialize!(FullAlbum { "id" => id, "title" => title, "cover" => cover, "cover_medium" => cover_medium, "cover_big" => cover_big, "cover_xl" => cover_xl, "release_date" => release_date, "nb_tracks" => nb_tracks, "record_type" => record_type, "label" => label, @@ -70,7 +70,7 @@ pub struct FullArtist { pub nb_fan: isize, pub nb_album: isize, } -go_deserialize!(FullArtist { +lenient_deserialize!(FullArtist { "id" => id, "name" => name, "picture" => picture, "picture_medium" => picture_medium, "picture_big" => picture_big, "picture_xl" => picture_xl, "nb_fan" => nb_fan, "nb_album" => nb_album, }); @@ -79,7 +79,7 @@ go_deserialize!(FullArtist { pub struct Name { pub name: String, } -go_deserialize!(Name { "name" => name, }); +lenient_deserialize!(Name { "name" => name, }); #[derive(Clone, Debug, Default)] pub struct FullPlaylist { @@ -93,7 +93,7 @@ pub struct FullPlaylist { pub creator: Name, pub tracks: Tracks, } -go_deserialize!(FullPlaylist { +lenient_deserialize!(FullPlaylist { "id" => id, "title" => title, "picture" => picture, "picture_medium" => picture_medium, "picture_big" => picture_big, "picture_xl" => picture_xl, "nb_tracks" => nb_tracks, "creator" => creator, "tracks" => tracks, @@ -111,7 +111,7 @@ pub struct ArtistAlbum { pub cover_xl: String, pub record_type: String, } -go_deserialize!(ArtistAlbum { +lenient_deserialize!(ArtistAlbum { "id" => id, "title" => title, "release_date" => release_date, "nb_tracks" => nb_tracks, "cover" => cover, "cover_medium" => cover_medium, "cover_big" => cover_big, "cover_xl" => cover_xl, "record_type" => record_type, @@ -121,13 +121,13 @@ go_deserialize!(ArtistAlbum { pub struct ArtistAlbums { pub data: Option>, } -go_deserialize!(ArtistAlbums { "data" => data, }); +lenient_deserialize!(ArtistAlbums { "data" => data, }); #[derive(Clone, Debug, Default)] pub struct AlbumTrackCount { pub nb_tracks: isize, } -go_deserialize!(AlbumTrackCount { "nb_tracks" => nb_tracks, }); +lenient_deserialize!(AlbumTrackCount { "nb_tracks" => nb_tracks, }); macro_rules! cover_image { ($($name:ty),*) => {$( diff --git a/rust_backend/crates/core/src/metadata/musicbrainz.rs b/rust_backend/crates/core/src/metadata/musicbrainz.rs index 1c2c0ce9..d2177a5a 100644 --- a/rust_backend/crates/core/src/metadata/musicbrainz.rs +++ b/rust_backend/crates/core/src/metadata/musicbrainz.rs @@ -3,7 +3,7 @@ mod case_data; mod casing; -use crate::lyrics::json::go_deserialize; +use crate::json::lenient_deserialize; use crate::matching::lowercase; use std::collections::BTreeSet; @@ -12,34 +12,34 @@ pub struct Tag { pub count: isize, pub name: String, } -go_deserialize!(Tag { "count" => count, "name" => name, }); +lenient_deserialize!(Tag { "count" => count, "name" => name, }); #[derive(Clone, Debug, Default)] pub struct ArtistCredit { pub name: String, pub joinphrase: String, } -go_deserialize!(ArtistCredit { "name" => name, "joinphrase" => joinphrase, }); +lenient_deserialize!(ArtistCredit { "name" => name, "joinphrase" => joinphrase, }); #[derive(Clone, Debug, Default)] pub struct Release { pub title: String, pub artist_credit: Option>, } -go_deserialize!(Release { "title" => title, "artist-credit" => artist_credit, }); +lenient_deserialize!(Release { "title" => title, "artist-credit" => artist_credit, }); #[derive(Clone, Debug, Default)] pub struct Recording { pub tags: Option>, pub releases: Option>, } -go_deserialize!(Recording { "tags" => tags, "releases" => releases, }); +lenient_deserialize!(Recording { "tags" => tags, "releases" => releases, }); #[derive(Clone, Debug, Default)] pub struct Response { pub recordings: Option>, } -go_deserialize!(Response { "recordings" => recordings, }); +lenient_deserialize!(Response { "recordings" => recordings, }); pub fn genre(tags: &[Tag]) -> String { let mut seen = BTreeSet::new(); diff --git a/rust_backend/crates/core/src/metadata/reenrich.rs b/rust_backend/crates/core/src/metadata/reenrich.rs index 0ca649e1..1556e4f6 100644 --- a/rust_backend/crates/core/src/metadata/reenrich.rs +++ b/rust_backend/crates/core/src/metadata/reenrich.rs @@ -1,6 +1,6 @@ //! Re-enrichment input, candidate policy and preview metadata. No file access. -use crate::lyrics::json::go_deserialize; +use crate::json::lenient_deserialize; use crate::matching::lowercase; use crate::resolver::{artists_match, titles_match, track_identity_title, track_titles_match}; use serde::Serialize; @@ -36,7 +36,7 @@ pub struct Request { pub preview_only: bool, pub replace_release_metadata: bool, } -go_deserialize!(Request { +lenient_deserialize!(Request { "file_path" => file_path, "cover_url" => cover_url, "cover_max_dimension" => cover_max_dimension, "embed_lyrics" => embed_lyrics, "lyrics_mode" => lyrics_mode, "artist_tag_mode" => artist_tag_mode, diff --git a/rust_backend/crates/core/src/metadata/share.rs b/rust_backend/crates/core/src/metadata/share.rs index f69c190a..6c34623d 100644 --- a/rust_backend/crates/core/src/metadata/share.rs +++ b/rust_backend/crates/core/src/metadata/share.rs @@ -1,6 +1,6 @@ //! Collection matching and share links from already-decoded extension tracks. -use crate::lyrics::json::go_deserialize; +use crate::json::lenient_deserialize; use crate::lyrics::matching::{normalize_loose_artist, normalize_title}; use crate::matching::lowercase; use serde::Serialize; @@ -13,7 +13,7 @@ pub struct Request { pub kind: String, pub source_extension_id: String, } -go_deserialize!(Request { +lenient_deserialize!(Request { "name" => name, "artists" => artists, "type" => kind, "source_extension_id" => source_extension_id, }); diff --git a/rust_backend/crates/core/src/resolver.rs b/rust_backend/crates/core/src/resolver.rs index 6ff3c5bd..9b0830fd 100644 --- a/rust_backend/crates/core/src/resolver.rs +++ b/rust_backend/crates/core/src/resolver.rs @@ -1,6 +1,6 @@ //! Typed platform-resolver payloads and metadata matching shared with Go. -use crate::lyrics::json::{Update, go_deserialize}; +use crate::json::{Update, lenient_deserialize}; use crate::lyrics::matching::normalize_loose_artist; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; @@ -36,14 +36,14 @@ impl<'de, T: Deserialize<'de>> Update<'de> for Map { pub struct Link { pub url: String, } -go_deserialize!(Link { "url" => url, }); +lenient_deserialize!(Link { "url" => url, }); #[derive(Clone, Debug, Default)] pub struct Entity { pub title: String, pub artist: String, } -go_deserialize!(Entity { "title" => title, "artistname" => artist, }); +lenient_deserialize!(Entity { "title" => title, "artistname" => artist, }); #[derive(Clone, Debug, Default)] pub struct UniTune { @@ -51,7 +51,7 @@ pub struct UniTune { pub links: Map, pub entities: Map, } -go_deserialize!(UniTune { +lenient_deserialize!(UniTune { "entityuniqueid" => entity_id, "linksbyplatform" => links, "entitiesbyuniqueid" => entities, @@ -61,7 +61,7 @@ go_deserialize!(UniTune { pub struct Artist { pub name: String, } -go_deserialize!(Artist { "name" => name, }); +lenient_deserialize!(Artist { "name" => name, }); #[derive(Clone, Debug, Default)] pub struct Recording { @@ -70,7 +70,7 @@ pub struct Recording { pub title: String, pub artists: Option>, } -go_deserialize!(Recording { +lenient_deserialize!(Recording { "id" => id, "score" => score, "title" => title, "artist-credit" => artists, }); @@ -78,25 +78,25 @@ go_deserialize!(Recording { pub struct Recordings { pub recordings: Option>, } -go_deserialize!(Recordings { "recordings" => recordings, }); +lenient_deserialize!(Recordings { "recordings" => recordings, }); #[derive(Clone, Debug, Default)] pub struct Resource { pub resource: String, } -go_deserialize!(Resource { "resource" => resource, }); +lenient_deserialize!(Resource { "resource" => resource, }); #[derive(Clone, Debug, Default)] pub struct Relation { pub url: Resource, } -go_deserialize!(Relation { "url" => url, }); +lenient_deserialize!(Relation { "url" => url, }); #[derive(Clone, Debug, Default)] pub struct Relations { pub relations: Option>, } -go_deserialize!(Relations { "relations" => relations, }); +lenient_deserialize!(Relations { "relations" => relations, }); #[derive(Clone, Debug, Default)] pub struct Created { @@ -104,7 +104,7 @@ pub struct Created { pub title: String, pub artist: String, } -go_deserialize!(Created { "full_url" => full_url, "title" => title, "artist" => artist, }); +lenient_deserialize!(Created { "full_url" => full_url, "title" => title, "artist" => artist, }); #[derive(Clone, Debug, Default)] pub struct PageData { @@ -112,13 +112,13 @@ pub struct PageData { pub artist: String, pub services: Map>, } -go_deserialize!(PageData { "title" => title, "artist" => artist, "services" => services, }); +lenient_deserialize!(PageData { "title" => title, "artist" => artist, "services" => services, }); #[derive(Clone, Debug, Default)] pub struct Page { pub data: PageData, } -go_deserialize!(Page { "data" => data, }); +lenient_deserialize!(Page { "data" => data, }); /// The platform resolver uses Go's broader metadata artist matcher, rather /// than the stricter primary-artist equality required for lyrics searches. diff --git a/rust_backend/crates/extensions/src/repository/model.rs b/rust_backend/crates/extensions/src/repository/model.rs index 81034384..aa378a84 100644 --- a/rust_backend/crates/extensions/src/repository/model.rs +++ b/rust_backend/crates/extensions/src/repository/model.rs @@ -2,6 +2,7 @@ use super::{RepositoryError, error}; use crate::environment::compare_versions; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Value, json}; +use spotiflac_core::json::field_name; use spotiflac_core::matching::lowercase; use std::cmp::Ordering; use std::collections::BTreeMap; @@ -47,14 +48,10 @@ impl NullField for Tags { } } -fn field_matches(key: &str, wire: &str) -> bool { - key.chars() - .map(|value| match value { - 'ſ' => 's', - 'K' => 'k', - value => value.to_ascii_lowercase(), - }) - .eq(wire.chars().map(|value| value.to_ascii_lowercase())) +// `folded_key` comes from `field_name`, which leaves no ASCII uppercase, so an +// ASCII case-insensitive comparison equals comparing it with the lowercased wire. +fn field_matches(folded_key: &str, wire: &str) -> bool { + folded_key.eq_ignore_ascii_case(wire) } macro_rules! wire_struct { @@ -77,6 +74,7 @@ macro_rules! wire_struct { fn visit_map>(self, mut map: A) -> Result { let mut result = $name::default(); while let Some(key) = map.next_key::()? { + let key = field_name(&key); $(if field_matches(&key, $wire) { match map.next_value::>()? { Some(value) => result.$field = value, @@ -224,3 +222,82 @@ impl Registry { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + // The matcher this module used before it shared core's `field_name`. + fn previous_field_matches(key: &str, wire: &str) -> bool { + key.chars() + .map(|value| match value { + 'ſ' => 's', + '\u{212A}' => 'k', + value => value.to_ascii_lowercase(), + }) + .eq(wire.chars().map(|value| value.to_ascii_lowercase())) + } + + #[test] + fn field_matching_with_core_folding_equals_previous_matcher() { + let keys = [ + "", + "id", + "ID", + "Id", + "displayName", + "DISPLAYNAME", + "display_name", + "DISPLAY_NAME", + "checkſum_sha256", + "CHECKSUMSHA256", + "\u{212A}ey", + "Key", + "sha256", + "SHA256", + "s\u{0301}ha256", + "\u{017F}", + "\u{212A}", + "\u{00C9}tag", + "\u{00E9}tag", + "tags ", + "\u{FEFF}tags", + "updated_at\u{0000}", + ]; + let wires = [ + "", + "id", + "display_name", + "displayName", + "checksum_sha256", + "checksumSha256", + "key", + "sha256", + "s", + "k", + "\u{00E9}tag", + "tags", + "updated_at", + ]; + for key in keys { + let folded = field_name(key); + for wire in wires { + assert_eq!( + field_matches(&folded, wire), + previous_field_matches(key, wire), + "key {key:?} wire {wire:?}" + ); + } + } + + let registry: Registry = serde_json::from_str( + r#"{"VERSION":2,"updated_at":"old","UPDATED_AT":null,"Extensions":[{"ID":"a","DisplayName":"A","checkſum_sha256":"x"}]}"#, + ) + .unwrap(); + assert_eq!(registry.version, 2); + assert_eq!(registry.updated_at, "old"); + assert_eq!(registry.extensions[0].id, "a"); + assert_eq!(registry.extensions[0].display_name_alt, "A"); + assert_eq!(registry.extensions[0].checksum_sha256, "x"); + } +}