From d9427573dcca4c0840757e299ad4d4fcdb46623e Mon Sep 17 00:00:00 2001 From: Yukine Date: Sat, 16 May 2026 05:17:17 +0200 Subject: [PATCH] feat(playmatch): align with upstream MetadataProvider enum and return all provider ids --- backend/handler/metadata/playmatch_handler.py | 82 +++++++++++++++---- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/backend/handler/metadata/playmatch_handler.py b/backend/handler/metadata/playmatch_handler.py index 58f05c5e4..8e9394ecf 100644 --- a/backend/handler/metadata/playmatch_handler.py +++ b/backend/handler/metadata/playmatch_handler.py @@ -16,12 +16,23 @@ from utils.context import ctx_httpx_client class PlaymatchProvider(str, Enum): IGDB = "IGDB" + SteamGridDB = "SteamGridDB" + ScreenScraper = "ScreenScraper" + MobyGames = "MobyGames" + LaunchBox = "LaunchBox" + EmuReady = "EmuReady" + OpenVGDB = "OpenVGDB" -# (rom attribute, provider tag). Playmatch drops unknown tags server-side. +# (rom attribute, provider tag). Tag casing matches Playmatch's MetadataProvider +# enum names (uppercased; Playmatch parses case-insensitively but spacing and +# underscores must match). Entries for providers Playmatch doesn't currently +# recognize are kept on purpose: Playmatch drops unknown tags server-side, and +# preserving them means older RomM clients keep submitting correct tags when +# Playmatch adds support for more providers. _PLAYMATCH_PROVIDER_TAGS: tuple[tuple[str, str], ...] = ( ("igdb_id", "IGDB"), - ("moby_id", "MOBY_GAMES"), + ("moby_id", "MOBYGAMES"), ("ss_id", "SCREENSCRAPER"), ("ra_id", "RETRO_ACHIEVEMENTS"), ("launchbox_id", "LAUNCHBOX"), @@ -35,6 +46,18 @@ _PLAYMATCH_PROVIDER_TAGS: tuple[tuple[str, str], ...] = ( ) +# Inbound providerName -> rom attribute, derived from _PLAYMATCH_PROVIDER_TAGS. +# Keyed on the uppercased tag so we can match against Playmatch's CamelCase +# MetadataProvider values (`"MobyGames"` -> `"MOBYGAMES"` -> `"moby_id"`). +_TAG_TO_ATTR: dict[str, str] = {tag: attr for attr, tag in _PLAYMATCH_PROVIDER_TAGS} + +# Subset of attrs the scan handler consumes from a Playmatch lookup. Other +# entries in _PLAYMATCH_PROVIDER_TAGS are used only for outbound suggestions. +_LOOKUP_ROM_ATTRS: frozenset[str] = frozenset( + {"igdb_id", "moby_id", "ss_id", "launchbox_id", "sgdb_id"} +) + + class GameMatchType(str, Enum): SHA256 = "SHA256" SHA1 = "SHA1" @@ -55,6 +78,20 @@ class PlaymatchExternalMetadata(TypedDict): class PlaymatchRomMatch(TypedDict): igdb_id: int | None + moby_id: int | None + ss_id: int | None + launchbox_id: int | None + sgdb_id: int | None + + +def _empty_playmatch_rom_match() -> PlaymatchRomMatch: + return PlaymatchRomMatch( + igdb_id=None, + moby_id=None, + ss_id=None, + launchbox_id=None, + sgdb_id=None, + ) class PlaymatchHandler(MetadataHandler): @@ -124,7 +161,7 @@ class PlaymatchHandler(MetadataHandler): detail="Can't connect to Playmatch, check your internet connection", ) from exc except json.JSONDecodeError as exc: - log.error("Error decoding JSON response from ScreenScraper: %s", exc) + log.error("Error decoding JSON response from Playmatch: %s", exc) return {} async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch: @@ -136,14 +173,14 @@ class PlaymatchHandler(MetadataHandler): :raises HTTPException: If the request fails or the service is unavailable. """ if not self.is_enabled(): - return PlaymatchRomMatch(igdb_id=None) + return _empty_playmatch_rom_match() first_file = next( (file for file in files if file.file_size_bytes > 0), None, ) if first_file is None: - return PlaymatchRomMatch(igdb_id=None) + return _empty_playmatch_rom_match() try: response = await self._request( @@ -157,30 +194,43 @@ class PlaymatchHandler(MetadataHandler): ) except httpx.HTTPStatusError: # We silently fail if the service is unavailable as this should not block the rest of RomM. - return PlaymatchRomMatch(igdb_id=None) + return _empty_playmatch_rom_match() game_match_type = response.get("gameMatchType", None) if game_match_type == GameMatchType.NoMatch: log.debug("No match found for the provided ROM file.") - return PlaymatchRomMatch(igdb_id=None) + return _empty_playmatch_rom_match() externalMetadata = response.get("externalMetadata", []) if len(externalMetadata) == 0: log.debug("No external metadata found for the matched ROM file.") - return PlaymatchRomMatch(igdb_id=None) - - igdb_id = None + return _empty_playmatch_rom_match() + result = _empty_playmatch_rom_match() for metadata in externalMetadata: provider_name = metadata.get("providerName", None) provider_game_id = metadata.get("providerId", None) - if provider_name == PlaymatchProvider.IGDB and provider_game_id is not None: - log.debug( - "Playmatch found IGDB match with IGDB ID: %s", provider_game_id - ) - igdb_id = int(provider_game_id) + if not provider_name or provider_game_id is None: + continue - return PlaymatchRomMatch(igdb_id=igdb_id) + attr = _TAG_TO_ATTR.get(provider_name.upper()) + if not attr or attr not in _LOOKUP_ROM_ATTRS: + continue + + try: + parsed_id = int(provider_game_id) + except (TypeError, ValueError): + log.debug( + "Playmatch returned non-int id for %s: %r", + provider_name, + provider_game_id, + ) + continue + + log.debug("Playmatch found %s match with id: %s", provider_name, parsed_id) + result[attr] = parsed_id # type: ignore[literal-required] + + return result @staticmethod def is_manual_match(form_fields_set: set[str]) -> bool: