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.
This commit is contained in:
zarzet committed 2026-10-19 00:36:29 +07:00
1 parent ff3f752014
commit 088bd875dd
16 files changed
+340 -245

No files matched your search

+1 -1
View File
@@ -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,
+160
View File
@@ -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<E: serde::de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
Ok(Cow::Borrowed(value))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(Cow::Owned(value.into()))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(Cow::Owned(value))
}
}
pub(crate) struct KeySeed;
impl<'de> DeserializeSeed<'de> for KeySeed {
type Value = Cow<'de, str>;
fn deserialize<D: Deserializer<'de>>(self, decoder: D) -> Result<Self::Value, D::Error> {
decoder.deserialize_str(KeyVisitor)
}
}
pub(crate) trait Update<'de> {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error>;
}
macro_rules! scalar {
($($type:ty),*) => {$(
impl<'de> Update<'de> for $type {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
// A later null does not overwrite an already decoded scalar.
if let Some(value) = Option::<Self>::deserialize(decoder)? { *self = value; }
Ok(())
}
}
)*};
}
scalar!(String, bool, i64, isize, f64);
impl<'de, T: Deserialize<'de>> Update<'de> for Option<T> {
fn update<D: Deserializer<'de>>(&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<D: Deserializer<'de>>(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<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
let mut result = Self::default();
$crate::json::Update::update(&mut result, decoder)?;
Ok(result)
}
}
impl<'de> $crate::json::Update<'de> for $name {
fn update<D: serde::Deserializer<'de>>(&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<E: serde::de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_map<M: serde::de::MapAccess<'de>>(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::<serde::de::IgnoredAny>()?; }
}
}
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");
}
}
+1
View File
@@ -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;
+4 -3
View File
@@ -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<String>,
}
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,
@@ -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(());
}
+2 -144
View File
@@ -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<T: DeserializeOwned>(raw: &str) -> Result<T, serde_json::Error> {
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<E: serde::de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
Ok(Cow::Borrowed(value))
}
fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
Ok(Cow::Owned(value.into()))
}
fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
Ok(Cow::Owned(value))
}
}
pub(crate) struct KeySeed;
impl<'de> DeserializeSeed<'de> for KeySeed {
type Value = Cow<'de, str>;
fn deserialize<D: Deserializer<'de>>(self, decoder: D) -> Result<Self::Value, D::Error> {
decoder.deserialize_str(KeyVisitor)
}
}
pub(crate) trait Update<'de> {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error>;
}
macro_rules! scalar {
($($type:ty),*) => {$(
impl<'de> Update<'de> for $type {
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
// A later null does not overwrite an already decoded scalar.
if let Some(value) = Option::<Self>::deserialize(decoder)? { *self = value; }
Ok(())
}
}
)*};
}
scalar!(String, bool, i64, isize, f64);
impl<'de, T: Deserialize<'de>> Update<'de> for Option<T> {
fn update<D: Deserializer<'de>>(&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<D: Deserializer<'de>>(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<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
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<D: serde::Deserializer<'de>>(&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<E: serde::de::Error>(self) -> Result<(), E> {
Ok(())
}
fn visit_map<M: serde::de::MapAccess<'de>>(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::<serde::de::IgnoredAny>()?; }
}
}
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"}"#,
)
@@ -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,
+31 -31
View File
@@ -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<Vec<Artist>>,
}
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<Vec<NeteaseSong>>,
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<LyricField>,
@@ -60,23 +60,23 @@ pub struct NeteaseLyrics {
pub romalrc: Option<LyricField>,
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<Vec<AppleSongId>>,
}
go_deserialize!(AppleSongs { "data" => data, });
lenient_deserialize!(AppleSongs { "data" => data, });
#[derive(Clone, Debug, Default)]
pub struct AppleResults {
pub songs: Option<AppleSongs>,
}
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<BTreeMap<String, AppleSong>>,
}
go_deserialize!(AppleResources { "songs" => songs, });
lenient_deserialize!(AppleResources { "songs" => songs, });
#[derive(Clone, Debug, Default)]
pub struct AppleSearch {
pub results: AppleResults,
pub resources: Option<AppleResources>,
}
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<Vec<Artist>>,
}
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<Vec<QqSong>>,
}
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<Vec<QqSuggestion>>,
}
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<Vec<KugouSong>>,
}
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<Vec<GeniusHit>>,
}
go_deserialize!(GeniusSection { "hits" => hits, });
lenient_deserialize!(GeniusSection { "hits" => hits, });
#[derive(Clone, Debug, Default)]
pub struct GeniusResults {
pub sections: Option<Vec<GeniusSection>>,
}
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, });
+14 -16
View File
@@ -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<isize>,
pub endtime: Option<isize>,
}
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<Vec<AppleAgent>>,
}
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<AppleMetadata>,
}
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<String, String> {
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<Box<RawValue>>,
}
json::go_deserialize!(Payload { "metadata" => metadata, });
lenient_deserialize!(Payload { "metadata" => metadata, });
let metadata = json::decode::<Payload>(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<Vec<KpoeSyllable>>,
}
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<Vec<KpoeLine>>,
}
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();
@@ -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<Vec<Artist>>,
}
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<Vec<Track>>,
}
go_deserialize!(Tracks { "data" => data, });
lenient_deserialize!(Tracks { "data" => data, });
impl Track {
pub fn artist_display(&self) -> String {
@@ -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<Vec<Track>>,
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<Vec<Genre>>,
}
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<Vec<Artist>>,
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<Vec<ArtistAlbum>>,
}
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),*) => {$(
@@ -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<Vec<ArtistCredit>>,
}
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<Vec<Tag>>,
pub releases: Option<Vec<Release>>,
}
go_deserialize!(Recording { "tags" => tags, "releases" => releases, });
lenient_deserialize!(Recording { "tags" => tags, "releases" => releases, });
#[derive(Clone, Debug, Default)]
pub struct Response {
pub recordings: Option<Vec<Recording>>,
}
go_deserialize!(Response { "recordings" => recordings, });
lenient_deserialize!(Response { "recordings" => recordings, });
pub fn genre(tags: &[Tag]) -> String {
let mut seen = BTreeSet::new();
@@ -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,
@@ -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,
});
+13 -13
View File
@@ -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<T> {
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<Link>,
pub entities: Map<Entity>,
}
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<Vec<Artist>>,
}
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<Vec<Recording>>,
}
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<Vec<Relation>>,
}
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<Option<Link>>,
}
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.
@@ -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<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut result = $name::default();
while let Some(key) = map.next_key::<String>()? {
let key = field_name(&key);
$(if field_matches(&key, $wire) {
match map.next_value::<Option<$kind>>()? {
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");
}
}