Skip to content

Commit b1b7347

Browse files
authored
Merge pull request #3817 from rommapp/fix/igdb-sgdb-series-prefix-matching
fix(igdb): don't match series games to their base title
2 parents 28bc935 + b1f01ce commit b1b7347

2 files changed

Lines changed: 242 additions & 17 deletions

File tree

backend/handler/metadata/igdb_handler.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
# Regex to detect IGDB ID tags in filenames like (igdb-12345)
6565
IGDB_TAG_REGEX = re.compile(r"\(igdb-(\d+)\)", re.IGNORECASE)
6666

67+
# Jaro-Winkler score of an exact (post-normalization) title match. Only a first
68+
# pass hitting this may be trusted without widening the search.
69+
EXACT_MATCH_SCORE: Final = 1.0
70+
6771

6872
class IGDBPlatform(TypedDict):
6973
slug: str
@@ -558,6 +562,23 @@ def extract_igdb_id_from_filename(fs_name: str) -> int | None:
558562
return int(match.group(1))
559563
return None
560564

565+
def _is_prefix_superset_match(self, search_term: str, candidate_name: str) -> bool:
566+
"""Whether one title's words are a proper prefix of the other's.
567+
568+
Jaro-Winkler scores a base title and a longer variant that starts with
569+
it (e.g. "Portable Ops" vs "Portable Ops Plus") well above the match
570+
threshold, so a fuzzy pass can settle for the base when the variant is
571+
simply absent from that pass's candidates. Detecting this prefix/superset
572+
ambiguity lets the caller widen the search before committing. (#3805)
573+
"""
574+
search_tokens = self.normalize_search_term(search_term).split()
575+
candidate_tokens = self.normalize_search_term(candidate_name).split()
576+
if not search_tokens or not candidate_tokens:
577+
return False
578+
579+
shorter, longer = sorted((search_tokens, candidate_tokens), key=len)
580+
return len(shorter) < len(longer) and longer[: len(shorter)] == shorter
581+
561582
async def _search_rom(
562583
self, search_term: str, platform_igdb_id: int, with_game_type: bool = False
563584
) -> Game | None:
@@ -578,18 +599,18 @@ async def _search_rom(
578599
game_type_filter = ""
579600

580601
log.debug("Searching in games endpoint with game_type %s", game_type_filter)
581-
where_filter = f"{_build_platforms_where(platform_igdb_id)} {game_type_filter}"
602+
base_where = _build_platforms_where(platform_igdb_id)
582603

583604
# Special case for ScummVM games
584605
# https://github.com/rommapp/romm/issues/2424
585606
scummvm_platform = self.get_platform(UPS.SCUMMVM)
586607
if scummvm_platform["igdb_id"] == platform_igdb_id:
587-
where_filter = f"keywords=[{platform_igdb_id}] {game_type_filter}"
608+
base_where = f"keywords=[{platform_igdb_id}]"
588609

589610
roms = await self.igdb_service.list_games(
590611
search_term=search_term,
591612
fields=GAMES_FIELDS,
592-
where=where_filter,
613+
where=f"{base_where} {game_type_filter}",
593614
limit=self.pagination_limit,
594615
)
595616

@@ -599,12 +620,37 @@ async def _search_rom(
599620
search_term,
600621
list(games_by_name.keys()),
601622
)
602-
if best_match:
623+
624+
# Trust an exact first-pass hit outright. A non-exact hit that is only a
625+
# prefix/superset of the search term (e.g. matching "Portable Ops" for a
626+
# "Portable Ops Plus" search) may be a near-miss for a more specific
627+
# variant this pass never saw, so widen the search and re-rank across
628+
# every candidate before committing. (#3805)
629+
if best_match is not None and (
630+
best_score >= EXACT_MATCH_SCORE
631+
or not self._is_prefix_superset_match(search_term, best_match)
632+
):
603633
log.debug(
604634
f"Found match for '{search_term}' -> '{best_match}' (score: {best_score:.3f})"
605635
)
606636
return games_by_name[best_match]
607637

638+
extra_roms: list[Game] = []
639+
640+
# The game_type filter can hide a more specific variant that IGDB
641+
# classifies as an excluded type (e.g. an expansion). Re-query without
642+
# it so such variants become candidates.
643+
if game_type_filter:
644+
log.debug("Searching in games endpoint without game_type")
645+
extra_roms.extend(
646+
await self.igdb_service.list_games(
647+
search_term=search_term,
648+
fields=GAMES_FIELDS,
649+
where=base_where,
650+
limit=self.pagination_limit,
651+
)
652+
)
653+
608654
log.debug("Searching expanded in search endpoint")
609655
roms_expanded = await self.igdb_service.search(
610656
fields=SEARCH_FIELDS,
@@ -629,25 +675,30 @@ async def _search_rom(
629675
unique_game_ids,
630676
)
631677
id_filter = " | ".join(f"id={gid}" for gid in unique_game_ids)
632-
extra_roms = await self.igdb_service.list_games(
633-
fields=GAMES_FIELDS,
634-
where=f"({id_filter})",
635-
limit=self.pagination_limit,
678+
extra_roms.extend(
679+
await self.igdb_service.list_games(
680+
fields=GAMES_FIELDS,
681+
where=f"({id_filter})",
682+
limit=self.pagination_limit,
683+
)
636684
)
637685

638-
extra_games_by_name = _index_games_by_searchable_name(extra_roms)
639-
686+
if extra_roms:
687+
# Re-rank across the union of every pass so an exact variant surfaced
688+
# only after widening can outrank the first-pass near-miss on the
689+
# base title. The base stays in the pool, so it remains the fallback
690+
# when no better match exists.
691+
games_by_name = _index_games_by_searchable_name(roms + extra_roms)
640692
best_match, best_score = self.find_best_match(
641693
search_term,
642-
list(extra_games_by_name.keys()),
694+
list(games_by_name.keys()),
643695
)
644-
if best_match:
645-
log.debug(
646-
f"Found match for '{search_term}' -> '{best_match}' (score: {best_score:.3f})"
647-
)
648-
return extra_games_by_name[best_match]
649696

650-
roms.extend(extra_roms)
697+
if best_match:
698+
log.debug(
699+
f"Found match for '{search_term}' -> '{best_match}' (score: {best_score:.3f})"
700+
)
701+
return games_by_name[best_match]
651702

652703
return None
653704

backend/tests/handler/metadata/test_igdb_handler.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,177 @@ async def test_serial_at_filename_start_resolves_title(self):
705705
mock_hget.assert_awaited_once_with(PS1_SERIAL_INDEX_KEY, "SCUS-94163")
706706
assert result.get("name") == "Gran Turismo"
707707
assert result["igdb_id"] is None
708+
709+
710+
class TestIsPrefixSupersetMatch:
711+
"""Unit tests for the prefix/superset title heuristic (issue #3805)."""
712+
713+
@pytest.mark.parametrize(
714+
("search_term", "candidate", "expected"),
715+
[
716+
# A more specific variant's search term extends the base title.
717+
(
718+
"metal gear solid portable ops plus",
719+
"Metal Gear Solid: Portable Ops",
720+
True,
721+
),
722+
# Reversed: the base term is a prefix of the variant candidate.
723+
(
724+
"Metal Gear Solid: Portable Ops",
725+
"Metal Gear Solid: Portable Ops Plus",
726+
True,
727+
),
728+
("pokemon ranger shadows of almia", "Pokemon Ranger", True),
729+
# Extra word is not a trailing suffix, so not a prefix relationship.
730+
("sonic hedgehog", "Sonic the Hedgehog", False),
731+
# Identical titles are an exact match, not a prefix ambiguity.
732+
("Metal Gear Solid", "metal gear solid", False),
733+
# Unrelated titles.
734+
("contra", "Probotector", False),
735+
],
736+
)
737+
def test_prefix_superset_detection(self, search_term, candidate, expected):
738+
handler = IGDBHandler()
739+
assert handler._is_prefix_superset_match(search_term, candidate) is expected
740+
741+
742+
class TestSearchRomPrefixSupersetVariant:
743+
"""A base title that is a prefix of the searched variant must not be
744+
accepted when the more specific variant exists (issue #3805).
745+
746+
'Metal Gear Solid - Portable Ops Plus' and 'Portable Ops' (and the Pokemon
747+
Ranger series) scored the same IGDB id because the first search pass only
748+
returned the base game and its ~0.99 Jaro-Winkler score cleared the match
749+
threshold.
750+
"""
751+
752+
BASE_ID = 1001
753+
VARIANT_ID = 1002
754+
BASE_NAME = "Metal Gear Solid: Portable Ops"
755+
VARIANT_NAME = "Metal Gear Solid: Portable Ops Plus"
756+
757+
@pytest.mark.asyncio
758+
async def test_variant_excluded_by_game_type_is_recovered(self):
759+
"""When the game_type filter hides the variant (IGDB classifies it as an
760+
expansion), dropping the filter must surface it so the exact match wins
761+
over the base near-miss."""
762+
handler = IGDBHandler()
763+
764+
base = _make_game(self.BASE_ID, self.BASE_NAME)
765+
variant = _make_game(self.VARIANT_ID, self.VARIANT_NAME)
766+
767+
async def mock_list_games(
768+
search_term=None, fields=None, where=None, limit=None
769+
):
770+
# game_type-filtered pass excludes the variant (an expansion type).
771+
if where and "game_type" in where:
772+
return [base]
773+
# Re-query without the game_type filter surfaces both.
774+
return [base, variant]
775+
776+
with (
777+
patch(
778+
"handler.metadata.igdb_handler.IGDBHandler.is_enabled",
779+
return_value=True,
780+
),
781+
patch.object(
782+
handler.igdb_service,
783+
"list_games",
784+
side_effect=mock_list_games,
785+
),
786+
patch.object(
787+
handler.igdb_service,
788+
"search",
789+
new_callable=AsyncMock,
790+
return_value=[],
791+
),
792+
):
793+
result = await handler._search_rom(
794+
"metal gear solid portable ops plus",
795+
GENESIS_IGDB_ID,
796+
with_game_type=True,
797+
)
798+
799+
assert result is not None
800+
assert result["id"] == self.VARIANT_ID, (
801+
f"Expected the '{self.VARIANT_NAME}' variant (id={self.VARIANT_ID}), "
802+
f"got {result.get('name')} (id={result.get('id')}). The base title is "
803+
"only a prefix near-miss and must not win over the exact variant."
804+
)
805+
806+
@pytest.mark.asyncio
807+
async def test_base_title_still_matches_when_it_is_the_target(self):
808+
"""Scanning the base game itself must return the base on the first pass
809+
without any widening (exact match short-circuits)."""
810+
handler = IGDBHandler()
811+
812+
base = _make_game(self.BASE_ID, self.BASE_NAME)
813+
variant = _make_game(self.VARIANT_ID, self.VARIANT_NAME)
814+
815+
search_mock = AsyncMock(return_value=[])
816+
817+
async def mock_list_games(
818+
search_term=None, fields=None, where=None, limit=None
819+
):
820+
# First pass includes both; the base is an exact match.
821+
if where and "game_type" in where:
822+
return [base, variant]
823+
raise AssertionError("widening should not run for an exact match")
824+
825+
with (
826+
patch(
827+
"handler.metadata.igdb_handler.IGDBHandler.is_enabled",
828+
return_value=True,
829+
),
830+
patch.object(
831+
handler.igdb_service,
832+
"list_games",
833+
side_effect=mock_list_games,
834+
),
835+
patch.object(handler.igdb_service, "search", search_mock),
836+
):
837+
result = await handler._search_rom(
838+
"metal gear solid portable ops",
839+
GENESIS_IGDB_ID,
840+
with_game_type=True,
841+
)
842+
843+
assert result is not None
844+
assert result["id"] == self.BASE_ID
845+
search_mock.assert_not_awaited()
846+
847+
@pytest.mark.asyncio
848+
async def test_non_prefix_fuzzy_match_does_not_widen(self):
849+
"""A benign non-exact match that is not a prefix/superset must be
850+
returned from the first pass without extra queries."""
851+
handler = IGDBHandler()
852+
853+
game = _make_game(42, "Sonic the Hedgehog")
854+
search_mock = AsyncMock(return_value=[])
855+
856+
async def mock_list_games(
857+
search_term=None, fields=None, where=None, limit=None
858+
):
859+
if where and "game_type" in where:
860+
return [game]
861+
raise AssertionError("widening should not run for a non-prefix match")
862+
863+
with (
864+
patch(
865+
"handler.metadata.igdb_handler.IGDBHandler.is_enabled",
866+
return_value=True,
867+
),
868+
patch.object(
869+
handler.igdb_service,
870+
"list_games",
871+
side_effect=mock_list_games,
872+
),
873+
patch.object(handler.igdb_service, "search", search_mock),
874+
):
875+
result = await handler._search_rom(
876+
"sonic hedgehog", GENESIS_IGDB_ID, with_game_type=True
877+
)
878+
879+
assert result is not None
880+
assert result["id"] == 42
881+
search_mock.assert_not_awaited()

0 commit comments

Comments
 (0)