summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/mediacenter.md16
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py6
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js25
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py49
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py13
-rw-r--r--packages/meshbay-node/tests/test_tmdb_rematch_policy.py147
21 files changed, 296 insertions, 13 deletions
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',
@@ -916,6 +916,22 @@ class MeshBayTransport {
}
/**
+ * 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
* operator's shared credential/cache, not a per-group concern (see
@@ -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`
- <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}>
- ${t('video.fix_match')}
- </button>
+ <div class="video-admin-actions">
+ <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}>
+ ${t('video.fix_match')}
+ </button>
+ <button class="admin-btn" onClick=${doRematch} disabled=${rematching}>
+ ${rematching ? html`<span class="spinner"></span>` : t('video.rematch_one')}
+ </button>
+ </div>
`}
${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
@@ -107,6 +107,19 @@ async def test_clear_file_tmdb_drops_one_auto_match_but_keeps_an_override(cache)
@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")
await cache.mark_tmdb_override("gone")
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()