From 5d28d0c96cc8489645178b483835489779cb3887 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 29 Aug 2026 15:43:38 +0200 Subject: feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider sequel_variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. When the top result is not a confident textual hit (ratio < 0.6) and a year was requested, a different result of that exact release year is preferred — TMDB already year-filtered the search, so this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v --- packages/meshbay-node/tests/test_title_parse.py | 44 ++++++++++++++++ packages/meshbay-node/tests/test_tmdb.py | 53 ++++++++++++++++++++ .../meshbay-node/tests/test_tmdb_search_ladder.py | 58 +++++++++++++++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-node/tests') 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 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/meshbay-node/tests') 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