aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:58 +0200
commit71b7a310ce938f072fe20f27eeeadd40685f1ad1 (patch)
tree75a7d93d58fda0e742dd9b1c94538b532c34464a
parentb7733e812fadd6007976d262bd1d793572a36ba7 (diff)
downloadmeshbay-71b7a310ce938f072fe20f27eeeadd40685f1ad1.tar.gz
fix(node): correct TMDB movie matching, per-file overrides, rematch
A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
-rw-r--r--docs/mediacenter.md27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js18
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py38
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py10
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py49
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py103
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py4
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py1
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py49
-rw-r--r--packages/meshbay-node/tests/test_ops_rematch_video.py71
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py56
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py20
-rw-r--r--packages/meshbay-node/tests/test_tmdb_override_policy.py68
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_ladder.py193
26 files changed, 759 insertions, 27 deletions
diff --git a/docs/mediacenter.md b/docs/mediacenter.md
index 0df115b..3f70112 100644
--- a/docs/mediacenter.md
+++ b/docs/mediacenter.md
@@ -743,6 +743,33 @@ centralization was for (one computation, reused by every member).
| ~~V6~~ | ~~Two folders of the same show, named by different release groups, can produce two separate poster-grid cards~~ | **Closed, 2026-08-24.** Rather than fuzzy title matching (real design decision, still deferred), `PosterGrid` now merges raw show-groups client-side once each group's TMDB lookup resolves to the same confident `tmdb_id` (`onMetaResolved` reports each `PosterCard`'s resolved meta upward; a `useMemo` groups by id, combining episodes/seasons into one card). This only merges what TMDB already agrees is one show — a genuinely unmatched show still gets its own card, which is correct. Confirmed live: `OVNIs.S01...`/`Ovnis-S01...` now render as a single "OVNI(s)" card with both seasons |
| ~~V7~~ | ~~A show's automatic TMDB match can be wrong at the show level (not just mis-ranked locally), and a wrong match's `overview` can read as scoped to one season~~ | **Closed, 2026-08-24 — §5.7.** Per-season tabs (own `overview`/`air_date`, falling back to the show-level text when empty) plus an operator-only "Fix match…" search-and-correct affordance, applied to every file sharing the resolved `display_title`. Confirmed live on the operator's real "War of the Worlds" 3-season show, itself matched to a wrong season-3-specific 1988 promotional TMDB entry: season tabs correctly filtered episodes and swapped in each season's own air_date, and the search overlay returned real TMDB candidates for a manual correction |
+### 10.1 Movie-matching bugs — a batch found live (2026-08-29)
+
+Several wrong poster-grid matches on a real library: a two-volume film's second part
+matched the first; a numbered sequel matched a same-year making-of documentary (TMDB's
+real entry uses a Roman numeral); two entries of one franchise matched a single early
+entry whose *localized* TMDB title is the franchise name, and a third matched nothing.
+One mechanism: `_tmdb_search` returned the first candidate query whose title-similarity
+ratio merely cleared 0.6, before `alternative_title` / the Roman-numeral variant was
+tried. **Fixed** (branch `fix/videos-tmdb-matching`): scored retry ladder + year-exact
+rescue for a sub-0.6 top hit (§3.3's "trust TMDB's own ranking" still holds per query —
+no local re-rank of any one result list); `volume`/`part` folded into `display_title`;
+movie "Fix match" applied to the one file, not the whole `display_title` group;
+`ops.rematch_video` + a rename now drop stale cached matches (`media_cache.tmdb_override`
+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):**
+
+| # | Item | Severity |
+|---|---|---|
+| 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 |
+
## 11. Acceptance before shipping
1. Re-run the §3 validation (real TMDB calls, same corpus, same script
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 525f70c..d3bf11a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -182,6 +182,9 @@ export default {
'video.search_button': 'Suchen',
'video.search_no_results': 'Keine Treffer gefunden.',
'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.',
+ '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.',
// 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 a35eaf5..5c60f9e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -180,6 +180,9 @@ export default {
'video.search_button': 'Search',
'video.search_no_results': 'No matches found.',
'video.search_apply_hint': 'Applies to every file currently grouped under this title.',
+ '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.',
// 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 7400fc3..1e3aba2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -180,6 +180,9 @@ export default {
'video.search_button': 'Buscar',
'video.search_no_results': 'No se encontraron coincidencias.',
'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.',
+ '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.',
// 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 98b155a..c07b57c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -181,6 +181,9 @@ export default {
'video.search_button': 'Rechercher',
'video.search_no_results': 'Aucune correspondance trouvée.',
'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.',
+ '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é.',
// 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 9843e7c..4b68951 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -181,6 +181,9 @@ export default {
'video.search_button': 'Cerca',
'video.search_no_results': 'Nessuna corrispondenza trovata.',
'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.',
+ '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.',
// 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 41635d4..471933d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -178,6 +178,9 @@ export default {
'video.search_button': '検索',
'video.search_no_results': '一致する結果が見つかりません。',
'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。',
+ 'video.search_apply_hint_movie': 'このファイルにのみ適用されます。',
+ 'video.source_file': 'ファイル: {name}',
+ 'video.no_match': '確実なTMDB一致なし — ファイル名を表示しています。',
// 音楽
'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 38f9845..a24cb42 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -182,6 +182,9 @@ export default {
'video.search_button': 'Zoeken',
'video.search_no_results': 'Geen overeenkomsten gevonden.',
'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.',
+ '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.',
// 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 bfad68b..58dc7b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -189,6 +189,9 @@ export default {
'video.search_button': 'Szukaj',
'video.search_no_results': 'Nie znaleziono dopasowań.',
'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.',
+ 'video.search_apply_hint_movie': 'Dotyczy tylko tego pliku.',
+ 'video.source_file': 'Plik: {name}',
+ 'video.no_match': 'Brak pewnego dopasowania TMDB — pokazano nazwę pliku.',
// 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 a6ddadd..00d9051 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
@@ -182,6 +182,9 @@ export default {
'video.search_button': 'Pesquisar',
'video.search_no_results': 'Nenhuma correspondência encontrada.',
'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.',
+ '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.',
// 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 5fb2a0b..e5590d8 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
@@ -175,6 +175,9 @@ export default {
'video.search_button': '搜索',
'video.search_no_results': '未找到匹配项。',
'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。',
+ 'video.search_apply_hint_movie': '仅适用于此文件。',
+ 'video.source_file': '文件:{name}',
+ 'video.no_match': '没有可靠的 TMDB 匹配 — 显示文件名。',
// 音乐
'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 b315512..a7e8e3d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2916,6 +2916,30 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
color: var(--text-secondary);
margin-top: 6px;
}
+.video-detail-source {
+ font-size: 0.78em;
+ color: var(--text-dim);
+ margin-top: 6px;
+ word-break: break-all;
+}
+.video-detail-tmdbref { white-space: nowrap; }
+.video-detail-nomatch {
+ font-size: 0.85em;
+ color: var(--warn, #d98324);
+ margin-bottom: 4px;
+}
+.video-card-unmatched { outline: 1px dashed var(--warn, #d98324); outline-offset: -1px; }
+.video-card-flag {
+ display: inline-block;
+ margin-left: 4px;
+ padding: 0 5px;
+ border-radius: 8px;
+ font-size: 0.8em;
+ font-weight: 700;
+ color: var(--warn, #d98324);
+ border: 1px solid var(--warn, #d98324);
+ cursor: help;
+}
.video-season-list { margin-top: 14px; }
.video-season { margin-bottom: 14px; }
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 df930fd..f73def0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -325,7 +325,7 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
const ready = metaReady && imageReady;
return html`
- <div class="video-card" onClick=${onOpen}>
+ <div class="video-card ${ready && !confident ? 'video-card-unmatched' : ''}" onClick=${onOpen}>
${!ready && html`
<div class="video-poster video-poster-loading"><span class="spinner"></span></div>
`}
@@ -338,7 +338,10 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
</div>
${ready && html`
<div class="video-card-info">
- <div class="video-card-title">${(confident && meta.title) || title}</div>
+ <div class="video-card-title">
+ ${(confident && meta.title) || title}
+ ${!confident && html`<span class="video-card-flag" title=${t('video.no_match')}>?</span>`}
+ </div>
<div class="video-card-sub">
${confident && meta.release_date ? yearOf(meta.release_date) : ''}
${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''}
@@ -443,7 +446,9 @@ function TmdbSearchOverlay({
${searching ? html`<span class="spinner"></span>` : t('video.search_button')}
</button>
</form>
- <p class="video-search-hint">${t('video.search_apply_hint')}</p>
+ <p class="video-search-hint">
+ ${mediaType === 'tv' ? t('video.search_apply_hint') : t('video.search_apply_hint_movie')}
+ </p>
${error && html`<p class="video-search-error">${error}</p>`}
${results && results.length === 0 && !searching && html`
<p class="page-message">${t('video.search_no_results')}</p>
@@ -508,6 +513,13 @@ function VideoDetailModal({
<${Icon} name="close" /></button>
</div>
<div class="video-detail-body">
+ ${meta !== null && !confident && html`
+ <p class="video-detail-nomatch">${t('video.no_match')}</p>
+ `}
+ <p class="video-detail-source">
+ ${t('video.source_file', { name: `${repEntry.path}/${repEntry.name}` })}
+ ${confident && html`<span class="video-detail-tmdbref"> · TMDB #${meta.tmdb_id}</span>`}
+ </p>
${confident && html`
<p class="video-detail-overview">${(seasonConfident && seasonMeta.overview) || meta.overview}</p>
<p class="video-detail-facts">
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 779ad91..72813b6 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -448,6 +448,7 @@ class NodeDaemon:
# enricher (Pillow, not ffmpeg/mutagen). No credential, no
# third-party client to construct: EXIF is read locally.
self._photo_enricher = PhotoEnricher(self._media_cache)
+ self._state["media_cache"] = self._media_cache
log.info("Media cache opened: %s", media_cache_db)
# 5. Denylist
@@ -1218,6 +1219,14 @@ class NodeDaemon:
if old is None or (old.name == entry.name and old.path == entry.path):
continue
self._enriched_attempted.discard((indexer.group_id, entry.id))
+ # The rename re-derives the title (the whole point of this
+ # method), which can change the correct TMDB match — but
+ # file_tmdb is keyed by content hash, unchanged by a rename, so
+ # nothing else would ever dislodge the old name's match. A
+ # manual "Fix match" correction is kept (clear_file_tmdb skips
+ # anything in media_cache.tmdb_override).
+ if self._media_cache is not None:
+ await self._media_cache.clear_file_tmdb(entry.id)
await self._enrich_new_video_entries(indexer, updates)
async def _enrich_new_audio_entries(self, indexer: DirectoryIndexer, entries: list) -> None:
@@ -1576,21 +1585,22 @@ def main() -> None:
parser.add_argument("command", nargs="?",
choices=["init", "reset", "status", "ui", "gek-init",
"gek", "operator", "member", "group", "file",
- "denylist", "reload", "restart-daemon",
+ "video", "denylist", "reload", "restart-daemon",
"calibrate-argon2"],
help="init: provision config + keystore | reset: erase all "
"node state | status: node state and keys "
"| ui: print the admin UI URL | operator pair: pair a "
"browser with this node | member list|invite|revoke|unpin "
"| group list|add|remove | gek init|rotate | file list|rm "
- "| denylist show|clear | reload: re-read node.toml "
+ "| video rematch: re-resolve TMDB matches for a group's "
+ "videos | denylist show|clear | reload: re-read node.toml "
"(systemctl --user reload) | restart-daemon: restart "
"the systemd unit (systemctl --user restart) "
"| calibrate-argon2: benchmark")
parser.add_argument("subcommand", nargs="?",
help="'pair' for operator; list|invite|revoke|unpin for "
"member; list|add|remove for group; init|rotate for gek; "
- "list|rm for file; show|clear for denylist")
+ "list|rm for file; rematch for video; show|clear for denylist")
parser.add_argument("target", nargs="?",
help="username for member invite|revoke|unpin; group name "
"for group add; file id for file rm; identifier for "
@@ -1615,8 +1625,8 @@ def main() -> None:
# Query commands print a report; library logging would interleave with it.
quiet = args.command in ("status", "ui", "gek-init", "gek", "operator",
- "member", "group", "file", "denylist", "reload",
- "restart-daemon", "reset")
+ "member", "group", "file", "video", "denylist",
+ "reload", "restart-daemon", "reset")
logging.basicConfig(
level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
@@ -2107,6 +2117,24 @@ def main() -> None:
print("usage: meshbay-node file list|rm <id> [--group NAME] [--yes]")
sys.exit(1)
+ if args.command == "video":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ if (args.subcommand or "") != "rematch":
+ print("usage: meshbay-node video rematch [--group NAME] [--yes]")
+ sys.exit(1)
+ group_id = _resolve_group(cfg, args.group)
+ if not args.yes:
+ print("Re-resolve every automatic TMDB match for this group's videos?")
+ print("Manual 'Fix match' corrections are kept. Re-resolution is lazy —")
+ print("each poster re-queries TMDB the next time it is opened.")
+ if input("proceed? [y/N] ").strip().lower() not in ("y", "yes"):
+ print("cancelled")
+ return
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/video/rematch", method="POST")
+ print(f"cleared {out.get('removed', 0)} automatic match(es) "
+ f"across {out.get('videos', 0)} video file(s)")
+ return
+
if args.command == "group":
if args.subcommand in (None, "list"):
# Milestone 14.2.
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index 24b423e..24c23bc 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -146,6 +146,16 @@ def parse_movie_filename(filename: str) -> ParsedName:
title = str(title).strip() if title else None
if title:
title = _strip_editions(title)
+ # guessit peels a "Volume 2"/"Part 2" token off the title into its
+ # own field, so both parts of a two-part film parse to the same bare
+ # title. That collapsed the two on one TMDB search (the more popular
+ # first part won for both), and — since "Fix match" groups every
+ # entry sharing a display_title — left no way to correct one without
+ # the other. Fold the number back on so the two stay distinct in the
+ # query, the card and the override.
+ part = g.get("part") or g.get("volume")
+ if isinstance(part, int) and not isinstance(part, bool):
+ title = f"{title} {part}"
alt = g.get("alternative_title")
alt = _strip_editions(str(alt).strip()) if alt else None
year = g.get("year")
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 707a046..4600a09 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -34,6 +34,14 @@ CREATE TABLE IF NOT EXISTS file_tmdb (
tmdb_id TEXT NOT NULL,
media_type TEXT NOT NULL
);
+-- Files whose match was set by an explicit operator "Fix match"
+-- correction, not by the automatic matcher. `ops.rematch_video` and a
+-- rename's re-enrichment wipe the *auto-resolved* file->tmdb mappings so
+-- they re-resolve against the current matcher; a manual correction must
+-- survive that, so it is recorded here and skipped.
+CREATE TABLE IF NOT EXISTS tmdb_override (
+ file_id TEXT PRIMARY KEY
+);
CREATE TABLE IF NOT EXISTS tmdb_meta (
tmdb_id TEXT NOT NULL,
media_type TEXT NOT NULL,
@@ -144,6 +152,46 @@ class MediaCache:
)
await self._db.commit()
+ # ── manual "Fix match" corrections vs auto-resolved matches ──────────────
+
+ async def mark_tmdb_override(self, file_id: str) -> None:
+ """Record that this file's current match is an explicit operator
+ correction — `clear_file_tmdb` / `clear_tmdb_matches` skip it."""
+ await self._db.execute(
+ "INSERT OR IGNORE INTO tmdb_override (file_id) VALUES (?)", (file_id,))
+ await self._db.commit()
+
+ async def clear_file_tmdb(self, file_id: str) -> None:
+ """
+ Drop one file's *auto-resolved* match. Used on rename: the new name
+ re-derives the title, so the old name's match no longer applies —
+ but file_tmdb is keyed by content hash, unchanged by a rename, so
+ nothing else would ever dislodge it. A manual "Fix match"
+ correction is kept: the content, hence what the operator corrected,
+ is the same.
+ """
+ await self._db.execute(
+ "DELETE FROM file_tmdb WHERE file_id = ? AND file_id NOT IN "
+ "(SELECT file_id FROM tmdb_override)", (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
+ next `media_meta_req` re-resolves each against the current matcher
+ (`ops.rematch_video`, run by the operator after a matcher/parser
+ fix). Manual "Fix match" corrections (`tmdb_override`) are left in
+ place. Returns the number of rows removed.
+ """
+ if not self._db or not file_ids:
+ return 0
+ marks = ",".join("?" * len(file_ids))
+ cur = await self._db.execute(
+ f"DELETE FROM file_tmdb WHERE file_id IN ({marks}) AND file_id NOT IN "
+ "(SELECT file_id FROM tmdb_override)", file_ids)
+ await self._db.commit()
+ return cur.rowcount
+
# ── tmdb id -> metadata json ─────────────────────────────────────────────
async def get_tmdb_meta(self, tmdb_id: str, media_type: str) -> dict | None:
@@ -324,5 +372,6 @@ class MediaCache:
await self._db.execute("DELETE FROM photo_meta WHERE file_id = ?", (file_id,))
await self._db.execute("DELETE FROM video_meta WHERE file_id = ?", (file_id,))
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.execute("DELETE FROM file_mbid WHERE file_id = ?", (file_id,))
await self._db.commit()
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index cb8e01e..c921b05 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -974,6 +974,31 @@ async def prune_index_cache(state: dict) -> dict:
return {"status": "pruned", "removed": len(stale), "kept": len(paths) - len(stale)}
+# ── Videos: force TMDB re-matching ───────────────────────────────────────────
+#
+# `media_cache.file_tmdb` is keyed by a file's content hash and is otherwise
+# only pruned on deletion, so a fixed matcher/parser never dislodges a match
+# already in cache. This drops a group's *auto-resolved* mappings so the
+# next `media_meta_req` for each poster tile re-resolves against the current
+# code. Re-resolution is lazy and calls TMDB once per unique title — real
+# API budget — so this is an explicit operator action, never a background job.
+# Manual "Fix match" corrections (media_cache.tmdb_override) are kept.
+
+async def rematch_video(state: dict, group_id: str) -> dict:
+ media_cache = state.get("media_cache")
+ if media_cache is None:
+ raise OpError("No media cache in this process", status=503)
+ indexer = (state.get("indexers") or {}).get(group_id)
+ if indexer is None:
+ raise OpError("Unknown group", status=404)
+ file_ids = [e.id for e in indexer.index.entries if e.type == "video"]
+ removed = await media_cache.clear_tmdb_matches(file_ids)
+ log.info("Video rematch for group %s: %d auto match(es) cleared across %d video file(s)",
+ group_id[:8], removed, len(file_ids))
+ return {"status": "cleared", "removed": removed, "videos": len(file_ids),
+ "group_id": group_id}
+
+
# ── Reload ──────────────────────────────────────────────────────────────────
async def reload_config(state: dict) -> dict:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index dd68e18..788a8f1 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -3076,11 +3076,13 @@ class WebRTCPeerSession:
video_root/tmdb_config: it replaces what every member sees for a
show/movie, node-wide (media_cache is shared, not per-viewer).
- Applied to every entry sharing the representative file's
- display_title — the same grouping the poster grid itself uses
- (§3.4/§V6) — not just the one file the operator happened to be
- looking at, so the correction actually sticks regardless of which
- episode a future render picks as representative.
+ For a **show**, applied to every entry sharing the representative
+ file's display_title — the same grouping the poster grid uses
+ (§3.4/§V6) — so the correction sticks regardless of which episode a
+ future render picks as representative. For a **movie** it is applied
+ to that one file only: guessit gives a whole franchise the same
+ display_title, and a fan-out there corrected the wrong films (found
+ live, 2026-08-29). See `_admin_exec_tmdb_override`.
Keyed by `file_id`, not `path` — see `_do_media_meta_request`'s
docstring for why a folder-level path cannot name one file.
@@ -3140,11 +3142,25 @@ class WebRTCPeerSession:
if tmdb_client is not None:
meta = await self._tmdb_build_meta(tmdb_client, tmdb_id, media_type, {})
await media_cache.set_tmdb_meta(tmdb_id, media_type, meta)
- target_title = entry.display_title or entry.name
- matched = [e for e in ctx["index"].entries
- if e.type == "video" and (e.display_title or e.name) == target_title]
+ # A show's episodes are many files that legitimately share one match,
+ # and which episode a render picks as representative rotates — so a
+ # show override fans out across every entry with the same
+ # display_title. A *movie* is one file: fanning out by display_title
+ # there is a bug — guessit gives every
+ # "<franchise> - <year> - <subtitle>.mkv" the same display_title, so
+ # "Fix match" on one entry rewrote the whole franchise (found live,
+ # 2026-08-29). Each corrected file is also marked as a manual
+ # override so ops.rematch_video / a rename never wipe it.
+ is_show = entry.season is not None and entry.episode is not None
+ if is_show:
+ target_title = entry.display_title or entry.name
+ matched = [e for e in ctx["index"].entries
+ if e.type == "video" and (e.display_title or e.name) == target_title]
+ else:
+ matched = [entry]
for e in matched:
await media_cache.set_file_tmdb(e.id, tmdb_id, media_type)
+ await media_cache.mark_tmdb_override(e.id)
self._audit("tmdb_override", subject)
notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "file_id": file_id,
@@ -3157,9 +3173,22 @@ class WebRTCPeerSession:
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
- §3.3's retry ladder: the parsed title first, then a couple of
- generic, non-per-title fallbacks — never re-ranking TMDB's own
- top result locally (§3.3's last row).
+ §3.3's retry ladder. TMDB's own top result is still trusted per
+ query (§3.3's last row — no local re-ranking of *its* list); what
+ changed is that the ladder now *scores every candidate query* and
+ keeps the best, instead of returning the first that merely clears
+ 0.6.
+
+ The bare parsed title is the weakest query: guessit drops a
+ "Volume 2", strips a real subtitle into `alternative_title`, and
+ renders a sequel number where TMDB uses a Roman numeral. A wrong
+ film that happened to score ~0.7 against that weak query — a
+ same-year making-of documentary, or a franchise entry whose
+ localized TMDB title *is* the franchise name — used to win outright
+ before `alternative_title` or the Roman-numeral variant was ever
+ tried. Found live (2026-08-29): a numbered sequel matched a
+ same-year documentary; a two-volume film's second part matched the
+ first; several franchise entries matched one early entry.
"""
from meshbay_node.indexer import title_parse
@@ -3172,21 +3201,57 @@ class WebRTCPeerSession:
result, ratio = await tmdb_client.search_tv(naive)
return result, ratio
+ def _release_year(res: dict) -> int | None:
+ d = str(res.get("release_date") or res.get("first_air_date") or "")
+ return int(d[:4]) if d[:4].isdigit() else None
+
parsed = title_parse.parse_movie_filename(entry.name)
title = entry.display_title or parsed.display_title or parsed.naive_title
+
+ # Fast path, unchanged in effect: a strong direct hit still returns
+ # on the first call, so the common case costs exactly one request
+ # and the new ladder below only engages in the ambiguous 0<ratio<0.85
+ # zone where every one of the live bugs lived.
result, ratio = await tmdb_client.search_movie(title, parsed.year)
- if result is not None and ratio >= 0.6:
+ if result is not None and ratio >= 0.85:
return result, ratio
- for candidate in filter(None, [parsed.alt_title, parsed.naive_title,
- *title_parse.sequel_variants(title)]):
+
+ best_result, best_score = (result, ratio) if result is not None else (None, 0.0)
+
+ def _apply_year_rescue(res: dict, r: float) -> float:
+ # Only for a query whose own top hit is weak on its face
+ # (ratio < 0.6): TMDB already year-filtered the search, so its
+ # top result landing exactly on the filename's year is a hard
+ # corroborating signal that the low string ratio is a
+ # localized/rearranged title, not a wrong film. Never lets year
+ # equality outrank a genuinely strong textual match elsewhere.
+ if r < 0.6 and parsed.year and _release_year(res) == parsed.year:
+ return max(r, 0.6)
+ return r
+
+ if best_result is not None:
+ best_score = _apply_year_rescue(best_result, best_score)
+
+ for candidate in filter(None, [parsed.alt_title,
+ *title_parse.sequel_variants(title),
+ parsed.naive_title]):
if candidate == title:
continue
- result2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year)
- if result2 is not None and ratio2 > ratio:
- result, ratio = result2, ratio2
- if ratio >= 0.6:
+ r2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year)
+ if r2 is None and parsed.year:
+ # A year-filtered search that finds nothing: the filename's
+ # year tag may be an edition/regional year TMDB doesn't
+ # carry. Retry the same candidate unconstrained before
+ # dropping it.
+ r2, ratio2 = await tmdb_client.search_movie(candidate)
+ if r2 is None:
+ continue
+ score2 = _apply_year_rescue(r2, ratio2)
+ if score2 > best_score:
+ best_result, best_score = r2, score2
+ if best_score >= 0.85:
break
- return result, ratio
+ return best_result, best_score
@staticmethod
async def _tmdb_build_meta(tmdb_client, tmdb_id: str, media_type: str, result: dict) -> dict:
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index dfd9f68..514a143 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -207,6 +207,10 @@ def create_ui_app(state: dict) -> FastAPI:
async def api_index_cache_prune():
return await _op(lambda: ops.prune_index_cache(state))
+ @app.post("/api/groups/{group_id}/video/rematch")
+ async def api_video_rematch(group_id: str):
+ return await _op(lambda: ops.rematch_video(state, group_id))
+
@app.get("/api/groups/{group_id}/files")
async def api_group_files(group_id: str):
groups_ctx = state.get("groups_ctx", {})
diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py
index aff7330..aa966fd 100644
--- a/packages/meshbay-node/tests/test_cli_dispatch.py
+++ b/packages/meshbay-node/tests/test_cli_dispatch.py
@@ -37,6 +37,7 @@ VERBS = [
["operator", "pair"],
["file", "list"],
["file", "rm", "abc", "--yes"],
+ ["video", "rematch", "--yes"],
["denylist", "show"],
["denylist", "clear", "--yes"],
["reload"],
diff --git a/packages/meshbay-node/tests/test_media_cache.py b/packages/meshbay-node/tests/test_media_cache.py
index e70ef43..4e65b24 100644
--- a/packages/meshbay-node/tests/test_media_cache.py
+++ b/packages/meshbay-node/tests/test_media_cache.py
@@ -71,6 +71,55 @@ async def test_prune_file_removes_thumb_and_mapping_but_not_shared_meta(cache):
assert await cache.get_tmdb_meta("555", "tv") == {"name": "A Show"}
+# ── auto-resolved matches vs manual "Fix match" corrections ─────────────────
+
+@pytest.mark.asyncio
+async def test_clear_tmdb_matches_drops_auto_matches_but_keeps_overrides(cache):
+ await cache.set_file_tmdb("auto1", "10", "movie")
+ await cache.set_file_tmdb("auto2", "20", "movie")
+ await cache.set_file_tmdb("fixed", "30", "movie")
+ await cache.mark_tmdb_override("fixed")
+
+ removed = await cache.clear_tmdb_matches(["auto1", "auto2", "fixed", "never-seen"])
+
+ assert removed == 2
+ assert await cache.get_file_tmdb("auto1") is None
+ assert await cache.get_file_tmdb("auto2") is None
+ assert await cache.get_file_tmdb("fixed") == ("30", "movie"), (
+ "a manual Fix match correction must survive ops.rematch_video")
+
+
+@pytest.mark.asyncio
+async def test_clear_tmdb_matches_empty_list_is_a_noop(cache):
+ assert await cache.clear_tmdb_matches([]) == 0
+
+
+@pytest.mark.asyncio
+async def test_clear_file_tmdb_drops_one_auto_match_but_keeps_an_override(cache):
+ await cache.set_file_tmdb("renamed", "10", "movie")
+ await cache.clear_file_tmdb("renamed")
+ assert await cache.get_file_tmdb("renamed") is None
+
+ await cache.set_file_tmdb("renamed-fixed", "11", "movie")
+ await cache.mark_tmdb_override("renamed-fixed")
+ await cache.clear_file_tmdb("renamed-fixed")
+ assert await cache.get_file_tmdb("renamed-fixed") == ("11", "movie")
+
+
+@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")
+
+ await cache.prune_file("gone")
+
+ # the marker must not linger after the file leaves the index and then
+ # shield a later match on the same id from ops.rematch_video
+ assert await cache.get_file_tmdb("gone") is None
+ await cache.set_file_tmdb("gone", "99", "movie")
+ assert await cache.clear_tmdb_matches(["gone"]) == 1
+
+
# ── Music app (docs/musicbay.md §6) — file_mbid/mbid_meta ────────────────────
@pytest.mark.asyncio
diff --git a/packages/meshbay-node/tests/test_ops_rematch_video.py b/packages/meshbay-node/tests/test_ops_rematch_video.py
new file mode 100644
index 0000000..f63a1a9
--- /dev/null
+++ b/packages/meshbay-node/tests/test_ops_rematch_video.py
@@ -0,0 +1,71 @@
+"""
+Tests for ops.rematch_video — drops a group's *auto-resolved* video->TMDB
+matches so they re-resolve against the current matcher (run by the operator
+after a matcher/parser fix; `media_cache.file_tmdb` is keyed by content
+hash and is otherwise only pruned on deletion). Manual "Fix match"
+corrections are kept.
+"""
+
+import types
+
+import pytest
+from meshbay_node import ops
+from meshbay_node.media_cache import MediaCache
+
+pytestmark = pytest.mark.asyncio
+
+
+def _indexer(*entries):
+ return types.SimpleNamespace(index=types.SimpleNamespace(entries=list(entries)))
+
+
+def _entry(file_id, type_="video"):
+ return types.SimpleNamespace(id=file_id, type=type_)
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+async def test_no_media_cache_raises():
+ with pytest.raises(ops.OpError):
+ await ops.rematch_video({}, "g" * 32)
+
+
+async def test_unknown_group_raises(media_cache):
+ with pytest.raises(ops.OpError):
+ await ops.rematch_video({"media_cache": media_cache, "indexers": {}}, "nope")
+
+
+async def test_clears_auto_matches_keeps_overrides_ignores_non_video(media_cache):
+ await media_cache.set_file_tmdb("v-auto-1", "10", "movie")
+ await media_cache.set_file_tmdb("v-auto-2", "20", "movie")
+ await media_cache.set_file_tmdb("v-fixed", "30", "movie")
+ await media_cache.mark_tmdb_override("v-fixed")
+ await media_cache.set_file_mbid("a-track", "some-mbid") # audio, untouched
+
+ state = {
+ "media_cache": media_cache,
+ "indexers": {"g": _indexer(
+ _entry("v-auto-1"), _entry("v-auto-2"), _entry("v-fixed"),
+ _entry("a-track", "audio"),
+ )},
+ }
+
+ result = await ops.rematch_video(state, "g")
+
+ assert result == {"status": "cleared", "removed": 2, "videos": 3, "group_id": "g"}
+ assert await media_cache.get_file_tmdb("v-auto-1") is None
+ assert await media_cache.get_file_tmdb("v-auto-2") is None
+ assert await media_cache.get_file_tmdb("v-fixed") == ("30", "movie")
+ assert await media_cache.get_file_mbid("a-track") == "some-mbid"
+
+
+async def test_group_with_no_videos_is_a_clean_noop(media_cache):
+ state = {"media_cache": media_cache, "indexers": {"g": _indexer()}}
+ result = await ops.rematch_video(state, "g")
+ assert result == {"status": "cleared", "removed": 0, "videos": 0, "group_id": "g"}
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py
index 87e0d1a..7a77368 100644
--- a/packages/meshbay-node/tests/test_rename_reenrichment.py
+++ b/packages/meshbay-node/tests/test_rename_reenrichment.py
@@ -22,6 +22,7 @@ from meshbay_common.crypto import generate_gek
from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
from meshbay_node.daemon import NodeDaemon
from meshbay_node.indexer import DirectoryIndexer
+from meshbay_node.media_cache import MediaCache
from conftest import one_root
@@ -115,6 +116,61 @@ async def test_a_renamed_file_gets_re_enriched(tmp_path):
"from ever being title-parsed")
+async def test_a_rename_drops_the_stale_cached_tmdb_match(tmp_path):
+ """
+ A rename re-derives the title, which can change the correct TMDB match
+ — but media_cache.file_tmdb is keyed by content hash, unchanged by a
+ rename, so without an explicit clear the old name's match sticks
+ forever. (An explicit "Fix match" correction is kept — covered by
+ test_media_cache.py's clear_file_tmdb tests.)
+ """
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ old_path = shared / "old.frontier.3.2001.mkv"
+ old_path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29018,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ daemon._media_cache = media_cache
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ file_id = next(iter(indexer.index.entries)).id
+ await media_cache.set_file_tmdb(file_id, "201", "movie") # the wrong match
+
+ old_path.rename(shared / "old.frontier.iii.2001.mkv")
+ assert await indexer.reconcile()
+ for _ in range(30):
+ if await media_cache.get_file_tmdb(file_id) is None:
+ break
+ await asyncio.sleep(0.02)
+
+ assert await media_cache.get_file_tmdb(file_id) is None, (
+ "a rename must drop the auto-resolved match so it re-resolves")
+ await media_cache.close()
+
+
async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path):
"""
The other half of the same fix: an update whose name/path did *not*
diff --git a/packages/meshbay-node/tests/test_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index 5f0977c..03fb588 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -69,6 +69,26 @@ def test_sequel_variants_empty_when_no_trailing_digit():
assert sequel_variants("Some Movie") == []
+# ── bug 2026-08-29: guessit peels "Volume N" off the title ─────────────────
+
+def test_movie_volume_number_is_folded_back_into_the_title():
+ # guessit parses both to title="Some Saga" + volume=1/2; leaving it that
+ # way collapsed the two parts onto one TMDB match and made "Fix match"
+ # (grouped by display_title) unable to separate them.
+ r1 = parse_movie_filename("Some.Saga.Volume.1.2003.mkv")
+ r2 = parse_movie_filename("Some.Saga.Volume.2.2004.mkv")
+ assert r1.display_title == "Some Saga 1"
+ assert r2.display_title == "Some Saga 2"
+ assert r1.display_title != r2.display_title
+ assert r1.year == 2003 and r2.year == 2004
+
+
+def test_movie_without_a_volume_token_is_unchanged():
+ # the "3" is already part of guessit's title here, not a volume field
+ r = parse_movie_filename("Old.Frontier.3.2001.mkv")
+ assert r.display_title == "Old Frontier 3"
+
+
# ── §3.3 row 6: no usable title at all ───────────────────────────────────────
def test_low_confidence_when_no_title_or_year():
diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py
index c2f28e6..0717a0c 100644
--- a/packages/meshbay-node/tests/test_tmdb_override_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py
@@ -195,6 +195,74 @@ async def test_override_updates_every_entry_sharing_the_display_title(tmp_path):
await media_cache.close()
+async def test_movie_override_touches_only_the_one_file(tmp_path):
+ """
+ guessit gives a whole franchise the same display_title (every
+ "<franchise>.-.<year>.-.<subtitle>.mkv" parses to the franchise name),
+ so fanning a *movie* override out by display_title corrected the wrong
+ films (found live, 2026-08-29). A movie override applies to its own
+ file_id only; a show override still fans out (test above).
+ """
+ session = _session(tmp_path, "op", operator="op")
+ index = session._ctx["index"]
+ m1 = IndexEntry(id="b" * 64, name="Some.Agent.42.-.2008.-.Second.Errand.mkv",
+ path="movies", size=1, type="video", added_at=0,
+ display_title="Some Agent 42")
+ m2 = IndexEntry(id="c" * 64, name="Some.Agent.42.-.2012.-.Third.Errand.mkv",
+ path="movies", size=1, type="video", added_at=0,
+ display_title="Some Agent 42")
+ index.add_entry(m1)
+ index.add_entry(m2)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ session._ctx["media_cache"] = media_cache
+ session._ctx["tmdb_client"] = None
+ session._verify_admin_sig = lambda transcript, sig: _true()
+ session._peer_registry = lambda: {}
+
+ await session._admin_exec_tmdb_override(
+ {"subject": f"file_id={m1.id},tmdb_id=302,media_type=movie"},
+ b"transcript", b"sig")
+
+ assert await media_cache.get_file_tmdb(m1.id) == ("302", "movie")
+ assert await media_cache.get_file_tmdb(m2.id) is None, (
+ "a movie override must not fan out to another film sharing the "
+ "parsed franchise display_title")
+ # the corrected file is also shielded from a later ops.rematch_video
+ assert await media_cache.clear_tmdb_matches([m1.id, m2.id]) == 0
+ finally:
+ await media_cache.close()
+
+
+async def test_show_override_fans_out_and_marks_every_corrected_file(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ index = session._ctx["index"]
+ s1 = _entry("shared/S1", "s01e01.mkv", "Some Show")
+ s2 = _entry("shared/S2", "s02e01.mkv", "Some Show")
+ index.add_entry(s1)
+ index.add_entry(s2)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ session._ctx["media_cache"] = media_cache
+ session._ctx["tmdb_client"] = None
+ session._verify_admin_sig = lambda transcript, sig: _true()
+ session._peer_registry = lambda: {}
+
+ await session._admin_exec_tmdb_override(
+ {"subject": f"file_id={s1.id},tmdb_id=2255,media_type=tv"},
+ b"transcript", b"sig")
+
+ assert await media_cache.get_file_tmdb(s2.id) == ("2255", "tv") # fan-out
+ # both episodes are marked, so ops.rematch_video spares the correction
+ assert await media_cache.clear_tmdb_matches([s1.id, s2.id]) == 0
+ finally:
+ await media_cache.close()
+
+
class _FakeTmdbClient:
"""Just enough for _tmdb_build_meta to run end to end — a fixed,
deterministic response, not a search stub (the override already has a
diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
new file mode 100644
index 0000000..49b71bd
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
@@ -0,0 +1,193 @@
+"""
+`WebRTCPeerSession._tmdb_search` — the movie retry ladder.
+
+Regression cover for a batch of wrong poster-grid matches found live on a
+real library (2026-08-29), all one mechanism: the ladder used to return the
+first candidate query whose title-similarity ratio merely cleared 0.6, so a
+wrong film that scored ~0.7 against guessit's weak bare title won before
+`alternative_title` or the Roman-numeral variant was ever tried.
+
+ * A two-volume film's second part matched the *first* — guessit peels
+ "Volume 2" into its own field, collapsing both parts onto one query,
+ and the more popular first part is TMDB's top result for both.
+ * A numbered sequel matched a same-year making-of documentary — TMDB's
+ real entry uses a Roman numeral, so the "3" query surfaces the doc.
+ * Two entries of one franchise matched a single early entry whose
+ *localized* TMDB title is itself the parsed franchise name.
+ * One entry matched nothing — every candidate query missed on text (the
+ filename's spelling of the subtitle differs from TMDB's by one letter).
+
+The canned `(result, ratio)` tuples stand in for what the live TMDB API +
+the real `_best_match` return for each query; the ratios are the ones those
+queries actually produced when the mechanism was traced against the API.
+"""
+
+import pytest
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer.title_parse import naive_title, parse_movie_filename
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+# Stand-ins for real TMDB rows: a two-part film, a numbered sequel vs a
+# same-year documentary, and a franchise whose localized lead entry's title
+# is the franchise name.
+PART1 = {"id": "101", "title": "Some Saga: Volume 1", "release_date": "2003-10-10"}
+PART2 = {"id": "102", "title": "Some Saga: Volume 2", "release_date": "2004-04-16"}
+DOC = {"id": "201", "title": "Beyond Old Frontier", "release_date": "2001-11-01"}
+SEQUEL3 = {"id": "202", "title": "Old Frontier III", "release_date": "2001-07-18"}
+FRANCHISE_LEAD = {"id": "301", "title": "Some Agent 42 vs. Doctor X",
+ "release_date": "1962-10-05"}
+ENTRY_2002 = {"id": "302", "title": "Second Errand", "release_date": "2002-11-20"}
+ENTRY_1997 = {"id": "303", "title": "First Errand", "release_date": "1997-12-12"}
+STANDALONE = {"id": "401", "title": "A Quiet Film", "release_date": "2010-07-15"}
+
+
+class LadderTmdb:
+ """Canned `(result, ratio)` keyed by `(query, year)` — a hit for
+ `(query, None)` also answers a year-constrained lookup, mirroring the
+ real ladder's unconstrained retry."""
+
+ def __init__(self, table: dict):
+ self._table = table
+ self.calls: list[tuple] = []
+
+ async def search_movie(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
+
+ async def search_tv(self, title):
+ self.calls.append(("tv", title))
+ return None, 0.0
+
+
+async def _run(name: str, table: dict):
+ """Drive the real `_tmdb_search` for a movie filename, deriving
+ `display_title` exactly as enrich.py would from the current parser."""
+ parsed = parse_movie_filename(name)
+ entry = IndexEntry(
+ id="x", name=name, path="movies", size=1, type="video", added_at=0,
+ display_title=parsed.display_title or parsed.naive_title,
+ )
+ client = LadderTmdb(table)
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ result, ratio = await session._tmdb_search(client, entry, is_show=False)
+ 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():
+ result, ratio, client = await _run(
+ "Some.Saga.Volume.2.2004.mkv",
+ {("Some Saga 2", 2004): (PART2, 0.92)},
+ )
+ assert result["id"] == "102"
+ # a strong first hit still costs exactly one request
+ assert client.calls == [("Some Saga 2", 2004)]
+
+
+async def test_first_volume_still_resolves_to_the_first_volume():
+ result, _, _ = await _run(
+ "Some.Saga.Volume.1.2003.mkv",
+ {("Some Saga 1", 2003): (PART1, 0.92)},
+ )
+ assert result["id"] == "101"
+
+
+# ── numbered sequel: the Roman-numeral variant beats a same-year documentary ─
+
+async def test_numbered_sequel_prefers_the_real_film_over_a_documentary():
+ naive = naive_title("Old.Frontier.3.2001.mkv")
+ result, _, _ = await _run(
+ "Old.Frontier.3.2001.mkv",
+ {
+ ("Old Frontier 3", 2001): (DOC, 0.80), # weak bare-title hit
+ ("Old Frontier", 2001): (DOC, 0.76),
+ ("Old Frontier III", 2001): (SEQUEL3, 1.0), # the sequel variant
+ (naive, 2001): (DOC, 0.50),
+ },
+ )
+ assert result["id"] == "202"
+
+
+# ── franchise: alternative_title beats the localized franchise name ────────
+
+async def test_franchise_entry_uses_its_subtitle_not_the_franchise_name():
+ result, _, _ = await _run(
+ "Some.Agent.42.-.2002.-.Second.Errand.mkv",
+ {
+ ("Some Agent 42", 2002): (FRANCHISE_LEAD, 0.70), # localized lead, popularity #1
+ ("Second Errand", 2002): (ENTRY_2002, 1.0),
+ },
+ )
+ assert result["id"] == "302"
+
+
+async def test_franchise_alternative_title_still_wins_over_the_localized_lead():
+ # PASS 1 lands on the localized lead at 0.70 (not strong enough to
+ # short-circuit); the ladder must go on to try the subtitle.
+ result, _, _ = await _run(
+ "Some.Agent.42.-.1995.-.Third.Errand.mkv",
+ {
+ ("Some Agent 42", 1995): (FRANCHISE_LEAD, 0.70),
+ ("Third Errand", 1995): ({"id": "305", "title": "Third Errand",
+ "release_date": "1995-11-16"}, 1.0),
+ },
+ )
+ assert result["id"] == "305"
+
+
+# ── the year-exact rescue: every candidate query misses on text ────────────
+
+async def test_rescued_by_exact_release_year_when_every_candidate_misses():
+ # PASS 1's own top hit IS the right film (TMDB year-filtered the search)
+ # but scores far below 0.6 against the bare franchise title; the
+ # alt-title and naive queries all miss because the filename spells the
+ # subtitle differently from TMDB.
+ result, ratio, _ = await _run(
+ "Some.Agent.42.1997.First.Errand.mkv",
+ {("Some Agent 42", 1997): (ENTRY_1997, 0.20)},
+ )
+ assert result["id"] == "303"
+ # rescued to exactly the confidence floor — _do_media_meta_request keeps
+ # a match at ratio >= 0.6, rejects one below it.
+ assert ratio >= 0.6
+
+
+async def test_year_rescue_does_not_fire_without_a_year_match():
+ # Same weak PASS 1, but the top hit's year does NOT match the filename's
+ # → no rescue, stays sub-0.6, and _do_media_meta_request reports
+ # confidence 0 rather than pinning a wrong film.
+ result, ratio, _ = await _run(
+ "Some.Agent.42.1997.First.Errand.mkv",
+ {("Some Agent 42", 1997): ({"id": "999", "title": "An Old Film",
+ "release_date": "1962-01-01"}, 0.20)},
+ )
+ assert ratio < 0.6
+
+
+# ── the ladder must not let a weaker later candidate override a good hit ────
+
+async def test_a_strong_first_hit_is_not_overridden_by_a_weaker_variant():
+ result, _, _ = await _run(
+ "The.Thing.-.2011.-.Wrong.Subtitle.mkv",
+ {
+ ("The Thing", 2011): ({"id": "1", "title": "The Thing",
+ "release_date": "2011-10-14"}, 0.80),
+ ("Wrong Subtitle", 2011): ({"id": "999", "title": "Wrong Subtitle",
+ "release_date": "2011-01-01"}, 0.75),
+ },
+ )
+ assert result["id"] == "1"
+
+
+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)]