diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:58:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:58:58 +0200 |
| commit | 71b7a310ce938f072fe20f27eeeadd40685f1ad1 (patch) | |
| tree | 75a7d93d58fda0e742dd9b1c94538b532c34464a /packages | |
| parent | b7733e812fadd6007976d262bd1d793572a36ba7 (diff) | |
| download | meshbay-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
Diffstat (limited to 'packages')
25 files changed, 732 insertions, 27 deletions
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)] |