From 5d28d0c96cc8489645178b483835489779cb3887 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 15:43:38 +0200 Subject: feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider sequel_variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. When the top result is not a confident textual hit (ratio < 0.6) and a year was requested, a different result of that exact release year is preferred — TMDB already year-filtered the search, so this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v --- packages/meshbay-node/src/meshbay_node/tmdb.py | 58 +++++++++++++++++++------- 1 file changed, 42 insertions(+), 16 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/tmdb.py') diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py index a530660..c9623df 100644 --- a/packages/meshbay-node/src/meshbay_node/tmdb.py +++ b/packages/meshbay-node/src/meshbay_node/tmdb.py @@ -51,24 +51,47 @@ def _normalize(s: str) -> str: return re.sub(r"\s+", " ", s).strip() -def _best_match(query_title: str, results: list[dict], keys: tuple[str, ...]) -> tuple[dict | None, float]: +def _release_year_of(item: dict) -> int | None: + d = str(item.get("release_date") or item.get("first_air_date") or "") + return int(d[:4]) if d[:4].isdigit() else None + + +def _ratio_against(qn: str, item: dict, keys: tuple[str, ...]) -> float: + best = 0.0 + for k in keys: + val = item.get(k) + if val: + best = max(best, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio()) + return best + + +def _best_match( + query_title: str, results: list[dict], keys: tuple[str, ...], + year: int | None = None, +) -> tuple[dict | None, float]: """ Trusts TMDB's own ranking (§3.3's last row — a locally-recomputed - re-rank picked a coincidentally-closer-looking wrong show once): only - the top result is considered. The similarity ratio is returned purely - as a confidence signal for the caller's fallback decision, never used - to pick a different candidate. + re-rank picked a coincidentally-closer-looking wrong show once): the + top result is what's returned. The similarity ratio rides along purely + as a confidence signal for the caller's fallback decision. + + One narrow exception (§10.1/V9): when the top result is *not* a + confident textual hit (ratio < 0.6) and a `year` was requested, a + different result of that **exact** release year is preferred. TMDB + already year-filtered the search, so an entry landing on the requested + year is a hard corroboration, not the fuzzy string re-rank §3.3 warns + against — and this never overrides a confident top hit. """ if not results: return None, 0.0 - top = results[0] qn = _normalize(query_title) - best_ratio = 0.0 - for k in keys: - val = top.get(k) - if val: - best_ratio = max(best_ratio, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio()) - return top, best_ratio + top = results[0] + top_ratio = _ratio_against(qn, top, keys) + if year is not None and top_ratio < 0.6: + for item in results: + if _release_year_of(item) == year: + return item, _ratio_against(qn, item, keys) + return top, top_ratio class TmdbClient: @@ -138,12 +161,15 @@ class TmdbClient: params["year"] = year data = await self._get("search/movie", params) results = (data or {}).get("results", []) - return _best_match(title, results, ("title", "original_title")) + return _best_match(title, results, ("title", "original_title"), year=year) - async def search_tv(self, title: str) -> tuple[dict | None, float]: - data = await self._get("search/tv", {"query": title}) + async def search_tv(self, title: str, year: int | None = None) -> tuple[dict | None, float]: + params = {"query": title} + if year: + params["first_air_date_year"] = year + data = await self._get("search/tv", params) results = (data or {}).get("results", []) - return _best_match(title, results, ("name", "original_name")) + return _best_match(title, results, ("name", "original_name"), year=year) async def search_movie_results(self, title: str) -> list[dict]: """ -- cgit v1.2.3