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
+72 -8
View File
@@ -11,8 +11,17 @@
use serde::Deserialize;
use thiserror::Error;
use crate::similarity::best_title_match;
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
/// Minimum name-similarity score (see `similarity::title_similarity`) a TMDB search
/// result must clear to be considered a plausible match for the query at all. Below
/// this, `lookup_episode` reports `NotFound` rather than confidently picking whichever
/// result TMDB happened to rank first - popularity/relevance ranking is not the same
/// thing as an accurate name match.
const NAME_MATCH_THRESHOLD: f64 = 0.35;
#[derive(Debug, Error)]
pub enum TmdbError {
#[error("network error talking to TMDB: {0}")]
@@ -153,10 +162,12 @@ impl TmdbClient {
}
/// Convenience: search for the best-matching show by name, then fetch a specific
/// episode. Picks the first search result - good enough for v1 since TMDB already
/// ranks by relevance; a proper fuzzy-matching disambiguation pass (porting
/// `EpisodeMetrics`) is still on the roadmap for when a search returns multiple
/// plausible shows.
/// episode. Disambiguates a multi-result search by picking whichever result's name
/// is textually closest to `series_query` (see `similarity::best_title_match`)
/// rather than just trusting TMDB's own top-ranked (relevance/popularity) result -
/// so a more popular but differently-named show doesn't get picked over an exact
/// match that's merely ranked lower. If nothing in the results is a plausible
/// match at all, reports `NotFound` instead of guessing.
pub async fn lookup_episode(
&self,
series_query: &str,
@@ -164,14 +175,12 @@ impl TmdbClient {
episode: i32,
) -> Result<EpisodeInfo, TmdbError> {
let results = self.search_tv(series_query).await?;
let best = results
.into_iter()
.next()
let best = best_title_match(series_query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD)
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
let info = self.episode(best.id, season, episode).await?;
Ok(EpisodeInfo {
series_name: best.name,
series_name: best.name.clone(),
episode_title: info.episode_title,
air_date: info.air_date,
})
@@ -283,6 +292,61 @@ mod tests {
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
let mut server = mockito::Server::new_async().await;
// TMDB ranks results by relevance/popularity, not exact-name-match - simulate
// it putting a differently-named (but more popular) show first, with the
// actually-correct exact match ranked second. Before the similarity-based
// disambiguation, `lookup_episode` just took `.next()` and would've picked
// "Chucky" here, which is wrong.
let _search_mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Chuck".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":1,"name":"Chucky"},{"id":2,"name":"Chuck"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/2/season/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Chuck Versus the Intersect","air_date":"2007-09-24"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
assert_eq!(info.series_name, "Chuck");
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
}
#[tokio::test]
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
let mut server = mockito::Server::new_async().await;
// TMDB returned *something*, but nothing in the results is actually close to
// the query - should report NotFound rather than confidently renaming against
// an unrelated show.
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":9,"name":"Totally Unrelated Program"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn surfaces_an_invalid_token_as_an_api_error() {
let mut server = mockito::Server::new_async().await;