From 71b7a310ce938f072fe20f27eeeadd40685f1ad1 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 14:58:30 +0200 Subject: fix(node): correct TMDB movie matching, per-file overrides, rematch A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v --- .../src/meshbay_node/transport/webrtc_server.py | 103 +++++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) (limited to 'packages/meshbay-node/src/meshbay_node/transport') diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index dd68e18..788a8f1 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -3076,11 +3076,13 @@ class WebRTCPeerSession: video_root/tmdb_config: it replaces what every member sees for a show/movie, node-wide (media_cache is shared, not per-viewer). - Applied to every entry sharing the representative file's - display_title — the same grouping the poster grid itself uses - (§3.4/§V6) — not just the one file the operator happened to be - looking at, so the correction actually sticks regardless of which - episode a future render picks as representative. + For a **show**, applied to every entry sharing the representative + file's display_title — the same grouping the poster grid uses + (§3.4/§V6) — so the correction sticks regardless of which episode a + future render picks as representative. For a **movie** it is applied + to that one file only: guessit gives a whole franchise the same + display_title, and a fan-out there corrected the wrong films (found + live, 2026-08-29). See `_admin_exec_tmdb_override`. Keyed by `file_id`, not `path` — see `_do_media_meta_request`'s docstring for why a folder-level path cannot name one file. @@ -3140,11 +3142,25 @@ class WebRTCPeerSession: if tmdb_client is not None: meta = await self._tmdb_build_meta(tmdb_client, tmdb_id, media_type, {}) await media_cache.set_tmdb_meta(tmdb_id, media_type, meta) - target_title = entry.display_title or entry.name - matched = [e for e in ctx["index"].entries - if e.type == "video" and (e.display_title or e.name) == target_title] + # A show's episodes are many files that legitimately share one match, + # and which episode a render picks as representative rotates — so a + # show override fans out across every entry with the same + # display_title. A *movie* is one file: fanning out by display_title + # there is a bug — guessit gives every + # " - - .mkv" the same display_title, so + # "Fix match" on one entry rewrote the whole franchise (found live, + # 2026-08-29). Each corrected file is also marked as a manual + # override so ops.rematch_video / a rename never wipe it. + is_show = entry.season is not None and entry.episode is not None + if is_show: + target_title = entry.display_title or entry.name + matched = [e for e in ctx["index"].entries + if e.type == "video" and (e.display_title or e.name) == target_title] + else: + matched = [entry] for e in matched: await media_cache.set_file_tmdb(e.id, tmdb_id, media_type) + await media_cache.mark_tmdb_override(e.id) self._audit("tmdb_override", subject) notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "file_id": file_id, @@ -3157,9 +3173,22 @@ class WebRTCPeerSession: async def _tmdb_search(self, tmdb_client, entry, is_show: bool): """ - §3.3's retry ladder: the parsed title first, then a couple of - generic, non-per-title fallbacks — never re-ranking TMDB's own - top result locally (§3.3's last row). + §3.3's retry ladder. TMDB's own top result is still trusted per + query (§3.3's last row — no local re-ranking of *its* list); what + changed is that the ladder now *scores every candidate query* and + keeps the best, instead of returning the first that merely clears + 0.6. + + The bare parsed title is the weakest query: guessit drops a + "Volume 2", strips a real subtitle into `alternative_title`, and + renders a sequel number where TMDB uses a Roman numeral. A wrong + film that happened to score ~0.7 against that weak query — a + same-year making-of documentary, or a franchise entry whose + localized TMDB title *is* the franchise name — used to win outright + before `alternative_title` or the Roman-numeral variant was ever + tried. Found live (2026-08-29): a numbered sequel matched a + same-year documentary; a two-volume film's second part matched the + first; several franchise entries matched one early entry. """ from meshbay_node.indexer import title_parse @@ -3172,21 +3201,57 @@ class WebRTCPeerSession: result, ratio = await tmdb_client.search_tv(naive) return result, ratio + def _release_year(res: dict) -> int | None: + d = str(res.get("release_date") or res.get("first_air_date") or "") + return int(d[:4]) if d[:4].isdigit() else None + parsed = title_parse.parse_movie_filename(entry.name) title = entry.display_title or parsed.display_title or parsed.naive_title + + # Fast path, unchanged in effect: a strong direct hit still returns + # on the first call, so the common case costs exactly one request + # and the new ladder below only engages in the ambiguous 0= 0.6: + if result is not None and ratio >= 0.85: return result, ratio - for candidate in filter(None, [parsed.alt_title, parsed.naive_title, - *title_parse.sequel_variants(title)]): + + best_result, best_score = (result, ratio) if result is not None else (None, 0.0) + + def _apply_year_rescue(res: dict, r: float) -> float: + # Only for a query whose own top hit is weak on its face + # (ratio < 0.6): TMDB already year-filtered the search, so its + # top result landing exactly on the filename's year is a hard + # corroborating signal that the low string ratio is a + # localized/rearranged title, not a wrong film. Never lets year + # equality outrank a genuinely strong textual match elsewhere. + if r < 0.6 and parsed.year and _release_year(res) == parsed.year: + return max(r, 0.6) + return r + + if best_result is not None: + best_score = _apply_year_rescue(best_result, best_score) + + for candidate in filter(None, [parsed.alt_title, + *title_parse.sequel_variants(title), + parsed.naive_title]): if candidate == title: continue - result2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year) - if result2 is not None and ratio2 > ratio: - result, ratio = result2, ratio2 - if ratio >= 0.6: + r2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year) + if r2 is None and parsed.year: + # A year-filtered search that finds nothing: the filename's + # year tag may be an edition/regional year TMDB doesn't + # carry. Retry the same candidate unconstrained before + # dropping it. + r2, ratio2 = await tmdb_client.search_movie(candidate) + if r2 is None: + continue + score2 = _apply_year_rescue(r2, ratio2) + if score2 > best_score: + best_result, best_score = r2, score2 + if best_score >= 0.85: break - return result, ratio + return best_result, best_score @staticmethod async def _tmdb_build_meta(tmdb_client, tmdb_id: str, media_type: str, result: dict) -> dict: -- cgit v1.2.3