mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-01 21:59:43 +02:00
Replace the Go backend with the Rust workspace and route Android/iOS through UniFFI bindings. Include the migrated extension runtime, network, providers, media metadata, downloads and library operations, with version 5.0.0+147. Remove Go sources, adapters, native build selection and CI dependencies. Build Rust unconditionally and lock the iOS Rust pod using a relative path. Retain legacy source and migration evidence in a local ignored archive. Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI build checkpoint; no new APK or iOS application build for this cleanup. Known follow-up: URL/URLSearchParams globals are missing from the Rust JS runtime. A controlled extension replay confirms a URL-resolution regression; this commit does not fix that runtime gap.
340 lines
11 KiB
Rust
340 lines
11 KiB
Rust
//! Typed platform-resolver payloads and metadata matching shared with Go.
|
|
|
|
use crate::lyrics::json::{Update, go_deserialize};
|
|
use crate::lyrics::matching::normalize_loose_artist;
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Metadata {
|
|
pub title: String,
|
|
pub artist: String,
|
|
}
|
|
|
|
/// Go reuses a non-nil map across repeated JSON fields, but resets it on null.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Map<T>(pub BTreeMap<String, T>);
|
|
|
|
impl<T> Default for Map<T> {
|
|
fn default() -> Self {
|
|
Self(BTreeMap::new())
|
|
}
|
|
}
|
|
|
|
impl<'de, T: Deserialize<'de>> Update<'de> for Map<T> {
|
|
fn update<D: Deserializer<'de>>(&mut self, decoder: D) -> Result<(), D::Error> {
|
|
match Option::<BTreeMap<String, T>>::deserialize(decoder)? {
|
|
Some(values) => self.0.extend(values),
|
|
None => self.0.clear(),
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Link {
|
|
pub url: String,
|
|
}
|
|
go_deserialize!(Link { "url" => url, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Entity {
|
|
pub title: String,
|
|
pub artist: String,
|
|
}
|
|
go_deserialize!(Entity { "title" => title, "artistname" => artist, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct UniTune {
|
|
pub entity_id: String,
|
|
pub links: Map<Link>,
|
|
pub entities: Map<Entity>,
|
|
}
|
|
go_deserialize!(UniTune {
|
|
"entityuniqueid" => entity_id,
|
|
"linksbyplatform" => links,
|
|
"entitiesbyuniqueid" => entities,
|
|
});
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Artist {
|
|
pub name: String,
|
|
}
|
|
go_deserialize!(Artist { "name" => name, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Recording {
|
|
pub id: String,
|
|
pub score: isize,
|
|
pub title: String,
|
|
pub artists: Option<Vec<Artist>>,
|
|
}
|
|
go_deserialize!(Recording {
|
|
"id" => id, "score" => score, "title" => title, "artist-credit" => artists,
|
|
});
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Recordings {
|
|
pub recordings: Option<Vec<Recording>>,
|
|
}
|
|
go_deserialize!(Recordings { "recordings" => recordings, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Resource {
|
|
pub resource: String,
|
|
}
|
|
go_deserialize!(Resource { "resource" => resource, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Relation {
|
|
pub url: Resource,
|
|
}
|
|
go_deserialize!(Relation { "url" => url, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Relations {
|
|
pub relations: Option<Vec<Relation>>,
|
|
}
|
|
go_deserialize!(Relations { "relations" => relations, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Created {
|
|
pub full_url: String,
|
|
pub title: String,
|
|
pub artist: String,
|
|
}
|
|
go_deserialize!(Created { "full_url" => full_url, "title" => title, "artist" => artist, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct PageData {
|
|
pub title: String,
|
|
pub artist: String,
|
|
pub services: Map<Option<Link>>,
|
|
}
|
|
go_deserialize!(PageData { "title" => title, "artist" => artist, "services" => services, });
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Page {
|
|
pub data: PageData,
|
|
}
|
|
go_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.
|
|
pub fn artists_match(expected: &str, found: &str) -> bool {
|
|
let first = normalize_loose_artist(expected);
|
|
let second = normalize_loose_artist(found);
|
|
if first.contains(&second) || second.contains(&first) {
|
|
return true;
|
|
}
|
|
let split = |value: &str| {
|
|
let mut value = crate::matching::lowercase(value);
|
|
for separator in [
|
|
" feat. ", " feat ", " ft. ", " ft ", " & ", " and ", ",", ";", " x ",
|
|
] {
|
|
value = value.replace(separator, "|");
|
|
}
|
|
value
|
|
.split('|')
|
|
.map(normalize_loose_artist)
|
|
.filter(|v| !v.is_empty())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
for first in split(expected) {
|
|
for second in split(found) {
|
|
if first.contains(&second) || second.contains(&first) {
|
|
return true;
|
|
}
|
|
let mut first: Vec<_> = first.split_whitespace().collect();
|
|
let mut second: Vec<_> = second.split_whitespace().collect();
|
|
first.sort_unstable();
|
|
second.sort_unstable();
|
|
if first == second {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
let latin = |value: &str| {
|
|
!value.chars().any(|ch| {
|
|
matches!(ch as u32,
|
|
0x4e00..=0x9fff | 0x3040..=0x309f | 0x30a0..=0x30ff |
|
|
0xac00..=0xd7af | 0x0600..=0x06ff | 0x0400..=0x04ff)
|
|
})
|
|
};
|
|
latin(expected) != latin(found)
|
|
}
|
|
|
|
/// Album/title matching used by Go's metadata enrichment policy.
|
|
pub fn titles_match(expected: &str, found: &str) -> bool {
|
|
use crate::lyrics::matching::normalize_title;
|
|
use crate::matching::lowercase;
|
|
let (expected, found) = (lowercase(expected.trim()), lowercase(found.trim()));
|
|
let contains = |a: &str, b: &str| a.contains(b) || b.contains(a);
|
|
if contains(&expected, &found) {
|
|
return true;
|
|
}
|
|
let clean = |value: &str| {
|
|
let mut value = value.to_owned();
|
|
let versions = [
|
|
"remaster",
|
|
"remastered",
|
|
"deluxe",
|
|
"bonus",
|
|
"single",
|
|
"album version",
|
|
"radio edit",
|
|
"original mix",
|
|
"extended",
|
|
"club mix",
|
|
"remix",
|
|
"live",
|
|
"acoustic",
|
|
"demo",
|
|
];
|
|
for (open, close) in [('(', ')'), ('[', ']')] {
|
|
while let (Some(start), Some(end)) = (value.rfind(open), value.rfind(close)) {
|
|
if end <= start
|
|
|| !versions
|
|
.iter()
|
|
.any(|word| value[start + 1..end].contains(word))
|
|
{
|
|
break;
|
|
}
|
|
value = format!("{}{}", value[..start].trim(), &value[end + 1..]);
|
|
}
|
|
}
|
|
for suffix in [
|
|
" - remaster",
|
|
" - remastered",
|
|
" - single version",
|
|
" - radio edit",
|
|
" - live",
|
|
" - acoustic",
|
|
" - demo",
|
|
" - remix",
|
|
] {
|
|
if value.ends_with(suffix) {
|
|
value.truncate(value.len() - suffix.len());
|
|
}
|
|
}
|
|
while value.contains(" ") {
|
|
value = value.replace(" ", " ");
|
|
}
|
|
value.trim().to_owned()
|
|
};
|
|
let (a, b) = (clean(&expected), clean(&found));
|
|
if a == b || (!a.is_empty() && !b.is_empty() && contains(&a, &b)) {
|
|
return true;
|
|
}
|
|
let core = |value: &str| {
|
|
let end = [value.find('('), value.find('['), value.find(" - ")]
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|index| *index > 0)
|
|
.min()
|
|
.unwrap_or(value.len());
|
|
value[..end].trim().to_owned()
|
|
};
|
|
let (a, b) = (core(&expected), core(&found));
|
|
if !a.is_empty() && a == b {
|
|
return true;
|
|
}
|
|
let (a, b) = (normalize_title(&expected), normalize_title(&found));
|
|
if !a.is_empty() && !b.is_empty() && contains(&a, &b) {
|
|
return true;
|
|
}
|
|
let symbols = |value: &str| {
|
|
use unicode_general_category::{GeneralCategory::*, get_general_category};
|
|
value
|
|
.chars()
|
|
.filter(|ch| {
|
|
!ch.is_alphanumeric()
|
|
&& !ch.is_whitespace()
|
|
&& !matches!(
|
|
get_general_category(*ch),
|
|
ConnectorPunctuation
|
|
| DashPunctuation
|
|
| OpenPunctuation
|
|
| ClosePunctuation
|
|
| InitialPunctuation
|
|
| FinalPunctuation
|
|
| OtherPunctuation
|
|
| NonspacingMark
|
|
| SpacingMark
|
|
| EnclosingMark
|
|
)
|
|
})
|
|
.collect::<String>()
|
|
};
|
|
if !expected.chars().any(char::is_alphanumeric) || !found.chars().any(char::is_alphanumeric) {
|
|
let a = symbols(&expected);
|
|
return !a.is_empty() && a == symbols(&found);
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn track_identity_title(value: &str) -> String {
|
|
crate::lyrics::matching::normalize_title(&track_title_without_annotations(value))
|
|
}
|
|
|
|
fn track_title_without_annotations(value: &str) -> std::borrow::Cow<'_, str> {
|
|
static ANNOTATION: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
|
|
regex::Regex::new(r#"(?i)[(\[][\t\n\f\r ]*(?:(?:feat\.?|ft\.?|featuring)[\t\n\f\r ]+[^)\]]+|from[\t\n\f\r ]+["“][^)\]]+["”][\t\n\f\r ]*)[)\]]"#).unwrap()
|
|
});
|
|
ANNOTATION.replace_all(value, " ")
|
|
}
|
|
|
|
pub fn track_titles_match(expected: &str, found: &str) -> bool {
|
|
let (expected, found) = (
|
|
track_title_without_annotations(expected),
|
|
track_title_without_annotations(found),
|
|
);
|
|
let (a, b) = (
|
|
crate::lyrics::matching::normalize_title(&expected),
|
|
crate::lyrics::matching::normalize_title(&found),
|
|
);
|
|
if !a.is_empty() && a == b {
|
|
return true;
|
|
}
|
|
if a.split_whitespace()
|
|
.chain(b.split_whitespace())
|
|
.any(|word| {
|
|
matches!(
|
|
word,
|
|
"mix"
|
|
| "remix"
|
|
| "live"
|
|
| "acoustic"
|
|
| "demo"
|
|
| "instrumental"
|
|
| "karaoke"
|
|
| "edit"
|
|
| "extended"
|
|
| "slowed"
|
|
| "sped"
|
|
)
|
|
})
|
|
{
|
|
return false;
|
|
}
|
|
titles_match(&expected, &found)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn repeated_maps_merge_but_null_resets_and_nested_structs_retain_fields() {
|
|
let payload: UniTune = crate::lyrics::decode_document(br#"{"linksByPlatform":{"a":{"url":"first"}},"LINKSBYPLATFORM":{"b":{"URL":"second","url":null}},"unused":1e400}"#).unwrap();
|
|
assert_eq!(payload.links.0.len(), 2);
|
|
assert_eq!(payload.links.0["b"].url, "second");
|
|
let payload: Page = crate::lyrics::decode_response(br#"{"data":{"title":"Title","services":{"a":{"url":"old"}}},"data":{"services":null},"data":{"artist":"Artist","services":{"b":null}}}; ignored"#).unwrap();
|
|
assert_eq!(payload.data.title, "Title");
|
|
assert_eq!(payload.data.artist, "Artist");
|
|
assert_eq!(payload.data.services.0.len(), 1);
|
|
assert!(payload.data.services.0["b"].is_none());
|
|
}
|
|
}
|