summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:58 +0200
commit71b7a310ce938f072fe20f27eeeadd40685f1ad1 (patch)
tree75a7d93d58fda0e742dd9b1c94538b532c34464a /packages/meshbay-node/src/meshbay_node/transport
parentb7733e812fadd6007976d262bd1d793572a36ba7 (diff)
downloadmeshbay-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.py103
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: