aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_tmdb_search_ladder.py
blob: 49b71bd54a72acb4c58e4c5436e54d262c17ad6d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
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)]