aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 14:58:58 +0200
commit71b7a310ce938f072fe20f27eeeadd40685f1ad1 (patch)
tree75a7d93d58fda0e742dd9b1c94538b532c34464a /packages/meshbay-node/tests
parentb7733e812fadd6007976d262bd1d793572a36ba7 (diff)
downloadmeshbay-71b7a310ce938f072fe20f27eeeadd40685f1ad1.tar.gz
fix(node): correct TMDB movie matching, per-file overrides, rematch
A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
Diffstat (limited to 'packages/meshbay-node/tests')
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py1
-rw-r--r--packages/meshbay-node/tests/test_media_cache.py49
-rw-r--r--packages/meshbay-node/tests/test_ops_rematch_video.py71
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py56
-rw-r--r--packages/meshbay-node/tests/test_title_parse.py20
-rw-r--r--packages/meshbay-node/tests/test_tmdb_override_policy.py68
-rw-r--r--packages/meshbay-node/tests/test_tmdb_search_ladder.py193
7 files changed, 458 insertions, 0 deletions
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)]