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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user