Disambiguate Fetch Data's TMDB search results by name similarity

- New crates/renamr-core/src/similarity module: a partial port of the
  scoring idea behind FileBot's EpisodeMetrics.java - normalizes titles
  (lowercase, strips a trailing release-year parenthetical, collapses
  punctuation/whitespace) and scores them with a Sorensen-Dice coefficient
  over character bigrams. best_title_match() picks whichever candidate is
  textually closest to the query, provided it clears a minimum threshold;
  ties keep the earlier (i.e. TMDB-ranked) candidate.
- TmdbClient::lookup_episode now uses best_title_match instead of blindly
  taking the first search result, so a more popular but differently-named
  show can no longer beat an exact-but-lower-ranked match to the top - and
  reports NotFound instead of guessing when nothing in the results is a
  plausible match at all.
- 7 new tests (5 in similarity, 2 in tmdb) including the concrete regression
  case this fixes: searching "Chuck" with TMDB returning "Chucky" ranked
  above "Chuck" now correctly picks "Chuck".
- Also fixes a gitignore gap from last delivery: the _to_delete*/ scratch-dir
  pattern used to work around this repo's mounted-folder git-lock quirk had
  only ever been applied directly on the device, never carried back into the
  zip this delivery is built from - so the last delivery's cp -rf silently
  reintroduced the untracked-lock-file risk. Fixed in both places now.
This commit is contained in:
Renamr Scaffold
2026-08-29 03:27:19 +00:00
parent 717acb5de1
commit 855f9bea24
5 changed files with 264 additions and 31 deletions
+158
View File
@@ -0,0 +1,158 @@
//! Lightweight text-similarity scoring, used to disambiguate between several search
//! results for the same query. A partial port of the *scoring* idea behind FileBot's
//! `net.filebot.similarity.EpisodeMetrics` (which combines several metrics: name,
//! episode numbering, air date, etc). This only ports the show-name half: given a
//! search query and a list of candidate titles, it picks the title that's the closest
//! textual match rather than trusting whichever result an external API happened to
//! rank first - TMDB ranks by relevance/popularity, not by exact name match, so a
//! more popular but differently-named show can otherwise beat an exact-but-less-popular
//! one straight to the top of the list. `DateMatcher` (the other half of the original
//! `EpisodeMetrics`) is not ported - Renamr doesn't do date-based episode identification.
use std::collections::HashMap;
/// Lowercases, strips a trailing 4-digit-year parenthetical (e.g. "(2013)" - release
/// metadata that never appears in a real filename, but commonly appears in TMDB's own
/// titles for remakes/reboots), and collapses every run of punctuation/whitespace down
/// to a single space, so two titles that differ only in casing, punctuation, or an
/// appended year compare as equal.
pub fn normalize_title(input: &str) -> String {
let mut without_year = input.to_string();
if let Some(start) = without_year.rfind('(') {
let inner = &without_year[start + 1..];
if let Some(end) = inner.find(')') {
let candidate = &inner[..end];
if candidate.len() == 4 && candidate.chars().all(|c| c.is_ascii_digit()) {
without_year.replace_range(start..start + end + 2, "");
}
}
}
let mut out = String::with_capacity(without_year.len());
let mut last_was_space = true; // avoid a leading space in the output
for c in without_year.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_was_space = false;
} else if !last_was_space {
out.push(' ');
last_was_space = true;
}
}
out.trim_end().to_string()
}
/// Character-bigram multiset of a string, e.g. "abc" -> {ab: 1, bc: 1}.
fn bigrams(s: &str) -> HashMap<(char, char), u32> {
let chars: Vec<char> = s.chars().collect();
let mut map = HashMap::new();
if chars.len() < 2 {
// Too short for a real bigram - fall back to a single-char "bigram" so a
// one-letter title still contributes something rather than always scoring 0.
if let Some(&c) = chars.first() {
*map.entry((c, '\0')).or_insert(0) += 1;
}
return map;
}
for w in chars.windows(2) {
*map.entry((w[0], w[1])).or_insert(0) += 1;
}
map
}
/// Sorensen-Dice coefficient over character bigrams of the normalized titles: 1.0 for
/// an exact match (after normalization), trending toward 0.0 for unrelated strings,
/// while staying tolerant of the small differences (punctuation, word order, a
/// trailing "Part 2") that would sink a naive exact-string comparison.
pub fn title_similarity(a: &str, b: &str) -> f64 {
let na = normalize_title(a);
let nb = normalize_title(b);
if na == nb {
return 1.0;
}
let ba = bigrams(&na);
let bb = bigrams(&nb);
let total: u32 = ba.values().sum::<u32>() + bb.values().sum::<u32>();
if total == 0 {
return 0.0;
}
let mut overlap = 0u32;
for (bigram, &count) in &ba {
if let Some(&other_count) = bb.get(bigram) {
overlap += count.min(other_count);
}
}
(2.0 * overlap as f64) / total as f64
}
/// Picks whichever candidate's title is textually closest to `query`, provided its
/// score clears `threshold`. Below that, none of the candidates are considered a
/// plausible match for the query at all and `None` is returned - it's better to
/// report "not found" than to confidently rename files against the wrong show. Ties
/// keep the earlier candidate, which preserves the caller's own ranking (e.g. TMDB's
/// relevance/popularity order) as the tiebreaker when two titles score identically.
pub fn best_title_match<'a, T>(
query: &str,
candidates: &'a [T],
title_of: impl Fn(&T) -> &str,
threshold: f64,
) -> Option<&'a T> {
let mut best: Option<(&'a T, f64)> = None;
for candidate in candidates {
let score = title_similarity(query, title_of(candidate));
if score < threshold {
continue;
}
match best {
Some((_, best_score)) if score <= best_score => {}
_ => best = Some((candidate, score)),
}
}
best.map(|(candidate, _)| candidate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_after_case_and_punctuation_normalization_scores_1() {
assert_eq!(title_similarity("Breaking Bad", "breaking-bad"), 1.0);
assert_eq!(title_similarity("The Office (US)", "the office, us!"), 1.0);
}
#[test]
fn trailing_release_year_parenthetical_is_ignored() {
assert_eq!(title_similarity("Chernobyl", "Chernobyl (2019)"), 1.0);
}
#[test]
fn unrelated_titles_score_low() {
let score = title_similarity("Zzyzx Nonexistent Show", "Totally Unrelated Program");
assert!(score < 0.3, "expected a low score, got {score}");
}
#[test]
fn best_title_match_prefers_the_closer_textual_match_over_list_order() {
let candidates = vec!["Chucky".to_string(), "Chuck".to_string()];
let best = best_title_match("Chuck", &candidates, |s| s.as_str(), 0.3);
assert_eq!(best.map(String::as_str), Some("Chuck"));
}
#[test]
fn best_title_match_keeps_the_earlier_candidate_on_a_tie() {
let candidates = vec!["Show A".to_string(), "Show A".to_string()];
let best = best_title_match("Show A", &candidates, |s| s.as_str(), 0.3);
// Both score 1.0 - the first one (by identity, not just value) should win.
assert!(best.is_some_and(|s| std::ptr::eq(s, &candidates[0])));
}
#[test]
fn best_title_match_returns_none_when_nothing_clears_the_threshold() {
let candidates = vec!["Totally Unrelated Program".to_string()];
let best = best_title_match("Zzyzx Nonexistent Show", &candidates, |s| s.as_str(), 0.3);
assert_eq!(best, None);
}
}