diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:58:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:58:58 +0200 |
| commit | 71b7a310ce938f072fe20f27eeeadd40685f1ad1 (patch) | |
| tree | 75a7d93d58fda0e742dd9b1c94538b532c34464a /packages/meshbay-node/src/meshbay_node/transport | |
| parent | b7733e812fadd6007976d262bd1d793572a36ba7 (diff) | |
| download | meshbay-71b7a310ce938f072fe20f27eeeadd40685f1ad1.tar.gz | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 103 |
1 files changed, 84 insertions, 19 deletions
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 + # "<franchise> - <year> - <subtitle>.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<ratio<0.85 + # zone where every one of the live bugs lived. result, ratio = await tmdb_client.search_movie(title, parsed.year) - if result is not None and ratio >= 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: |