1use std::collections::HashMap;
12
13use unicode_normalization::UnicodeNormalization;
14
15use crate::meta::Track;
16use crate::musicbrainz::{Release, credit_name};
17
18const W_ALBUM: f64 = 3.0;
19const W_ARTIST: f64 = 3.0;
20const W_TITLE: f64 = 3.0;
21const W_LENGTH: f64 = 2.0;
22const W_MISSING: f64 = 0.9;
23const W_EXTRA: f64 = 0.6;
24const LENGTH_GRACE_SECS: f64 = 10.0;
26const LENGTH_MAX_SECS: f64 = 30.0;
27
28pub fn normalise(s: &str) -> String {
31 let folded: String = s
32 .nfkd()
33 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
34 .collect::<String>()
35 .to_lowercase()
36 .replace('&', " and ");
37 let words: Vec<&str> = folded
38 .split(|c: char| !c.is_alphanumeric())
39 .filter(|w| !w.is_empty())
40 .collect();
41 let words = match words.first() {
42 Some(&"the") if words.len() > 1 => &words[1..],
43 _ => &words[..],
44 };
45 words.join(" ")
46}
47
48pub fn base_title(s: &str) -> String {
52 const EDITION: &[&str] = &[
53 "edition",
54 "remaster",
55 "deluxe",
56 "anniversary",
57 "expanded",
58 "bonus",
59 "reissue",
60 "version",
61 "special",
62 "collector",
63 "explicit",
64 "clean",
65 "mono",
66 "stereo",
67 "hi-res",
68 "24-bit",
69 "24bit",
70 "flac",
71 "web",
72 "vinyl",
73 "cd",
74 "lp",
75 "ep",
76 ];
77 let mut out = s.trim().to_string();
78 loop {
79 let trimmed = out.trim_end();
80 let Some(close) = trimmed.chars().last().filter(|c| *c == ')' || *c == ']') else {
81 break;
82 };
83 let open = if close == ')' { '(' } else { '[' };
84 let Some(start) = trimmed.rfind(open) else {
85 break;
86 };
87 let inner = trimmed[start + 1..trimmed.len() - 1].to_lowercase();
88 let is_edition = EDITION.iter().any(|k| {
89 inner
90 .split(|c: char| !c.is_alphanumeric() && c != '-')
91 .any(|w| w == *k || w.starts_with(k))
92 }) || inner.chars().all(|c| c.is_ascii_digit());
93 if start == 0 || !is_edition {
94 break;
95 }
96 out = trimmed[..start]
97 .trim_end()
98 .trim_end_matches(['-', ':', '–'])
99 .trim_end()
100 .to_string();
101 }
102 out
103}
104
105pub fn string_distance(a: &str, b: &str) -> f64 {
106 static PATTERNS: std::sync::LazyLock<Vec<(regex::Regex, f64)>> =
111 std::sync::LazyLock::new(|| {
112 [
113 (r"^the ", 0.1),
114 (r"[\[\(]?(ep|single)[\]\)]?", 0.0),
115 (r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
116 (r"\(.*?\)", 0.3),
117 (r"\[.*?\]", 0.3),
118 (r"(, )?(pt\.|part) .+", 0.2),
119 ]
120 .into_iter()
121 .map(|(p, w)| (regex::Regex::new(p).expect("static regex"), w))
122 .collect()
123 });
124 let prepare = |s: &str| {
125 let mut s = s.to_lowercase();
126 for word in ["the", "a", "an"] {
127 if let Some(rest) = s.strip_suffix(&format!(", {word}")) {
128 s = format!("{word} {rest}");
129 }
130 }
131 s.replace('&', "and")
132 };
133 let (mut a, mut b) = (prepare(a), prepare(b));
134 let mut base = basic_distance(&a, &b);
135 let mut penalty = 0.0;
136 for (pattern, weight) in PATTERNS.iter() {
137 let (ca, cb) = (
138 pattern.replace_all(&a, "").into_owned(),
139 pattern.replace_all(&b, "").into_owned(),
140 );
141 if ca == a && cb == b {
142 continue;
143 }
144 let case = basic_distance(&ca, &cb);
145 let delta = (base - case).max(0.0);
146 if delta == 0.0 {
147 continue;
148 }
149 (a, b, base) = (ca, cb, case);
150 penalty += weight * delta;
151 }
152 base + penalty
153}
154
155fn basic_distance(a: &str, b: &str) -> f64 {
158 let clean = |s: &str| -> String {
159 deunicode::deunicode(s)
160 .to_lowercase()
161 .chars()
162 .filter(|c| c.is_ascii_alphanumeric())
163 .collect()
164 };
165 let (a, b) = (clean(a), clean(b));
166 if a.is_empty() && b.is_empty() {
167 return 0.0;
168 }
169 strsim::levenshtein(&a, &b) as f64 / a.chars().count().max(b.chars().count()) as f64
170}
171
172fn length_distance(local_secs: f64, remote_ms: Option<u64>) -> f64 {
177 let Some(ms) = remote_ms else { return 0.0 };
178 let remote = ms as f64 / 1000.0;
179 let grace = LENGTH_GRACE_SECS.max(remote * 0.02);
180 let max = LENGTH_MAX_SECS.max(remote * 0.06);
181 let diff = (local_secs - remote).abs();
182 ((diff - grace) / (max - grace)).clamp(0.0, 1.0)
183}
184
185#[derive(Debug, Clone)]
186pub struct Match {
187 pub release: Release,
188 pub distance: f64,
189 pub pairs: Vec<(usize, usize)>,
191 pub missing: usize,
193 pub extra: usize,
195 pub parts: Parts,
198}
199
200#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
201pub struct Parts {
202 pub album: f64,
203 pub artist: f64,
204 pub titles: f64,
205 pub lengths: f64,
206}
207
208impl std::fmt::Display for Parts {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 write!(
211 f,
212 "album {:.2}, artist {:.2}, titles {:.2}, lengths {:.2}",
213 self.album, self.artist, self.titles, self.lengths
214 )
215 }
216}
217
218impl Match {
219 pub fn is_complete(&self) -> bool {
220 self.missing == 0 && self.extra == 0
221 }
222}
223
224pub fn consensus<'a>(values: impl Iterator<Item = Option<&'a str>>) -> Option<String> {
226 let mut counts: HashMap<&str, usize> = HashMap::new();
227 for v in values.flatten() {
228 if !v.trim().is_empty() {
229 *counts.entry(v).or_default() += 1;
230 }
231 }
232 counts
233 .into_iter()
234 .max_by_key(|(v, n)| (*n, std::cmp::Reverse(v.len())))
235 .map(|(v, _)| v.to_string())
236}
237
238pub fn score(local: &[Track], release: &Release) -> Match {
239 let remote: Vec<_> = release.tracks().collect();
240 let album = consensus(local.iter().map(|t| t.album.as_deref())).unwrap_or_default();
241 let artist = consensus(
242 local
243 .iter()
244 .map(|t| t.album_artist.as_deref().or(t.artist.as_deref())),
245 )
246 .unwrap_or_default();
247
248 let d_album = string_distance(&base_title(&album), &base_title(&release.title));
249 let d_artist = if release.is_compilation()
250 && ["various artists", "various", "va"].contains(&normalise(&artist).as_str())
251 {
252 0.0
253 } else {
254 string_distance(&artist, &release.artist())
255 };
256
257 let cost = |l: usize, r: usize| -> (f64, f64) {
258 let (medium, track) = remote[r];
259 let _ = medium;
260 let title = local[l].title.as_deref().unwrap_or_default();
261 (
262 string_distance(title, &track.title),
263 length_distance(
264 local[l].duration.as_secs_f64(),
265 track.length.or(track.recording.length),
266 ),
267 )
268 };
269
270 let pairs = pair_by_number(local, release).unwrap_or_else(|| {
271 pair_by_cost(local.len(), remote.len(), |l, r| {
272 let (t, len) = cost(l, r);
273 W_TITLE * t + W_LENGTH * len
274 })
275 });
276
277 let mut num = W_ALBUM * d_album + W_ARTIST * d_artist;
278 let mut den = W_ALBUM + W_ARTIST;
279 let (mut titles, mut lengths) = (0.0, 0.0);
280 for &(l, r) in &pairs {
281 let (t, len) = cost(l, r);
282 titles += t;
283 lengths += len;
284 num += W_TITLE * t + W_LENGTH * len;
285 den += W_TITLE + W_LENGTH;
286 }
287 let paired = pairs.len().max(1) as f64;
288 let missing = remote.len() - pairs.len();
289 let extra = local.len() - pairs.len();
290 num += W_MISSING * missing as f64 + W_EXTRA * extra as f64;
291 den += W_MISSING * missing as f64 + W_EXTRA * extra as f64;
292
293 Match {
294 release: release.clone(),
295 distance: if den > 0.0 { num / den } else { 1.0 },
296 pairs,
297 missing,
298 extra,
299 parts: Parts {
300 album: d_album,
301 artist: d_artist,
302 titles: titles / paired,
303 lengths: lengths / paired,
304 },
305 }
306}
307
308fn pair_by_number(local: &[Track], release: &Release) -> Option<Vec<(usize, usize)>> {
311 let mut by_number = HashMap::new();
312 for (i, t) in local.iter().enumerate() {
313 let key = (t.disc.unwrap_or(1), t.track?);
314 if by_number.insert(key, i).is_some() {
315 return None;
316 }
317 }
318 let per_disc: Vec<(usize, usize)> = release
319 .tracks()
320 .enumerate()
321 .filter_map(|(r, (m, t))| by_number.get(&(m.position, t.position)).map(|&l| (l, r)))
322 .collect();
323 let mut overall = HashMap::new();
329 let unique = local
330 .iter()
331 .enumerate()
332 .all(|(i, t)| t.track.is_some_and(|n| overall.insert(n, i).is_none()));
333 let continuous: Vec<(usize, usize)> = if unique {
334 (0..release.tracks().count())
335 .filter_map(|r| overall.get(&(r as u32 + 1)).map(|&l| (l, r)))
336 .collect()
337 } else {
338 Vec::new()
339 };
340 let pairs = if continuous.len() > per_disc.len() {
341 continuous
342 } else {
343 per_disc
344 };
345 (pairs.len() * 2 >= local.len()).then_some(pairs)
348}
349
350fn pair_by_cost(
354 locals: usize,
355 remotes: usize,
356 cost: impl Fn(usize, usize) -> f64,
357) -> Vec<(usize, usize)> {
358 let mut all: Vec<(f64, usize, usize)> = (0..locals)
359 .flat_map(|l| (0..remotes).map(move |r| (l, r)))
360 .map(|(l, r)| (cost(l, r), l, r))
361 .collect();
362 all.sort_by(|a, b| a.0.total_cmp(&b.0));
363 let (mut used_l, mut used_r) = (vec![false; locals], vec![false; remotes]);
364 let mut pairs = Vec::new();
365 for (c, l, r) in all {
366 if used_l[l] || used_r[r] || c > (W_TITLE + W_LENGTH) * 0.6 {
367 continue;
368 }
369 used_l[l] = true;
370 used_r[r] = true;
371 pairs.push((l, r));
372 }
373 pairs.sort_unstable();
374 pairs
375}
376
377pub fn track_artist(release: &Release, index: usize) -> String {
379 let (_, t) = release.tracks().nth(index).expect("index from pairs");
380 if t.artist_credit.is_empty() {
381 release.artist()
382 } else {
383 credit_name(&t.artist_credit)
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use std::time::Duration;
391
392 fn release() -> Release {
393 serde_json::from_value(serde_json::json!({
394 "id": "r", "title": "Geogaddi",
395 "artist-credit": [{"name": "Boards of Canada", "joinphrase": "", "artist": {"id": "a", "name": "Boards of Canada"}}],
396 "media": [{"position": 1, "tracks": [
397 {"id": "t1", "position": 1, "title": "Ready Lets Go", "length": 59000, "recording": {"id": "r1"}},
398 {"id": "t2", "position": 2, "title": "Music Is Math", "length": 321000, "recording": {"id": "r2"}},
399 {"id": "t3", "position": 3, "title": "Beware the Friendly Stranger", "length": 37000, "recording": {"id": "r3"}}
400 ]}]
401 }))
402 .unwrap()
403 }
404
405 fn track(title: &str, n: Option<u32>, secs: u64) -> Track {
406 Track {
407 title: Some(title.into()),
408 album: Some("Geogaddi".into()),
409 artist: Some("Boards Of Canada".into()),
410 track: n,
411 duration: Duration::from_secs(secs),
412 ..Default::default()
413 }
414 }
415
416 #[test]
417 fn length_allowance_grows_with_long_tracks() {
418 assert_eq!(length_distance(1202.7, Some(1_224_000)), 0.0);
420 assert!((length_distance(201.0, Some(180_000)) - 0.55).abs() < 1e-9);
422 assert_eq!(length_distance(185.0, Some(180_000)), 0.0);
423 }
424
425 #[test]
426 fn edition_markers_come_off_titles() {
427 assert_eq!(base_title("Monster (25th Anniversary Edition)"), "Monster");
428 assert_eq!(base_title("Kill For Love (Deluxe)"), "Kill For Love");
429 assert_eq!(base_title("OK Computer [Remastered] (2017)"), "OK Computer");
430 assert_eq!(
431 base_title("(What's the Story) Morning Glory?"),
432 "(What's the Story) Morning Glory?"
433 );
434 assert_eq!(base_title("Live at Leeds (Live)"), "Live at Leeds (Live)");
435 assert_eq!(
436 base_title("Music Has the Right to Children"),
437 "Music Has the Right to Children"
438 );
439 }
440
441 #[test]
443 fn string_distance_matches_beets() {
444 let d = string_distance;
445 assert_eq!(d("Some String", "Some String"), 0.0);
446 assert_ne!(d("Some String", "Totally Different"), 0.0);
447 assert_eq!(d("Some String", "Some.String!"), 0.0);
448 assert_eq!(d("Some String", "sOME sTring"), 0.0);
449 assert!(d("The Band Name", "Band Name") < d("XXX Band Name", "Band Name"));
450 assert!(d("One (Two)", "One") < d("One .Two.", "One"));
451 assert!(d("One [Two]", "One") < d("One .Two.", "One"));
452 assert_eq!(d("My Song (EP)", "My Song"), 0.0);
453 assert!(d("My Song feat. Someone", "My Song") < d("My Song blah Someone", "My Song"));
454 assert_eq!(d("The Song Title", "Song Title, The"), 0.0);
455 assert_eq!(d("A Song Title", "Song Title, A"), 0.0);
456 assert_eq!(d("An Album Title", "Album Title, An"), 0.0);
457 assert_eq!(d("", ""), 0.0);
458 for (a, b) in [("The ", ""), ("(EP)", "(EP)"), (", An", "")] {
459 assert!(d(a, b).is_finite());
460 }
461 assert_eq!(d("Untitled", "[Untitled]"), 0.0);
462 assert_eq!(d("And", "&"), 0.0);
463 assert_eq!(d("\u{e9}\u{e1}\u{f1}", "ean"), 0.0);
464 }
465
466 #[test]
467 fn normalises_the_differences_no_one_means() {
468 assert_eq!(normalise("The Beatles"), "beatles");
469 assert_eq!(normalise("Björk & Friends!"), "bjork and friends");
470 assert!(string_distance("Ready Let's Go", "Ready Lets Go") < 0.1);
471 }
472
473 #[test]
474 fn a_complete_tagged_album_is_very_close() {
475 let local = vec![
476 track("Ready Lets Go", Some(1), 59),
477 track("Music Is Math", Some(2), 321),
478 track("Beware the Friendly Stranger", Some(3), 37),
479 ];
480 let m = score(&local, &release());
481 assert!(m.distance < 0.01, "{}", m.distance);
482 assert!(m.is_complete());
483 }
484
485 #[test]
486 fn untagged_files_pair_by_title_and_length() {
487 let local = vec![
488 track("music is math", None, 320),
489 track("ready let's go", None, 60),
490 track("beware the friendly stranger", None, 38),
491 ];
492 let m = score(&local, &release());
493 assert_eq!(m.pairs, [(0, 1), (1, 0), (2, 2)]);
494 assert!(m.distance < 0.05, "{}", m.distance);
495 }
496
497 #[test]
498 fn a_second_disc_numbered_on_from_the_first_pairs_with_one_medium() {
499 let mut local = vec![
500 track("Ready Lets Go", Some(1), 59),
501 track("Music Is Math", Some(2), 321),
502 track("Beware the Friendly Stranger", Some(3), 37),
503 ];
504 local[2].disc = Some(2);
505 let m = score(&local, &release());
506 assert_eq!(m.pairs, [(0, 0), (1, 1), (2, 2)]);
507 assert!(m.is_complete());
508 }
509
510 #[test]
511 fn files_counted_straight_through_pair_with_sides_that_restart() {
512 let two_sides: Release = serde_json::from_value(serde_json::json!({
513 "id": "r", "title": "Geogaddi",
514 "artist-credit": [{"name": "Boards of Canada", "joinphrase": "", "artist": {"id": "a", "name": "Boards of Canada"}}],
515 "media": [
516 {"position": 1, "tracks": [
517 {"id": "t1", "position": 1, "title": "Ready Lets Go", "length": 59000, "recording": {"id": "r1"}},
518 {"id": "t2", "position": 2, "title": "Music Is Math", "length": 321000, "recording": {"id": "r2"}}
519 ]},
520 {"position": 2, "tracks": [
521 {"id": "t3", "position": 1, "title": "Beware the Friendly Stranger", "length": 37000, "recording": {"id": "r3"}},
522 {"id": "t4", "position": 2, "title": "Gyroscope", "length": 215000, "recording": {"id": "r4"}}
523 ]}
524 ]
525 }))
526 .unwrap();
527 let local = vec![
528 track("Ready Lets Go", Some(1), 59),
529 track("Music Is Math", Some(2), 321),
530 track("Beware the Friendly Stranger", Some(3), 37),
531 track("Gyroscope", Some(4), 215),
532 ];
533 let m = score(&local, &two_sides);
534 assert_eq!(m.pairs, [(0, 0), (1, 1), (2, 2), (3, 3)]);
535 assert!(m.is_complete());
536 }
537
538 #[test]
539 fn missing_tracks_and_the_wrong_album_cost() {
540 let local = vec![track("Ready Lets Go", Some(1), 59)];
541 let m = score(&local, &release());
542 assert_eq!(m.missing, 2);
543 let mut wrong = vec![track("Roygbiv", Some(1), 150)];
544 wrong[0].album = Some("Music Has the Right to Children".into());
545 assert!(score(&wrong, &release()).distance > 0.4);
546 }
547}