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/meshbay-hub/src') 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/meshbay-hub/src') 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