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 --- .../src/meshbay_node/indexer/title_parse.py | 100 +++++++++++++++--- packages/meshbay-node/src/meshbay_node/tmdb.py | 58 ++++++++--- .../src/meshbay_node/transport/webrtc_server.py | 116 +++++++++++---------- packages/meshbay-node/tests/test_title_parse.py | 44 ++++++++ packages/meshbay-node/tests/test_tmdb.py | 53 ++++++++++ .../meshbay-node/tests/test_tmdb_search_ladder.py | 58 ++++++++++- 6 files changed, 340 insertions(+), 89 deletions(-) (limited to 'packages') diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py index 24c23bc..018746b 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py @@ -52,14 +52,8 @@ _SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE) # some other word that merely starts with "s" followed by digits. _SEASON_ABBREV_RE = re.compile(r"^s(\d{1,2})$", re.IGNORECASE) -_ROMAN_NUMERALS = { - 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", - 7: "VII", 8: "VIII", 9: "IX", 10: "X", -} - - def _roman_to_int(s: str) -> int | None: - values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100} + values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000} s = s.lower() if not s or any(c not in values for c in s): return None @@ -72,6 +66,49 @@ def _roman_to_int(s: str) -> int | None: return total or None +def _int_to_roman(n: int) -> str | None: + if not 1 <= n <= 39: + return None + out = [] + for val, sym in ((10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")): + while n >= val: + out.append(sym) + n -= val + return "".join(out) + + +# Spelled-out sequel indices, English + French, plus a few ordinals. +_NUMBER_WORDS: dict[str, int] = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, + "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, + "first": 1, "second": 2, "third": 3, + "un": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "sept": 7, + "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, + "premier": 1, "première": 1, "deuxième": 2, "seconde": 2, "troisième": 3, +} +_YEAR_RE = re.compile(r"(? int | None: + """First 19xx/20xx in `text`, or None — used to lift a year off a show + folder name ("Some.Show.2022.S01") for the search fallback (§10.1/V8).""" + m = _YEAR_RE.search(text or "") + return int(m.group(0)) if m else None + + +def clean_query(s: str) -> str: + """ + Punctuation → spaces for a TMDB query, *without* the extension / + parenthesized-year stripping `naive_title` does. `naive_title` assumes + a real filename; a show's `display_title` is a folder basename + ("Some.Show.Name" — `rsplit('.', 1)` would eat ".Name"), so it needs a + gentler normaliser (§10.1/V8). + """ + s = re.sub(r"[._-]+", " ", s or "") + s = _strip_editions(s) + return re.sub(r"\s+", " ", s).strip() + + def _strip_editions(title: str) -> str: return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip() @@ -90,21 +127,50 @@ def naive_title(filename: str) -> str: return re.sub(r"\s+", " ", stem).strip() +_PART_KEYWORDS = r"part|chapter|volume|vol|partie|chapitre|volet|livre|book|episode" +_TRAILING_INDEX_RE = re.compile( + r"^(?P.+?)(?:\s+(?:" + _PART_KEYWORDS + r"))?" + r"\s+(?P\d{1,2}|[ivxlcdm]{1,6}|" + "|".join(_NUMBER_WORDS) + r")$", + re.IGNORECASE, +) + + +def _index_value(tok: str) -> int | None: + tok = tok.strip().lower() + if tok.isdigit(): + v = int(tok) + return v if 1 <= v <= 39 else None + if tok in _NUMBER_WORDS: + return _NUMBER_WORDS[tok] + return _roman_to_int(tok) + + def sequel_variants(title: str) -> list[str]: """ - A trailing sequel digit sometimes has no equivalent in the real TMDB - title, or the real title uses a Roman numeral instead (§3.3 row 4). - Returns extra candidates to try — empty if `title` has no trailing digit. + A trailing sequel index often has no exact match in the real TMDB + title: the file has a digit where TMDB uses a Roman numeral (or the + reverse), spells the number out, or wraps it as "Part N" / "Chapitre N" + (§3.3 row 4, §10.1/V10). Returns extra candidate titles to try — the + base alone, and the index re-rendered as digit and as Roman numeral. + Empty when `title` carries no recognisable trailing index. """ - m = re.match(r"^(.*\S)\s+([2-9])$", title) + m = _TRAILING_INDEX_RE.match(title.strip()) if not m: return [] - base, digit = m.group(1), int(m.group(2)) - variants = [base] - roman = _ROMAN_NUMERALS.get(digit) - if roman: - variants.append(f"{base} {roman}") - return variants + base = m.group("base").strip() + if len(base) < 2: + return [] + n = _index_value(m.group("num")) + if n is None: + return [] + tok = m.group("num").lower() + out = [base] + roman = _int_to_roman(n) + if roman and roman.lower() != tok: + out.append(f"{base} {roman}") + if str(n) != tok: + out.append(f"{base} {n}") + return [v for v in dict.fromkeys(out) if v != title] def season_from_folder_name(name: str) -> int | None: 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]: """ 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 788a8f1..c75819e 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -3173,80 +3173,88 @@ class WebRTCPeerSession: async def _tmdb_search(self, tmdb_client, entry, is_show: bool): """ - §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. + §3.3's retry ladder — same shape for movies and shows (§10.1/V8). + TMDB's own top result is still trusted per query (§3.3's last row — + no local re-ranking of *its* list); what the ladder adds is that it + *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. + "Volume 2", strips a real subtitle into `alternative_title`, renders + a sequel index where TMDB spells it differently, and a show's folder + name can carry a year or release-group noise. A wrong entry that + scored ~0.7 against that weak query — a same-year making-of + documentary, a franchise entry whose localized TMDB title *is* the + franchise name, a season-specific promo entry standing in for a + whole show — used to win outright before a stronger candidate was + ever tried. Found live (2026-08-29). """ from meshbay_node.indexer import title_parse if is_show: title = entry.display_title or title_parse.naive_title(entry.name) - result, ratio = await tmdb_client.search_tv(title) - if result is None or ratio < 0.6: - naive = title_parse.naive_title(entry.name) - if naive != title: - 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 + name_naive = title_parse.naive_title(entry.name) + year = title_parse.year_in(title) or title_parse.year_in(entry.name) + extra = [c for c in (name_naive, title_parse.clean_query(title)) + if c and c != title] + return await self._tmdb_ladder( + tmdb_client.search_tv, title, extra, year, strong_extra=False) parsed = title_parse.parse_movie_filename(entry.name) title = entry.display_title or parsed.display_title or parsed.naive_title + strong = [c for c in (parsed.alt_title, *title_parse.sequel_variants(title)) if c] + extra = [c for c in (*strong, parsed.naive_title) if c and c != title] + return await self._tmdb_ladder( + tmdb_client.search_movie, title, extra, parsed.year, + strong_extra=bool(strong)) - # 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.85: - return result, ratio - - best_result, best_score = (result, ratio) if result is not None else (None, 0.0) + @staticmethod + async def _tmdb_ladder(search_fn, primary: str, extra: list[str], + year: int | None, strong_extra: bool): + """ + `search_fn(query, year) -> (result|None, ratio)`. Try `primary`, + return at once on a confident hit (ratio >= 0.85 — the common case, + one request). Otherwise score each `extra` candidate and keep the + best. `strong_extra` says whether `extra` contains anything more + specific than a punctuation-normalised restatement of `primary` + (an alternative_title, a sequel variant); when it does not and the + primary hit is already decent, the remaining calls are skipped + (§10.1/V11 — they almost never win and cost a round trip each). + """ + def _year_of(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 - 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: + def _rescue(res: dict, r: float) -> float: + # A sub-0.6 hit whose result lands on the exact requested year: + # TMDB already year-filtered the search, so this is a hard + # corroboration that the low ratio is a localised/rearranged + # title, not a wrong entry. Never overrides a confident hit. + if r < 0.6 and year and _year_of(res) == year: return max(r, 0.6) return r + result, ratio = await search_fn(primary, year) + if result is not None and ratio >= 0.85: + return result, ratio + best_result, best_score = (result, ratio) if result is not None else (None, 0.0) if best_result is not None: - best_score = _apply_year_rescue(best_result, best_score) + best_score = _rescue(best_result, best_score) + if best_score >= 0.6 and not strong_extra: + return best_result, best_score - for candidate in filter(None, [parsed.alt_title, - *title_parse.sequel_variants(title), - parsed.naive_title]): - if candidate == title: + for candidate in extra: + if candidate == primary: continue - 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) + r2, ratio2 = await search_fn(candidate, year) + if r2 is None and year: + # A year-filtered search that finds nothing: the year tag + # may be an edition/regional year TMDB doesn't carry. Retry + # the candidate unconstrained before dropping it. + r2, ratio2 = await search_fn(candidate, None) if r2 is None: continue - score2 = _apply_year_rescue(r2, ratio2) + score2 = _rescue(r2, ratio2) if score2 > best_score: best_result, best_score = r2, score2 if best_score >= 0.85: diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py index 03fb588..ac4d434 100644 --- a/packages/meshbay-node/tests/test_title_parse.py +++ b/packages/meshbay-node/tests/test_title_parse.py @@ -6,12 +6,14 @@ is a manual acceptance step (§11), not something this repo's corpus holds. from meshbay_node.indexer.title_parse import ( ParsedName, + clean_query, leading_episode_number, naive_title, parse_episode_filename, parse_movie_filename, season_from_folder_name, sequel_variants, + year_in, ) @@ -69,6 +71,48 @@ def test_sequel_variants_empty_when_no_trailing_digit(): assert sequel_variants("Some Movie") == [] +# ── §10.1/V10: wider sequel-index handling ────────────────────────────────── + +def test_sequel_variants_roman_numeral_offers_the_digit_form(): + v = sequel_variants("Old Frontier III") + assert "Old Frontier" in v + assert "Old Frontier 3" in v + + +def test_sequel_variants_strips_a_part_keyword_wrapper(): + v = sequel_variants("Some Saga Part 2") + assert "Some Saga" in v + assert "Some Saga II" in v + + +def test_sequel_variants_reads_a_spelled_out_index(): + v = sequel_variants("Story Chapter Three") + assert "Story" in v + assert "Story 3" in v and "Story III" in v + + +def test_sequel_variants_ignores_a_trailing_word_that_is_not_an_index(): + assert sequel_variants("The Dark Knight") == [] + assert sequel_variants("In Bruges") == [] + + +def test_sequel_variants_ignores_a_four_digit_year_suffix(): + assert sequel_variants("Blade Runner 2049") == [] + + +def test_year_in_lifts_a_year_from_a_show_folder_name(): + assert year_in("Some.Show.2022.S01") == 2022 + assert year_in("Some Show") is None + assert year_in("Episode 100 of 2010") == 2010 + + +def test_clean_query_despaces_a_folder_name_without_eating_the_last_word(): + # naive_title would rsplit on the last dot and drop ".Name" + assert clean_query("Some.Show.Name") == "Some Show Name" + assert clean_query("Some.Show.Name.S01") == "Some Show Name S01" + assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids + + # ── bug 2026-08-29: guessit peels "Volume N" off the title ───────────────── def test_movie_volume_number_is_folded_back_into_the_title(): diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py index fcbc2a2..ab452ce 100644 --- a/packages/meshbay-node/tests/test_tmdb.py +++ b/packages/meshbay-node/tests/test_tmdb.py @@ -40,6 +40,59 @@ async def test_search_movie_returns_top_result_and_confidence(): await client.close() +# ── §10.1/V9: year-exact preference, only when the top hit is weak ────────── + +@pytest.mark.asyncio +async def test_year_exact_result_wins_when_the_top_hit_is_low_confidence(): + # results[0] is TMDB's popularity #1 but a poor textual match for the + # query; results[2] is the exact requested year. + body = {"results": [ + {"id": 1, "title": "Franchise vs. The Doctor", "release_date": "1962-10-05"}, + {"id": 2, "title": "Franchise: Goldfinger", "release_date": "1964-09-17"}, + {"id": 3, "title": "Second Errand", "release_date": "2002-11-20"}, + ]} + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/movie": body})), + ) + result, _ = await client.search_movie("The Franchise", 2002) + + assert result["id"] == 3 + await client.close() + + +@pytest.mark.asyncio +async def test_year_is_ignored_when_the_top_hit_is_already_confident(): + body = {"results": [ + {"id": 1, "title": "The Franchise", "release_date": "1999-01-01"}, + {"id": 2, "title": "Unrelated", "release_date": "2002-01-01"}, + ]} + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/movie": body})), + ) + result, ratio = await client.search_movie("The Franchise", 2002) + + assert result["id"] == 1 and ratio > 0.9 + await client.close() + + +@pytest.mark.asyncio +async def test_no_year_match_leaves_the_top_result_in_place(): + body = {"results": [ + {"id": 1, "title": "Something Else Entirely", "release_date": "1990-01-01"}, + {"id": 2, "title": "Also Not It", "release_date": "1991-01-01"}, + ]} + client = TmdbClient( + roster=FakeRoster(), + transport=httpx.MockTransport(_handler({"search/movie": body})), + ) + result, _ = await client.search_movie("The Franchise", 2002) + + assert result["id"] == 1 + await client.close() + + @pytest.mark.asyncio async def test_search_tv_returns_top_result(): body = {"results": [{"id": 7, "name": "Some Show"}]} diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py index 49b71bd..9865c77 100644 --- a/packages/meshbay-node/tests/test_tmdb_search_ladder.py +++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py @@ -61,8 +61,12 @@ class LadderTmdb: return self._table[(title, None)] return None, 0.0 - async def search_tv(self, title): - self.calls.append(("tv", title)) + async def search_tv(self, title, year=None): + self.calls.append((title, year)) + if (title, year) in self._table: + return self._table[(title, year)] + if (title, None) in self._table: + return self._table[(title, None)] return None, 0.0 @@ -80,6 +84,17 @@ async def _run(name: str, table: dict): return result, ratio, client +async def _run_show(display_title: str, name: str, table: dict): + entry = IndexEntry( + id="s", name=name, path="Show/S1", size=1, type="video", added_at=0, + display_title=display_title, season=1, episode=1, + ) + client = LadderTmdb(table) + session = WebRTCPeerSession.__new__(WebRTCPeerSession) + result, ratio = await session._tmdb_search(client, entry, is_show=True) + return result, ratio, client + + # ── a two-part film: the parts must resolve to different entries ──────────── async def test_second_volume_resolves_to_the_second_volume(): @@ -191,3 +206,42 @@ async def test_strong_direct_match_costs_a_single_request(): _, _, client = await _run("A.Quiet.Film.2010.mkv", {("A Quiet Film", 2010): (STANDALONE, 1.0)}) assert client.calls == [("A Quiet Film", 2010)] + + +# ── §10.1/V11: a decent primary hit with nothing more specific to try ────── + +async def test_decent_primary_with_no_stronger_candidate_costs_one_call(): + result, _, client = await _run( + "A.Quiet.Film.2010.mkv", + {("A Quiet Film", 2010): ({"id": "77", "title": "A Quiet Movie", + "release_date": "2010-01-01"}, 0.70)}, + ) + assert result["id"] == "77" + assert client.calls == [("A Quiet Film", 2010)], ( + "no alt title, no sequel index → the punctuation-restatement fallback " + "is not worth a second request once the primary hit is decent") + + +# ── §10.1/V8: the show branch uses the same scored ladder ───────────────── + +async def test_show_scored_ladder_beats_a_weak_primary_hit(): + result, _, _ = await _run_show( + "Some.Show.Name.S01", "s01e01.mkv", + { + ("Some.Show.Name.S01", None): ({"id": "10", "name": "Promo Special", + "first_air_date": "2019-01-01"}, 0.55), + ("Some Show Name S01", None): ({"id": "20", "name": "Some Show Name", + "first_air_date": "2017-01-01"}, 0.95), + }, + ) + assert result["id"] == "20" + + +async def test_show_year_in_folder_name_rescues_a_weak_hit(): + result, ratio, _ = await _run_show( + "Some.Show.2019.S01", "s01e01.mkv", + {("Some.Show.2019.S01", 2019): ({"id": "30", "name": "Some Show", + "first_air_date": "2019-05-01"}, 0.30)}, + ) + assert result["id"] == "30" + assert ratio >= 0.6 -- cgit v1.2.3 From bc40ab2c4486cca4ae63e33976151b5d7f856a84 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 15:46:42 +0200 Subject: feat(hub): V12 — merge movies by TMDB id in the poster grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v --- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/src/meshbay_hub/static/style.css | 1 + .../src/meshbay_hub/static/video-app.js | 83 +++++++++++++++++----- 12 files changed, 77 insertions(+), 17 deletions(-) (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index d3bf11a..55cf62e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -185,6 +185,7 @@ export default { 'video.search_apply_hint_movie': 'Gilt nur für diese Datei.', 'video.source_file': 'Datei: {name}', 'video.no_match': 'Keine sichere TMDB-Übereinstimmung — Dateiname wird angezeigt.', + 'video.versions': '{n} Versionen', // Musik 'music.mode_grid': 'Alben', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 5c60f9e..4b9a4ee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -183,6 +183,7 @@ export default { 'video.search_apply_hint_movie': 'Applies to this file only.', 'video.source_file': 'File: {name}', 'video.no_match': 'No confident TMDB match — showing the filename.', + 'video.versions': '{n} versions', // Music 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 1e3aba2..a65a619 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -183,6 +183,7 @@ export default { 'video.search_apply_hint_movie': 'Se aplica solo a este archivo.', 'video.source_file': 'Archivo: {name}', 'video.no_match': 'Sin coincidencia fiable en TMDB — se muestra el nombre del archivo.', + 'video.versions': '{n} versiones', // Música 'music.mode_grid': 'Álbumes', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c07b57c..f026b3e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -184,6 +184,7 @@ export default { 'video.search_apply_hint_movie': 'Ne s’applique qu’à ce fichier.', 'video.source_file': 'Fichier : {name}', 'video.no_match': 'Aucune correspondance TMDB fiable — nom de fichier affiché.', + 'video.versions': '{n} versions', // Musique 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 4b68951..71ee6ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -184,6 +184,7 @@ export default { 'video.search_apply_hint_movie': 'Si applica solo a questo file.', 'video.source_file': 'File: {name}', 'video.no_match': 'Nessuna corrispondenza TMDB affidabile — mostrato il nome del file.', + 'video.versions': '{n} versioni', // Musica 'music.mode_grid': 'Album', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 471933d..aa22837 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -181,6 +181,7 @@ export default { 'video.search_apply_hint_movie': 'このファイルにのみ適用されます。', 'video.source_file': 'ファイル: {name}', 'video.no_match': '確実なTMDB一致なし — ファイル名を表示しています。', + 'video.versions': '{n} 個のバージョン', // 音楽 'music.mode_grid': 'アルバム', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index a24cb42..9e5f8fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -185,6 +185,7 @@ export default { 'video.search_apply_hint_movie': 'Alleen van toepassing op dit bestand.', 'video.source_file': 'Bestand: {name}', 'video.no_match': 'Geen betrouwbare TMDB-match — bestandsnaam wordt getoond.', + 'video.versions': '{n} versies', // Muziek 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 58dc7b5..d8c4846 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -192,6 +192,7 @@ export default { 'video.search_apply_hint_movie': 'Dotyczy tylko tego pliku.', 'video.source_file': 'Plik: {name}', 'video.no_match': 'Brak pewnego dopasowania TMDB — pokazano nazwę pliku.', + 'video.versions': 'Wersje: {n}', // Muzyka 'music.mode_grid': 'Albumy', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 00d9051..53390be 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -185,6 +185,7 @@ export default { 'video.search_apply_hint_movie': 'Aplica-se somente a este arquivo.', 'video.source_file': 'Arquivo: {name}', 'video.no_match': 'Sem correspondência confiável no TMDB — exibindo o nome do arquivo.', + 'video.versions': '{n} versões', // Música 'music.mode_grid': 'Álbuns', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index e5590d8..d845f6d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -178,6 +178,7 @@ export default { 'video.search_apply_hint_movie': '仅适用于此文件。', 'video.source_file': '文件:{name}', 'video.no_match': '没有可靠的 TMDB 匹配 — 显示文件名。', + 'video.versions': '{n} 个版本', // 音乐 'music.mode_grid': '专辑', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index a7e8e3d..a6934fc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2929,6 +2929,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } margin-bottom: 4px; } .video-card-unmatched { outline: 1px dashed var(--warn, #d98324); outline-offset: -1px; } +.video-version-list { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; } .video-card-flag { display: inline-block; margin-left: 4px; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index f73def0..48709f9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -478,7 +478,7 @@ function TmdbSearchOverlay({ } function VideoDetailModal({ - title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin, + title, meta, repEntry, show, files, transportRef, gekRef, onClose, onPlay, isNodeAdmin, }) { const confident = Boolean(meta && meta.confidence && meta.tmdb_id); const [searching, setSearching] = useState(false); @@ -543,12 +543,27 @@ function VideoDetailModal({ <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} onSelect=${setSelectedSeason} /> `} - ${!show && html` + ${!show && (!files || files.length <= 1) && html` `} + ${!show && files && files.length > 1 && html` +
+ ${[...files] + .sort((a, b) => (b.height || 0) - (a.height || 0) || (b.size || 0) - (a.size || 0)) + .map((f) => html` + + `)} +
+ `} ${show && html`
${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons) @@ -643,28 +658,62 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable }); }, [shows, metaByGroup]); - const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show }); + // The movie counterpart of mergedShows (§10.1/V12): two files that TMDB + // resolves to the same id — the usual case being one film present at two + // resolutions, or the same rip in two folders — collapse to one card + // whose detail modal lists the versions. Same keying discipline as + // mergedShows: the first constituent's id is stable and unique, so an + // unmerged movie keeps the exact key its card already had and a merge + // updates props instead of remounting (which would throw away an + // already-resolved poster). + const mergedMovies = useMemo(() => { + const byTmdbId = new Map(); + const standalone = []; + for (const e of movies) { + const meta = metaByGroup[`movie:${e.id}`]; + const tmdbId = meta && meta.confidence && meta.tmdb_id; + if (tmdbId) { + if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []); + byTmdbId.get(tmdbId).push(e); + } else { + standalone.push([e]); + } + } + return [...byTmdbId.values(), ...standalone].map((files) => ({ + key: `movie:${files[0].id}`, + title: files[0].display_title || files[0].name, + files, + })); + }, [movies, metaByGroup]); + + const openDetail = (title, repEntry, show, files) => setDetail({ title, repEntry, show, files }); const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef; const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef; const detailMeta = useMediaMeta(detailTRef, detail ? detail.repEntry.id : null, !!detail); return html`
- ${movies.map((e) => html` - <${LazyTile} key=${e.id}> - <${PosterCard} title=${e.display_title || e.name} - subtitle=${formatDuration(e.duration)} repEntry=${e} - groupKey=${`movie:${e.id}`} + ${mergedMovies.map((m) => { + const repEntry = m.files.find((e) => e.thumb_hash) || m.files[0]; + const subtitle = m.files.length > 1 + ? t('video.versions', { n: m.files.length }) + : formatDuration(repEntry.duration); + // With TMDB off and only one file there is nothing the detail modal + // would add for a movie (no overview, no season list) — straight to + // the player. More than one file always needs the version picker. + const straightToPlayer = !tmdbEnabled && m.files.length === 1; + return html` + <${LazyTile} key=${m.key}> + <${PosterCard} title=${m.title} + subtitle=${subtitle} repEntry=${repEntry} + groupKey=${`movie:${m.files[0].id}`} + onMetaResolved=${handleMetaResolved} transportRef=${transportRef} gekRef=${gekRef} - onOpen=${() => (tmdbEnabled - // With TMDB off there is nothing the detail modal would show - // for a movie (no overview, no season list to pick from, - // unlike a show) — so it would just be an extra click in - // front of a Play button. Straight to the player instead. - ? openDetail(e.display_title || e.name, e, null) - : onPreview(e))} /> + onOpen=${() => (straightToPlayer + ? onPreview(repEntry) + : openDetail(m.title, repEntry, null, m.files))} /> - `)} + `; })} ${mergedShows.map((s) => { // Prefer an episode that actually has a thumbnail over blindly // episodes[0]: if that specific file's enrichment hasn't produced @@ -696,7 +745,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
${detail && html` <${VideoDetailModal} title=${detail.title} meta=${detailMeta} - repEntry=${detail.repEntry} show=${detail.show} + repEntry=${detail.repEntry} show=${detail.show} files=${detail.files} transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin} onClose=${() => setDetail(null)} onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> -- cgit v1.2.3 From 4d167e9f958d26c1db920274974ae446426928e3 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 15:57:06 +0200 Subject: feat: V13 — per-card "re-match this one file" (MNP 0.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v --- docs/mediacenter.md | 16 +-- .../meshbay-common/src/meshbay_common/__init__.py | 6 +- .../meshbay-common/src/meshbay_common/adminop.py | 5 + .../meshbay-common/src/meshbay_common/protocol.py | 2 + .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/src/meshbay_hub/static/style.css | 1 + .../src/meshbay_hub/static/transport.js | 23 +++- .../src/meshbay_hub/static/video-app.js | 25 +++- .../meshbay-node/src/meshbay_node/media_cache.py | 12 ++ .../src/meshbay_node/transport/webrtc_server.py | 49 +++++++ packages/meshbay-node/tests/test_media_cache.py | 13 ++ .../meshbay-node/tests/test_tmdb_rematch_policy.py | 147 +++++++++++++++++++++ 21 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 packages/meshbay-node/tests/test_tmdb_rematch_policy.py (limited to 'packages') diff --git a/docs/mediacenter.md b/docs/mediacenter.md index 3f70112..5c27939 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -759,16 +759,16 @@ movie "Fix match" applied to the one file, not the whole `display_title` group; shields manual corrections); the movie detail modal shows the source filename + resolved TMDB id, and an unmatched poster gets a badge. -**Still open, noted so they are not lost (all found while fixing the above):** +**V8–V13 — the follow-ups, now done** (branch `feat/videos-matching-v8-v13`): -| # | Item | Severity | +| # | Item | Done | |---|---|---| -| V8 | The **TV/show branch** of `_tmdb_search` still returns the first candidate over 0.6 — the same shape just fixed for movies. §5.7's own wrong show-level match was this class; its parade was Fix match + season tabs, not the search. Give it the scored ladder too | medium | -| V9 | `_best_match` still trusts `results[0]` per query unconditionally. The movie fix works around it by trying more queries; if no competing query beats a wrong-but-high-ratio `results[0]`, it still wins. The originally-planned year-aware `_best_match` (mediacenter "C") would harden this — deferred: apply the year signal only when the #1 hit is already low-confidence | medium | -| V10 | `sequel_variants` is narrow: one trailing digit 2–9, arabic→roman only. No "Part One", "Chapitre 2", "10", roman→arabic | low | -| V11 | Extra TMDB calls in the 0.6–0.85 band: a film that used to match in one request now makes 2–4 before settling (then cached). Bounded, but real | low | -| V12 | Movies are not merged in the poster grid — a film present in 1080p + 720p shows as two cards (the `tmdb_id` merge in §V6 is show-only) | cosmetic | -| V13 | A per-card "re-match this one file" button in the SPA — `ops.rematch_video` is group-wide and CLI/loopback only; a per-card control needs a new signed op or MNP message. Fix match already covers targeted correction | low | +| ~~V8~~ | The TV/show branch of `_tmdb_search` used the old "first over 0.6 wins" shape | Both branches share one `_tmdb_ladder` — score every candidate, keep the best, fast-path a confident primary hit. `title_parse.year_in` lifts a year off a show folder name; `title_parse.clean_query` de-dots a folder-derived title without `naive_title`'s extension-strip | +| ~~V9~~ | `_best_match` trusted `results[0]` per query unconditionally | Optional `year`: when the top result is not a confident textual hit (< 0.6) and a year was requested, a different result of that exact release year is preferred. A confident top hit is never overridden | +| ~~V10~~ | `sequel_variants` narrow (trailing digit 2–9, arabic→roman only) | Widened: digit↔Roman both ways, spelled-out indices (one/two…, un/deux…, ordinals), a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year | +| ~~V11~~ | Extra TMDB calls in the 0.6–0.85 band | When the primary hit is decent (≥ 0.6) and there is nothing more specific to try (no `alternative_title`, no sequel variant), the ladder returns without the extra requests | +| ~~V12~~ | Movies not merged in the poster grid | `mergedMovies` groups by resolved `tmdb_id`, mirroring `mergedShows`; the detail modal lists the versions (resolution · duration · size), each a Play button. New `video.versions` key ×10. Edge: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own | +| ~~V13~~ | Per-card "re-match this one file" button | `OP_TMDB_REMATCH` / `MNP.TMDB_REMATCH` (MNP 0.13, additive) → `media_cache.drop_tmdb_match` (forgets the match *and* the override marker). Signed like `tmdb_override`. Button next to "Fix match" in the detail modal; `transport.rematchTmdbMatch`; `video.rematch_one` key ×10 | ## 11. Acceptance before shipping diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index d40f647..4612157 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -52,5 +52,9 @@ __version__ = "0.8.0" # 0.12: added `link_preview_req`/`link_preview_resp` — the node unfurls a URL # pasted in chat into an OpenGraph card. Additive: an older node logs "unknown # type" and the client just shows the bare link, as it always did. -MNP_VERSION = "0.12" +# 0.13: added `tmdb_rematch`/`tmdb_rematch_ack` — an operator dropping one +# file's cached TMDB match so it re-resolves with the current matcher +# (§10.1/V13). Additive: an older node logs "unknown type", the client's +# button just does nothing. +MNP_VERSION = "0.13" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index f96dd57..762a2d1 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -83,6 +83,11 @@ OP_VIDEO_ROOT = "video_root" # (media_cache is shared, not per-viewer), so an unsigned override would let # any member vandalize another show's metadata. OP_TMDB_OVERRIDE = "tmdb_override" +# "Re-match this one file" — drop its cached match (and any override marker) +# so the next media_meta_req re-resolves with the current matcher. Signed for +# the same reason as tmdb_override: media_cache is shared node-wide, so an +# unsigned reset would let any member wipe another's correction. +OP_TMDB_REMATCH = "tmdb_rematch" # MusicBrainz contact is now the owner's hub email (musicbrainz.py) — no # signed config op needed. Only the per-group toggle remains. # Whether the node calls MusicBrainz *at all* for this group — per-group from diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 651fe2d..a3fb4de 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -109,6 +109,8 @@ class MNP: TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster) TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match TMDB_OVERRIDE_ACK = "tmdb_override_ack" + TMDB_REMATCH = "tmdb_rematch" # operator → node: drop one file's match + TMDB_REMATCH_ACK = "tmdb_rematch_ack" # Music app (docs/musicbay.md). Contact is derived from the owner's hub # email at login — no config/ack pair needed. Only the per-group toggle # remains. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 55cf62e..c4ee388 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -186,6 +186,7 @@ export default { 'video.source_file': 'Datei: {name}', 'video.no_match': 'Keine sichere TMDB-Übereinstimmung — Dateiname wird angezeigt.', 'video.versions': '{n} Versionen', + 'video.rematch_one': 'Neu zuordnen', // Musik 'music.mode_grid': 'Alben', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4b9a4ee..16ae3ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -184,6 +184,7 @@ export default { 'video.source_file': 'File: {name}', 'video.no_match': 'No confident TMDB match — showing the filename.', 'video.versions': '{n} versions', + 'video.rematch_one': 'Re-match', // Music 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index a65a619..88b222d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -184,6 +184,7 @@ export default { 'video.source_file': 'Archivo: {name}', 'video.no_match': 'Sin coincidencia fiable en TMDB — se muestra el nombre del archivo.', 'video.versions': '{n} versiones', + 'video.rematch_one': 'Volver a asociar', // Música 'music.mode_grid': 'Álbumes', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index f026b3e..b08cf28 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -185,6 +185,7 @@ export default { 'video.source_file': 'Fichier : {name}', 'video.no_match': 'Aucune correspondance TMDB fiable — nom de fichier affiché.', 'video.versions': '{n} versions', + 'video.rematch_one': 'Relancer la recherche', // Musique 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 71ee6ab..f1e8467 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -185,6 +185,7 @@ export default { 'video.source_file': 'File: {name}', 'video.no_match': 'Nessuna corrispondenza TMDB affidabile — mostrato il nome del file.', 'video.versions': '{n} versioni', + 'video.rematch_one': 'Riassocia', // Musica 'music.mode_grid': 'Album', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index aa22837..95cc040 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -182,6 +182,7 @@ export default { 'video.source_file': 'ファイル: {name}', 'video.no_match': '確実なTMDB一致なし — ファイル名を表示しています。', 'video.versions': '{n} 個のバージョン', + 'video.rematch_one': '再マッチ', // 音楽 'music.mode_grid': 'アルバム', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 9e5f8fb..076aa15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -186,6 +186,7 @@ export default { 'video.source_file': 'Bestand: {name}', 'video.no_match': 'Geen betrouwbare TMDB-match — bestandsnaam wordt getoond.', 'video.versions': '{n} versies', + 'video.rematch_one': 'Opnieuw koppelen', // Muziek 'music.mode_grid': 'Albums', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index d8c4846..00ef59e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -193,6 +193,7 @@ export default { 'video.source_file': 'Plik: {name}', 'video.no_match': 'Brak pewnego dopasowania TMDB — pokazano nazwę pliku.', 'video.versions': 'Wersje: {n}', + 'video.rematch_one': 'Dopasuj ponownie', // Muzyka 'music.mode_grid': 'Albumy', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 53390be..7d9173e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -186,6 +186,7 @@ export default { 'video.source_file': 'Arquivo: {name}', 'video.no_match': 'Sem correspondência confiável no TMDB — exibindo o nome do arquivo.', 'video.versions': '{n} versões', + 'video.rematch_one': 'Combinar de novo', // Música 'music.mode_grid': 'Álbuns', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index d845f6d..74d729e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -179,6 +179,7 @@ export default { 'video.source_file': '文件:{name}', 'video.no_match': '没有可靠的 TMDB 匹配 — 显示文件名。', 'video.versions': '{n} 个版本', + 'video.rematch_one': '重新匹配', // 音乐 'music.mode_grid': '专辑', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index a6934fc..8e714f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2930,6 +2930,7 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .video-card-unmatched { outline: 1px dashed var(--warn, #d98324); outline-offset: -1px; } .video-version-list { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; } +.video-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } .video-card-flag { display: inline-block; margin-left: 4px; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index d6a28d9..5a6e36b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -73,7 +73,7 @@ function _aborted() { // list is what lets a response two steps later be tied back to the right // one. const ADMIN_OP_TYPES = new Set([ - 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', + 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', @@ -915,6 +915,22 @@ class MeshBayTransport { return msg; } + /** + * Drop one file's cached TMDB match so it re-resolves with the node's + * current matcher (§10.1/V13) — the one-click alternative to the full + * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. + */ + async rematchTmdbMatch(fileId, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_rematch', v: '0.7', file_id: fileId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); + } + return msg; + } + /** * Set/clear a custom TMDB API token, and/or set the language TMDB is * queried in (e.g. "fr-FR") — one for the whole node, since both are one @@ -2032,6 +2048,11 @@ class MeshBayTransport { fileId: msg.file_id || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', }); } + // Same shape: the operator dropped one file's match to have it + // re-resolved (§10.1/V13). No tmdbId — the node re-derives it. + if (msg.type === 'tmdb_rematch_ack' && this._onTmdbOverride) { + this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: '', mediaType: '' }); + } // Same shape: the operator changed which folder is the Videos app's // entry point for this group. diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 48709f9..6d13e27 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -482,8 +482,22 @@ function VideoDetailModal({ }) { const confident = Boolean(meta && meta.confidence && meta.tmdb_id); const [searching, setSearching] = useState(false); + const [rematching, setRematching] = useState(false); const mediaType = show ? 'tv' : 'movie'; + // §10.1/V13: drop this file's cached match on the node and let it + // re-resolve with the current matcher — the one-click alternative to the + // full search-and-pick flow above. + const doRematch = useCallback(async () => { + if (rematching) return; + setRematching(true); + try { + await transportRef.current.rematchTmdbMatch(repEntry.id, buildSignFn(transportRef)); + bumpMediaMetaGeneration(); + } catch { /* leave the current match in place */ } + setRematching(false); + }, [rematching, repEntry, transportRef]); + // Reset whenever a different file/show is opened in this same modal // instance — repEntry/show change identity, selectedSeason must not // silently keep pointing at whatever the previous show's season 4 was. @@ -535,9 +549,14 @@ function VideoDetailModal({ `} `} ${isNodeAdmin && html` - +
+ + +
`} ${showMultiSeason && html` <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason} diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 4600a09..8233270 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -175,6 +175,18 @@ class MediaCache: "(SELECT file_id FROM tmdb_override)", (file_id,)) await self._db.commit() + async def drop_tmdb_match(self, file_id: str) -> None: + """ + Full per-file reset: forget the match *and* any manual override + marker, so the next `media_meta_req` re-resolves from scratch with + the current matcher. This is the explicit operator "re-match this + one" action (§10.1/V13) — deliberately stronger than + `clear_file_tmdb`, which spares an override. + """ + await self._db.execute("DELETE FROM file_tmdb WHERE file_id = ?", (file_id,)) + await self._db.execute("DELETE FROM tmdb_override WHERE file_id = ?", (file_id,)) + await self._db.commit() + async def clear_tmdb_matches(self, file_ids: list[str]) -> int: """ Drop the auto-resolved file->tmdb mappings for these files so the 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 c75819e..af6bf08 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -70,6 +70,7 @@ from meshbay_common.adminop import ( OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, + OP_TMDB_REMATCH, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, @@ -475,6 +476,8 @@ class WebRTCPeerSession: self._spawn(self._do_tmdb_search_request(msg)) elif mtype == MNP.TMDB_OVERRIDE: self._do_tmdb_override(msg) + elif mtype == MNP.TMDB_REMATCH: + self._do_tmdb_rematch(msg) elif mtype == MNP.MUSICBRAINZ_ENABLED: self._do_musicbrainz_enabled(msg) elif mtype == MNP.MUSIC_META_REQ: @@ -3171,6 +3174,49 @@ class WebRTCPeerSession: except Exception: pass + def _do_tmdb_rematch(self, msg: dict) -> None: + """ + An operator dropping one file's cached TMDB match so it re-resolves + with the current matcher (§10.1/V13) — the one-click alternative to + the full search-and-pick "Fix match" flow, and reachable without + SSH (`meshbay-node video rematch` clears a whole group). Signed like + `tmdb_override`: `media_cache` is shared node-wide. + """ + file_id = msg.get("file_id") + if not isinstance(file_id, str) or not file_id: + self._send({"type": "error", "detail": "Missing file_id"}) + return + if not self._group_ctx()["index"].get_entry(file_id): + self._send({"type": "error", "detail": "File not found"}) + return + if not self._has_admin_authority(): + self._send({"type": "error", "detail": "No authorized key for this"}) + return + self._issue_admin_challenge(OP_TMDB_REMATCH, f"file_id={file_id}") + + async def _admin_exec_tmdb_rematch( + self, pending: dict, transcript: bytes, sig: bytes, + ) -> None: + subject = pending["subject"] + if not await self._verify_admin_sig(transcript, sig): + self._send({"type": "error", "detail": "Signature verification failed"}) + self._audit("admin_auth_failed", f"tmdb_rematch:{subject}") + return + file_id = dict(part.split("=", 1) for part in subject.split(","))["file_id"] + media_cache = self._ctx.get("media_cache") + if media_cache is None: + self._send({"type": "error", "detail": "Media cache not available"}) + return + await media_cache.drop_tmdb_match(file_id) + self._audit("tmdb_rematch", subject) + + notice = {"type": MNP.TMDB_REMATCH_ACK, "v": MNP_VERSION, "file_id": file_id} + for uid, session in list(self._peer_registry().items()): + try: + session._send(notice) + except Exception: + pass + async def _tmdb_search(self, tmdb_client, entry, is_show: bool): """ §3.3's retry ladder — same shape for movies and shows (§10.1/V8). @@ -3874,6 +3920,9 @@ class WebRTCPeerSession: elif pending["op"] == OP_TMDB_OVERRIDE: self._spawn( self._admin_exec_tmdb_override(pending, transcript, sig_bytes)) + elif pending["op"] == OP_TMDB_REMATCH: + self._spawn( + self._admin_exec_tmdb_rematch(pending, transcript, sig_bytes)) elif pending["op"] == OP_MUSICBRAINZ_ENABLED: self._spawn( self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py index 4e65b24..f12a366 100644 --- a/packages/meshbay-node/tests/test_media_cache.py +++ b/packages/meshbay-node/tests/test_media_cache.py @@ -106,6 +106,19 @@ async def test_clear_file_tmdb_drops_one_auto_match_but_keeps_an_override(cache) assert await cache.get_file_tmdb("renamed-fixed") == ("11", "movie") +@pytest.mark.asyncio +async def test_drop_tmdb_match_forgets_both_the_match_and_the_override(cache): + await cache.set_file_tmdb("f", "10", "movie") + await cache.mark_tmdb_override("f") + + await cache.drop_tmdb_match("f") + + assert await cache.get_file_tmdb("f") is None + # override marker is gone: a fresh match is now treated as ordinary + await cache.set_file_tmdb("f", "20", "movie") + assert await cache.clear_tmdb_matches(["f"]) == 1 + + @pytest.mark.asyncio async def test_prune_file_also_clears_the_override_marker(cache): await cache.set_file_tmdb("gone", "10", "movie") diff --git a/packages/meshbay-node/tests/test_tmdb_rematch_policy.py b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py new file mode 100644 index 0000000..ff259c1 --- /dev/null +++ b/packages/meshbay-node/tests/test_tmdb_rematch_policy.py @@ -0,0 +1,147 @@ +""" +`tmdb_rematch` (§10.1/V13) — an operator dropping one file's cached TMDB +match so it re-resolves with the current matcher. Signed like +`tmdb_override` (media_cache is shared node-wide); unlike `clear_file_tmdb` +it forgets a manual override marker too, since the operator is explicitly +asking for a fresh resolution. +""" + +import hashlib + +import pytest +from conftest import one_root +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from meshbay_common.adminop import OP_TMDB_REMATCH +from meshbay_common.protocol import MNP, IndexEntry +from meshbay_node.indexer.group_index import GroupIndex +from meshbay_node.media_cache import MediaCache +from meshbay_node.transport.webrtc_server import WebRTCPeerSession + +pytestmark = pytest.mark.asyncio + + +def _session(tmp_path, user_id, *, operator=None): + shared = tmp_path / "shared" + shared.mkdir(exist_ok=True) + index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) + s = WebRTCPeerSession.__new__(WebRTCPeerSession) + s._ctx = {"roots": one_root(shared), "index": index, "sk_node": index.sk_node, + "node_user_id": operator} + s._group_id = None + s._user_id = user_id + s._pk_user = "" + s.sent = [] + s._send = s.sent.append + s._audit = lambda *a, **k: None + return s + + +def _entry(name): + digest = hashlib.sha256(name.encode()).hexdigest() + return IndexEntry(id=digest, name=name, path="movies", size=1, type="video", + added_at=0, display_title="Some Film") + + +async def _true(): + return True + + +async def test_missing_file_id_is_refused(tmp_path): + s = _session(tmp_path, "op", operator="op") + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({}) + + assert not issued + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_unknown_file_id_is_refused(tmp_path): + s = _session(tmp_path, "op", operator="op") + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({"file_id": "nope"}) + + assert not issued + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_no_authorized_key_is_refused(tmp_path): + s = _session(tmp_path, "member", operator="the-operator") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + s._has_admin_authority = lambda: False + + s._do_tmdb_rematch({"file_id": e.id}) + + assert [m for m in s.sent if m.get("type") == "error"] + + +async def test_a_valid_request_is_signed(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + s._has_admin_authority = lambda: True + issued = [] + s._issue_admin_challenge = lambda op, subject: issued.append((op, subject)) + + s._do_tmdb_rematch({"file_id": e.id}) + + assert issued == [(OP_TMDB_REMATCH, f"file_id={e.id}")] + + +async def test_exec_drops_the_match_and_the_override_marker(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + s._ctx["media_cache"] = media_cache + await media_cache.set_file_tmdb(e.id, "wrong-id", "movie") + await media_cache.mark_tmdb_override(e.id) + s._verify_admin_sig = lambda transcript, sig: _true() + peer = type("Peer", (), {"sent": []})() + peer._send = peer.sent.append + s._peer_registry = lambda: {"peer-1": peer} + + await s._admin_exec_tmdb_rematch( + {"subject": f"file_id={e.id}"}, b"transcript", b"sig") + + assert await media_cache.get_file_tmdb(e.id) is None + # the override marker is gone too, so a later group-wide rematch + # would treat a fresh match as ordinary + await media_cache.set_file_tmdb(e.id, "fresh", "movie") + assert await media_cache.clear_tmdb_matches([e.id]) == 1 + assert [m for m in peer.sent if m.get("type") == MNP.TMDB_REMATCH_ACK] + finally: + await media_cache.close() + + +async def test_exec_refuses_a_bad_signature(tmp_path): + s = _session(tmp_path, "op", operator="op") + e = _entry("some.film.2001.mkv") + s._ctx["index"].add_entry(e) + + media_cache = MediaCache(db_path=tmp_path / "media_cache.db") + await media_cache.open() + try: + s._ctx["media_cache"] = media_cache + await media_cache.set_file_tmdb(e.id, "keep-me", "movie") + + async def _false(): + return False + s._verify_admin_sig = lambda transcript, sig: _false() + + await s._admin_exec_tmdb_rematch( + {"subject": f"file_id={e.id}"}, b"transcript", b"badsig") + + assert await media_cache.get_file_tmdb(e.id) == ("keep-me", "movie") + assert [m for m in s.sent if m.get("type") == "error"] + finally: + await media_cache.close() -- cgit v1.2.3