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.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js108
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/title_parse.py100
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py12
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py58
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py165
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py13
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py44
-rw-r--r--packages/meshbay-node/tests/test_tmdb.py53
-rw-r--r--packages/meshbay-node/tests/test_tmdb_rematch_policy.py147
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_ladder.py58
26 files changed, 713 insertions, 119 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 d3bf11a..c4ee388 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,8 @@ 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',
+ '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 5c60f9e..16ae3ab 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,8 @@ 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',
+ '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 1e3aba2..88b222d 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,8 @@ 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',
+ '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 c07b57c..b08cf28 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,8 @@ 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',
+ '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 4b68951..f1e8467 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,8 @@ 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',
+ '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 471933d..95cc040 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,8 @@ export default {
'video.search_apply_hint_movie': 'このファイルにのみ適用されます。',
'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 a24cb42..076aa15 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,8 @@ 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',
+ '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 58dc7b5..00ef59e 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,8 @@ 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}',
+ '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 00d9051..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
@@ -185,6 +185,8 @@ 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',
+ '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 e5590d8..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
@@ -178,6 +178,8 @@ export default {
'video.search_apply_hint_movie': '仅适用于此文件。',
'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 a7e8e3d..8e714f3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2929,6 +2929,8 @@ 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-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 f73def0..6d13e27 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -478,12 +478,26 @@ 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);
+ 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,20 +549,40 @@ 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}
onSelect=${setSelectedSeason} />
`}
- ${!show && html`
+ ${!show && (!files || files.length <= 1) && html`
<button class="admin-btn" onClick=${() => onPlay(repEntry)}>
<${Icon} name="play" /> ${t('group.play')}
${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''}
</button>
`}
+ ${!show && files && files.length > 1 && html`
+ <div class="video-version-list">
+ ${[...files]
+ .sort((a, b) => (b.height || 0) - (a.height || 0) || (b.size || 0) - (a.size || 0))
+ .map((f) => html`
+ <button class="video-episode-row" key=${f.id} onClick=${() => onPlay(f)}>
+ <${Icon} name="play" />
+ <span class="video-episode-label">
+ ${[formatResolution(f.width, f.height), formatDuration(f.duration),
+ formatSize(f.size)].filter(Boolean).join(' · ')}
+ </span>
+ </button>
+ `)}
+ </div>
+ `}
${show && html`
<div class="video-season-list">
${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons)
@@ -643,28 +677,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`
<div class="video-grid">
- ${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))} />
</${LazyTile}>
- `)}
+ `; })}
${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 +764,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
</div>
${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); }} />
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
index 24c23bc..018746b 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/title_parse.py
@@ -52,14 +52,8 @@ _SPECIALS_RE = re.compile(r"\b(?:bonus|extras?|specials?)\b", re.IGNORECASE)
# some other word that merely starts with "s" followed by digits.
_SEASON_ABBREV_RE = re.compile(r"^s(\d{1,2})$", re.IGNORECASE)
-_ROMAN_NUMERALS = {
- 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI",
- 7: "VII", 8: "VIII", 9: "IX", 10: "X",
-}
-
-
def _roman_to_int(s: str) -> int | None:
- values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100}
+ values = {"i": 1, "v": 5, "x": 10, "l": 50, "c": 100, "d": 500, "m": 1000}
s = s.lower()
if not s or any(c not in values for c in s):
return None
@@ -72,6 +66,49 @@ def _roman_to_int(s: str) -> int | None:
return total or None
+def _int_to_roman(n: int) -> str | None:
+ if not 1 <= n <= 39:
+ return None
+ out = []
+ for val, sym in ((10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")):
+ while n >= val:
+ out.append(sym)
+ n -= val
+ return "".join(out)
+
+
+# Spelled-out sequel indices, English + French, plus a few ordinals.
+_NUMBER_WORDS: dict[str, int] = {
+ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7,
+ "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
+ "first": 1, "second": 2, "third": 3,
+ "un": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "sept": 7,
+ "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12,
+ "premier": 1, "première": 1, "deuxième": 2, "seconde": 2, "troisième": 3,
+}
+_YEAR_RE = re.compile(r"(?<!\d)(?:19|20)\d{2}(?!\d)")
+
+
+def year_in(text: str) -> int | None:
+ """First 19xx/20xx in `text`, or None — used to lift a year off a show
+ folder name ("Some.Show.2022.S01") for the search fallback (§10.1/V8)."""
+ m = _YEAR_RE.search(text or "")
+ return int(m.group(0)) if m else None
+
+
+def clean_query(s: str) -> str:
+ """
+ Punctuation → spaces for a TMDB query, *without* the extension /
+ parenthesized-year stripping `naive_title` does. `naive_title` assumes
+ a real filename; a show's `display_title` is a folder basename
+ ("Some.Show.Name" — `rsplit('.', 1)` would eat ".Name"), so it needs a
+ gentler normaliser (§10.1/V8).
+ """
+ s = re.sub(r"[._-]+", " ", s or "")
+ s = _strip_editions(s)
+ return re.sub(r"\s+", " ", s).strip()
+
+
def _strip_editions(title: str) -> str:
return re.sub(r"\s+", " ", _EDITION_RE.sub(" ", title)).strip()
@@ -90,21 +127,50 @@ def naive_title(filename: str) -> str:
return re.sub(r"\s+", " ", stem).strip()
+_PART_KEYWORDS = r"part|chapter|volume|vol|partie|chapitre|volet|livre|book|episode"
+_TRAILING_INDEX_RE = re.compile(
+ r"^(?P<base>.+?)(?:\s+(?:" + _PART_KEYWORDS + r"))?"
+ r"\s+(?P<num>\d{1,2}|[ivxlcdm]{1,6}|" + "|".join(_NUMBER_WORDS) + r")$",
+ re.IGNORECASE,
+)
+
+
+def _index_value(tok: str) -> int | None:
+ tok = tok.strip().lower()
+ if tok.isdigit():
+ v = int(tok)
+ return v if 1 <= v <= 39 else None
+ if tok in _NUMBER_WORDS:
+ return _NUMBER_WORDS[tok]
+ return _roman_to_int(tok)
+
+
def sequel_variants(title: str) -> list[str]:
"""
- A trailing sequel digit sometimes has no equivalent in the real TMDB
- title, or the real title uses a Roman numeral instead (§3.3 row 4).
- Returns extra candidates to try — empty if `title` has no trailing digit.
+ A trailing sequel index often has no exact match in the real TMDB
+ title: the file has a digit where TMDB uses a Roman numeral (or the
+ reverse), spells the number out, or wraps it as "Part N" / "Chapitre N"
+ (§3.3 row 4, §10.1/V10). Returns extra candidate titles to try — the
+ base alone, and the index re-rendered as digit and as Roman numeral.
+ Empty when `title` carries no recognisable trailing index.
"""
- m = re.match(r"^(.*\S)\s+([2-9])$", title)
+ m = _TRAILING_INDEX_RE.match(title.strip())
if not m:
return []
- base, digit = m.group(1), int(m.group(2))
- variants = [base]
- roman = _ROMAN_NUMERALS.get(digit)
- if roman:
- variants.append(f"{base} {roman}")
- return variants
+ base = m.group("base").strip()
+ if len(base) < 2:
+ return []
+ n = _index_value(m.group("num"))
+ if n is None:
+ return []
+ tok = m.group("num").lower()
+ out = [base]
+ roman = _int_to_roman(n)
+ if roman and roman.lower() != tok:
+ out.append(f"{base} {roman}")
+ if str(n) != tok:
+ out.append(f"{base} {n}")
+ return [v for v in dict.fromkeys(out) if v != title]
def season_from_folder_name(name: str) -> int | None:
diff --git a/packages/meshbay-node/src/meshbay_node/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/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index a530660..c9623df 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -51,24 +51,47 @@ def _normalize(s: str) -> str:
return re.sub(r"\s+", " ", s).strip()
-def _best_match(query_title: str, results: list[dict], keys: tuple[str, ...]) -> tuple[dict | None, float]:
+def _release_year_of(item: dict) -> int | None:
+ d = str(item.get("release_date") or item.get("first_air_date") or "")
+ return int(d[:4]) if d[:4].isdigit() else None
+
+
+def _ratio_against(qn: str, item: dict, keys: tuple[str, ...]) -> float:
+ best = 0.0
+ for k in keys:
+ val = item.get(k)
+ if val:
+ best = max(best, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio())
+ return best
+
+
+def _best_match(
+ query_title: str, results: list[dict], keys: tuple[str, ...],
+ year: int | None = None,
+) -> tuple[dict | None, float]:
"""
Trusts TMDB's own ranking (§3.3's last row — a locally-recomputed
- re-rank picked a coincidentally-closer-looking wrong show once): only
- the top result is considered. The similarity ratio is returned purely
- as a confidence signal for the caller's fallback decision, never used
- to pick a different candidate.
+ re-rank picked a coincidentally-closer-looking wrong show once): the
+ top result is what's returned. The similarity ratio rides along purely
+ as a confidence signal for the caller's fallback decision.
+
+ One narrow exception (§10.1/V9): when the top result is *not* a
+ confident textual hit (ratio < 0.6) and a `year` was requested, a
+ different result of that **exact** release year is preferred. TMDB
+ already year-filtered the search, so an entry landing on the requested
+ year is a hard corroboration, not the fuzzy string re-rank §3.3 warns
+ against — and this never overrides a confident top hit.
"""
if not results:
return None, 0.0
- top = results[0]
qn = _normalize(query_title)
- best_ratio = 0.0
- for k in keys:
- val = top.get(k)
- if val:
- best_ratio = max(best_ratio, difflib.SequenceMatcher(None, qn, _normalize(str(val))).ratio())
- return top, best_ratio
+ top = results[0]
+ top_ratio = _ratio_against(qn, top, keys)
+ if year is not None and top_ratio < 0.6:
+ for item in results:
+ if _release_year_of(item) == year:
+ return item, _ratio_against(qn, item, keys)
+ return top, top_ratio
class TmdbClient:
@@ -138,12 +161,15 @@ class TmdbClient:
params["year"] = year
data = await self._get("search/movie", params)
results = (data or {}).get("results", [])
- return _best_match(title, results, ("title", "original_title"))
+ return _best_match(title, results, ("title", "original_title"), year=year)
- async def search_tv(self, title: str) -> tuple[dict | None, float]:
- data = await self._get("search/tv", {"query": title})
+ async def search_tv(self, title: str, year: int | None = None) -> tuple[dict | None, float]:
+ params = {"query": title}
+ if year:
+ params["first_air_date_year"] = year
+ data = await self._get("search/tv", params)
results = (data or {}).get("results", [])
- return _best_match(title, results, ("name", "original_name"))
+ return _best_match(title, results, ("name", "original_name"), year=year)
async def search_movie_results(self, title: str) -> list[dict]:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 788a8f1..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,82 +3174,133 @@ 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. TMDB's own top result is still trusted per
- query (§3.3's last row — no local re-ranking of *its* list); what
- changed is that the ladder now *scores every candidate query* and
- keeps the best, instead of returning the first that merely clears
- 0.6.
+ §3.3's retry ladder — same shape for movies and shows (§10.1/V8).
+ TMDB's own top result is still trusted per query (§3.3's last row —
+ no local re-ranking of *its* list); what the ladder adds is that it
+ *scores every candidate query* and keeps the best, instead of
+ returning the first that merely clears 0.6.
The bare parsed title is the weakest query: guessit drops a
- "Volume 2", strips a real subtitle into `alternative_title`, and
- renders a sequel number where TMDB uses a Roman numeral. A wrong
- film that happened to score ~0.7 against that weak query — a
- same-year making-of documentary, or a franchise entry whose
- localized TMDB title *is* the franchise name — used to win outright
- before `alternative_title` or the Roman-numeral variant was ever
- tried. Found live (2026-08-29): a numbered sequel matched a
- same-year documentary; a two-volume film's second part matched the
- first; several franchise entries matched one early entry.
+ "Volume 2", strips a real subtitle into `alternative_title`, renders
+ a sequel index where TMDB spells it differently, and a show's folder
+ name can carry a year or release-group noise. A wrong entry that
+ scored ~0.7 against that weak query — a same-year making-of
+ documentary, a franchise entry whose localized TMDB title *is* the
+ franchise name, a season-specific promo entry standing in for a
+ whole show — used to win outright before a stronger candidate was
+ ever tried. Found live (2026-08-29).
"""
from meshbay_node.indexer import title_parse
if is_show:
title = entry.display_title or title_parse.naive_title(entry.name)
- result, ratio = await tmdb_client.search_tv(title)
- if result is None or ratio < 0.6:
- naive = title_parse.naive_title(entry.name)
- if naive != title:
- result, ratio = await tmdb_client.search_tv(naive)
- return result, ratio
-
- def _release_year(res: dict) -> int | None:
- d = str(res.get("release_date") or res.get("first_air_date") or "")
- return int(d[:4]) if d[:4].isdigit() else None
+ name_naive = title_parse.naive_title(entry.name)
+ year = title_parse.year_in(title) or title_parse.year_in(entry.name)
+ extra = [c for c in (name_naive, title_parse.clean_query(title))
+ if c and c != title]
+ return await self._tmdb_ladder(
+ tmdb_client.search_tv, title, extra, year, strong_extra=False)
parsed = title_parse.parse_movie_filename(entry.name)
title = entry.display_title or parsed.display_title or parsed.naive_title
+ strong = [c for c in (parsed.alt_title, *title_parse.sequel_variants(title)) if c]
+ extra = [c for c in (*strong, parsed.naive_title) if c and c != title]
+ return await self._tmdb_ladder(
+ tmdb_client.search_movie, title, extra, parsed.year,
+ strong_extra=bool(strong))
- # Fast path, unchanged in effect: a strong direct hit still returns
- # on the first call, so the common case costs exactly one request
- # and the new ladder below only engages in the ambiguous 0<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.85:
- return result, ratio
-
- best_result, best_score = (result, ratio) if result is not None else (None, 0.0)
+ @staticmethod
+ async def _tmdb_ladder(search_fn, primary: str, extra: list[str],
+ year: int | None, strong_extra: bool):
+ """
+ `search_fn(query, year) -> (result|None, ratio)`. Try `primary`,
+ return at once on a confident hit (ratio >= 0.85 — the common case,
+ one request). Otherwise score each `extra` candidate and keep the
+ best. `strong_extra` says whether `extra` contains anything more
+ specific than a punctuation-normalised restatement of `primary`
+ (an alternative_title, a sequel variant); when it does not and the
+ primary hit is already decent, the remaining calls are skipped
+ (§10.1/V11 — they almost never win and cost a round trip each).
+ """
+ def _year_of(res: dict) -> int | None:
+ d = str(res.get("release_date") or res.get("first_air_date") or "")
+ return int(d[:4]) if d[:4].isdigit() else None
- def _apply_year_rescue(res: dict, r: float) -> float:
- # Only for a query whose own top hit is weak on its face
- # (ratio < 0.6): TMDB already year-filtered the search, so its
- # top result landing exactly on the filename's year is a hard
- # corroborating signal that the low string ratio is a
- # localized/rearranged title, not a wrong film. Never lets year
- # equality outrank a genuinely strong textual match elsewhere.
- if r < 0.6 and parsed.year and _release_year(res) == parsed.year:
+ def _rescue(res: dict, r: float) -> float:
+ # A sub-0.6 hit whose result lands on the exact requested year:
+ # TMDB already year-filtered the search, so this is a hard
+ # corroboration that the low ratio is a localised/rearranged
+ # title, not a wrong entry. Never overrides a confident hit.
+ if r < 0.6 and year and _year_of(res) == year:
return max(r, 0.6)
return r
+ result, ratio = await search_fn(primary, year)
+ if result is not None and ratio >= 0.85:
+ return result, ratio
+ best_result, best_score = (result, ratio) if result is not None else (None, 0.0)
if best_result is not None:
- best_score = _apply_year_rescue(best_result, best_score)
+ best_score = _rescue(best_result, best_score)
+ if best_score >= 0.6 and not strong_extra:
+ return best_result, best_score
- for candidate in filter(None, [parsed.alt_title,
- *title_parse.sequel_variants(title),
- parsed.naive_title]):
- if candidate == title:
+ for candidate in extra:
+ if candidate == primary:
continue
- r2, ratio2 = await tmdb_client.search_movie(candidate, parsed.year)
- if r2 is None and parsed.year:
- # A year-filtered search that finds nothing: the filename's
- # year tag may be an edition/regional year TMDB doesn't
- # carry. Retry the same candidate unconstrained before
- # dropping it.
- r2, ratio2 = await tmdb_client.search_movie(candidate)
+ r2, ratio2 = await search_fn(candidate, year)
+ if r2 is None and year:
+ # A year-filtered search that finds nothing: the year tag
+ # may be an edition/regional year TMDB doesn't carry. Retry
+ # the candidate unconstrained before dropping it.
+ r2, ratio2 = await search_fn(candidate, None)
if r2 is None:
continue
- score2 = _apply_year_rescue(r2, ratio2)
+ score2 = _rescue(r2, ratio2)
if score2 > best_score:
best_result, best_score = r2, score2
if best_score >= 0.85:
@@ -3866,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_title_parse.py b/packages/meshbay-node/tests/test_title_parse.py
index 03fb588..ac4d434 100644
--- a/packages/meshbay-node/tests/test_title_parse.py
+++ b/packages/meshbay-node/tests/test_title_parse.py
@@ -6,12 +6,14 @@ is a manual acceptance step (§11), not something this repo's corpus holds.
from meshbay_node.indexer.title_parse import (
ParsedName,
+ clean_query,
leading_episode_number,
naive_title,
parse_episode_filename,
parse_movie_filename,
season_from_folder_name,
sequel_variants,
+ year_in,
)
@@ -69,6 +71,48 @@ def test_sequel_variants_empty_when_no_trailing_digit():
assert sequel_variants("Some Movie") == []
+# ── §10.1/V10: wider sequel-index handling ──────────────────────────────────
+
+def test_sequel_variants_roman_numeral_offers_the_digit_form():
+ v = sequel_variants("Old Frontier III")
+ assert "Old Frontier" in v
+ assert "Old Frontier 3" in v
+
+
+def test_sequel_variants_strips_a_part_keyword_wrapper():
+ v = sequel_variants("Some Saga Part 2")
+ assert "Some Saga" in v
+ assert "Some Saga II" in v
+
+
+def test_sequel_variants_reads_a_spelled_out_index():
+ v = sequel_variants("Story Chapter Three")
+ assert "Story" in v
+ assert "Story 3" in v and "Story III" in v
+
+
+def test_sequel_variants_ignores_a_trailing_word_that_is_not_an_index():
+ assert sequel_variants("The Dark Knight") == []
+ assert sequel_variants("In Bruges") == []
+
+
+def test_sequel_variants_ignores_a_four_digit_year_suffix():
+ assert sequel_variants("Blade Runner 2049") == []
+
+
+def test_year_in_lifts_a_year_from_a_show_folder_name():
+ assert year_in("Some.Show.2022.S01") == 2022
+ assert year_in("Some Show") is None
+ assert year_in("Episode 100 of 2010") == 2010
+
+
+def test_clean_query_despaces_a_folder_name_without_eating_the_last_word():
+ # naive_title would rsplit on the last dot and drop ".Name"
+ assert clean_query("Some.Show.Name") == "Some Show Name"
+ assert clean_query("Some.Show.Name.S01") == "Some Show Name S01"
+ assert naive_title("Some.Show.Name") != "Some Show Name" # the trap it avoids
+
+
# ── bug 2026-08-29: guessit peels "Volume N" off the title ─────────────────
def test_movie_volume_number_is_folded_back_into_the_title():
diff --git a/packages/meshbay-node/tests/test_tmdb.py b/packages/meshbay-node/tests/test_tmdb.py
index fcbc2a2..ab452ce 100644
--- a/packages/meshbay-node/tests/test_tmdb.py
+++ b/packages/meshbay-node/tests/test_tmdb.py
@@ -40,6 +40,59 @@ async def test_search_movie_returns_top_result_and_confidence():
await client.close()
+# ── §10.1/V9: year-exact preference, only when the top hit is weak ──────────
+
+@pytest.mark.asyncio
+async def test_year_exact_result_wins_when_the_top_hit_is_low_confidence():
+ # results[0] is TMDB's popularity #1 but a poor textual match for the
+ # query; results[2] is the exact requested year.
+ body = {"results": [
+ {"id": 1, "title": "Franchise vs. The Doctor", "release_date": "1962-10-05"},
+ {"id": 2, "title": "Franchise: Goldfinger", "release_date": "1964-09-17"},
+ {"id": 3, "title": "Second Errand", "release_date": "2002-11-20"},
+ ]}
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/movie": body})),
+ )
+ result, _ = await client.search_movie("The Franchise", 2002)
+
+ assert result["id"] == 3
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_year_is_ignored_when_the_top_hit_is_already_confident():
+ body = {"results": [
+ {"id": 1, "title": "The Franchise", "release_date": "1999-01-01"},
+ {"id": 2, "title": "Unrelated", "release_date": "2002-01-01"},
+ ]}
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/movie": body})),
+ )
+ result, ratio = await client.search_movie("The Franchise", 2002)
+
+ assert result["id"] == 1 and ratio > 0.9
+ await client.close()
+
+
+@pytest.mark.asyncio
+async def test_no_year_match_leaves_the_top_result_in_place():
+ body = {"results": [
+ {"id": 1, "title": "Something Else Entirely", "release_date": "1990-01-01"},
+ {"id": 2, "title": "Also Not It", "release_date": "1991-01-01"},
+ ]}
+ client = TmdbClient(
+ roster=FakeRoster(),
+ transport=httpx.MockTransport(_handler({"search/movie": body})),
+ )
+ result, _ = await client.search_movie("The Franchise", 2002)
+
+ assert result["id"] == 1
+ await client.close()
+
+
@pytest.mark.asyncio
async def test_search_tv_returns_top_result():
body = {"results": [{"id": 7, "name": "Some Show"}]}
diff --git a/packages/meshbay-node/tests/test_tmdb_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()
diff --git a/packages/meshbay-node/tests/test_tmdb_search_ladder.py b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
index 49b71bd..9865c77 100644
--- a/packages/meshbay-node/tests/test_tmdb_search_ladder.py
+++ b/packages/meshbay-node/tests/test_tmdb_search_ladder.py
@@ -61,8 +61,12 @@ class LadderTmdb:
return self._table[(title, None)]
return None, 0.0
- async def search_tv(self, title):
- self.calls.append(("tv", title))
+ async def search_tv(self, title, year=None):
+ self.calls.append((title, year))
+ if (title, year) in self._table:
+ return self._table[(title, year)]
+ if (title, None) in self._table:
+ return self._table[(title, None)]
return None, 0.0
@@ -80,6 +84,17 @@ async def _run(name: str, table: dict):
return result, ratio, client
+async def _run_show(display_title: str, name: str, table: dict):
+ entry = IndexEntry(
+ id="s", name=name, path="Show/S1", size=1, type="video", added_at=0,
+ display_title=display_title, season=1, episode=1,
+ )
+ client = LadderTmdb(table)
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ result, ratio = await session._tmdb_search(client, entry, is_show=True)
+ return result, ratio, client
+
+
# ── a two-part film: the parts must resolve to different entries ────────────
async def test_second_volume_resolves_to_the_second_volume():
@@ -191,3 +206,42 @@ async def test_strong_direct_match_costs_a_single_request():
_, _, client = await _run("A.Quiet.Film.2010.mkv",
{("A Quiet Film", 2010): (STANDALONE, 1.0)})
assert client.calls == [("A Quiet Film", 2010)]
+
+
+# ── §10.1/V11: a decent primary hit with nothing more specific to try ──────
+
+async def test_decent_primary_with_no_stronger_candidate_costs_one_call():
+ result, _, client = await _run(
+ "A.Quiet.Film.2010.mkv",
+ {("A Quiet Film", 2010): ({"id": "77", "title": "A Quiet Movie",
+ "release_date": "2010-01-01"}, 0.70)},
+ )
+ assert result["id"] == "77"
+ assert client.calls == [("A Quiet Film", 2010)], (
+ "no alt title, no sequel index → the punctuation-restatement fallback "
+ "is not worth a second request once the primary hit is decent")
+
+
+# ── §10.1/V8: the show branch uses the same scored ladder ─────────────────
+
+async def test_show_scored_ladder_beats_a_weak_primary_hit():
+ result, _, _ = await _run_show(
+ "Some.Show.Name.S01", "s01e01.mkv",
+ {
+ ("Some.Show.Name.S01", None): ({"id": "10", "name": "Promo Special",
+ "first_air_date": "2019-01-01"}, 0.55),
+ ("Some Show Name S01", None): ({"id": "20", "name": "Some Show Name",
+ "first_air_date": "2017-01-01"}, 0.95),
+ },
+ )
+ assert result["id"] == "20"
+
+
+async def test_show_year_in_folder_name_rescues_a_weak_hit():
+ result, ratio, _ = await _run_show(
+ "Some.Show.2019.S01", "s01e01.mkv",
+ {("Some.Show.2019.S01", 2019): ({"id": "30", "name": "Some Show",
+ "first_air_date": "2019-05-01"}, 0.30)},
+ )
+ assert result["id"] == "30"
+ assert ratio >= 0.6