use once_cell::sync::Lazy;
use regex::Regex;
use unicode_normalization::{char::is_combining_mark, UnicodeNormalization};
fn fold_basic(s: &str) -> String {
let mut decomposed = String::new();
for c in s.nfd() {
if !is_combining_mark(c) {
for l in c.to_lowercase() {
decomposed.push(l);
}
}
}
let replaced = decomposed
.replace("&", "&")
.replace('“', "\"")
.replace('”', "\"")
.replace('‘', "'")
.replace('’', "'");
let mut out = String::with_capacity(replaced.len());
let mut last_space = false;
for ch in replaced.chars() {
if ch.is_whitespace() {
if !last_space {
out.push(' ');
last_space = true;
}
} else {
out.push(ch);
last_space = false;
}
}
out.trim().to_owned()
}
fn strip_common_punct(s: &str) -> String {
s.chars()
.filter(|c| !c.is_ascii_punctuation())
.collect()
}
static DECORATION_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)(?:[\(\[\{][^)\]\}]*?(?:remaster|live|version|edit|mix|karaoke|mono|instrumental|acoustic)[^)\]\}]*?[\)\]\}]|\s*[-–—]\s*(?:\d{2,4}\s*)?(?:live|remaster|version|edit|mix|karaoke|mono|instrumental|acoustic)(?:\s*\d{2,4})?\s*$)",
)
.unwrap()
});
static ALBUM_DECORATION_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)(?:[\(\[\{][^)\]\}]*?(?:deluxe|expanded|anniversary|remaster|edition|version)[^)\]\}]*?[\)\]\}]|\s*[-–—]\s*(?:(?:deluxe|expanded|anniversary|remaster|edition|version)(?:\s+(?:edition|version))?)\s*$)",
)
.unwrap()
});
static FEAT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?i)\s+\(?\s*(?:feat(?:\.|\b)|featuring|ft(?:\.|\b))\s+[^)]+\)?"
)
.unwrap()
});
static ARTIST_SPLIT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\s*(?:&| and | x |,|;|\+)\s*").unwrap());
pub fn normalize_song_title(raw: &str) -> String {
let no_feat = FEAT_RE.replace_all(raw, "").to_string();
let core = DECORATION_RE.replace_all(&no_feat, "").to_string();
let base = fold_basic(&core);
let stripped = strip_common_punct(&base);
fold_basic(&stripped)
}
pub fn normalize_album_title(raw: &str) -> String {
let core = ALBUM_DECORATION_RE.replace_all(raw, "").to_string();
let base = fold_basic(&core);
let stripped = strip_common_punct(&base);
fold_basic(&stripped)
}
pub fn normalize_artist_name(raw: &str) -> String {
let basic = fold_basic(raw);
const DASHES: [char; 6] = ['-', '‐', '‒', '–', '—', '−'];
let mut dash_as_space = String::with_capacity(basic.len());
for ch in basic.chars() {
if DASHES.contains(&ch) {
dash_as_space.push(' ');
} else {
dash_as_space.push(ch);
}
}
let connectors_standardised = ARTIST_SPLIT_RE
.replace_all(&dash_as_space, " and ")
.to_string();
let cleaned = strip_common_punct(&connectors_standardised);
let mut parts: Vec<_> = cleaned
.split(" and ")
.map(str::trim)
.filter(|s| !s.is_empty() && *s != "various artists")
.collect();
parts.sort_unstable();
parts.dedup();
parts.join(" and ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn track_examples() {
assert_eq!(
normalize_song_title("Shape of You (Extended Version)"),
"shape of you"
);
assert_eq!(
normalize_song_title("Hôtel California – Live 1999"),
"hotel california"
);
}
#[test]
fn track_feat_and_decorations() {
assert_eq!(
normalize_song_title("Blinding Lights (feat. ROSALÍA)"),
"blinding lights"
);
assert_eq!(
normalize_song_title("Blinding Lights feat. ROSALIA"),
"blinding lights"
);
assert_eq!(
normalize_song_title("Blinding Lights ft. Rosalia"),
"blinding lights"
);
assert_eq!(
normalize_song_title("Blinding Lights (featuring Rosalia)"),
"blinding lights"
);
assert_eq!(
normalize_song_title("Song Name (Remastered 2014)"),
"song name"
);
assert_eq!(
normalize_song_title("Song Name [Acoustic Version]"),
"song name"
);
assert_eq!(
normalize_song_title("Song Name – Live"),
"song name"
);
assert_eq!(
normalize_song_title("Song Name – Live 2001"),
"song name"
);
assert_eq!(
normalize_song_title("Song Name - Mix"),
"song name"
);
assert_eq!(normalize_song_title("Rock & Roll"), "rock roll");
assert_eq!(normalize_song_title("“Hello”"), "hello");
assert_eq!(normalize_song_title(" Don't Stop (Remaster) "), "dont stop");
}
#[test]
fn album_examples() {
assert_eq!(
normalize_album_title("Back to Black (Deluxe Edition)"),
"back to black"
);
}
#[test]
fn album_more_cases() {
assert_eq!(normalize_album_title("Random Title - EP"), "random title ep");
assert_eq!(normalize_album_title("Random Title - Single"), "random title single");
assert_eq!(
normalize_album_title("Album (Remastered 2011) [Deluxe Edition]"),
"album"
);
assert_eq!(
normalize_album_title("Album – Anniversary Edition"),
"album"
);
}
#[test]
fn artist_examples() {
assert_eq!(normalize_artist_name("Beyoncé & JAY-Z"), "beyonce and jay z");
assert_eq!(normalize_artist_name("AC/DC"), "acdc");
}
#[test]
fn artist_connectors_sort_and_dedup() {
assert_eq!(
normalize_artist_name("Beyoncé x JAY-Z & Ed Sheeran"),
"beyonce and ed sheeran and jay z"
);
assert_eq!(
normalize_artist_name("Beyoncé & Beyoncé"),
"beyonce"
);
assert_eq!(
normalize_artist_name("Various Artists & Beyoncé"),
"beyonce"
);
assert_eq!(
normalize_artist_name("Artist1, Artist2; Artist3 + Artist1"),
"artist1 and artist2 and artist3"
);
}
}