Skip to content

Commit 9bafd11

Browse files
authored
Merge pull request #3381 from DevYukine/feat/playmatch-multi-provider
feat(playmatch): add SteamGridDB, ScreenScraper, MobyGames & Launchbox hash support
2 parents 06eac10 + c3c6829 commit 9bafd11

2 files changed

Lines changed: 184 additions & 63 deletions

File tree

backend/handler/metadata/playmatch_handler.py

Lines changed: 98 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
22
from enum import Enum
3-
from typing import NotRequired, TypedDict
3+
from typing import Final, NotRequired, TypedDict
44

55
import httpx
66
import yarl
@@ -16,31 +16,53 @@
1616

1717
class PlaymatchProvider(str, Enum):
1818
IGDB = "IGDB"
19+
STEAM_GRID_DB = "SteamGridDB"
20+
SCREEN_SCRAPER = "ScreenScraper"
21+
MOBY_GAMES = "MobyGames"
22+
LAUNCH_BOX = "LaunchBox"
23+
EMU_READY = "EmuReady"
24+
OPEN_VGDB = "OpenVGDB"
25+
26+
27+
# Tag is the uppercased Playmatch MetadataProvider name.
28+
# Playmatch parses it case-insensitively but spacing must match.
29+
# Tags Playmatch doesn't yet know are kept so older RomM clients keep
30+
# submitting the right tag once Playmatch adds support.
31+
PLAYMATCH_TAG_TO_ATTR: Final[dict[str, str]] = {
32+
"IGDB": "igdb_id",
33+
"MOBYGAMES": "moby_id",
34+
"SCREENSCRAPER": "ss_id",
35+
"RETRO_ACHIEVEMENTS": "ra_id",
36+
"LAUNCHBOX": "launchbox_id",
37+
"HASHEOUS": "hasheous_id",
38+
"TGDB": "tgdb_id",
39+
"FLASHPOINT": "flashpoint_id",
40+
"HOWLONGTOBEAT": "hltb_id",
41+
"LIBRETRO": "libretro_id",
42+
"STEAMGRIDDB": "sgdb_id",
43+
"GAMELIST": "gamelist_id",
44+
}
45+
46+
# Rom attrs the scan handler actually consumes from a Playmatch lookup.
47+
# Other tags exist only for outbound suggestions.
48+
PLAYMATCH_LOOKUP_ROM_ATTRS: frozenset[str] = frozenset(
49+
{"igdb_id", "moby_id", "ss_id", "launchbox_id", "sgdb_id"}
50+
)
1951

20-
21-
# (rom attribute, provider tag). Playmatch drops unknown tags server-side.
22-
_PLAYMATCH_PROVIDER_TAGS: tuple[tuple[str, str], ...] = (
23-
("igdb_id", "IGDB"),
24-
("moby_id", "MOBY_GAMES"),
25-
("ss_id", "SCREENSCRAPER"),
26-
("ra_id", "RETRO_ACHIEVEMENTS"),
27-
("launchbox_id", "LAUNCHBOX"),
28-
("hasheous_id", "HASHEOUS"),
29-
("tgdb_id", "TGDB"),
30-
("flashpoint_id", "FLASHPOINT"),
31-
("hltb_id", "HOWLONGTOBEAT"),
32-
("libretro_id", "LIBRETRO"),
33-
("sgdb_id", "STEAMGRIDDB"),
34-
("gamelist_id", "GAMELIST"),
52+
# MetadataSource values (StrEnum) for which Playmatch can return ids. Typed as
53+
# strings so this module stays free of scan_handler imports. EmuReady and
54+
# OpenVGDB are in Playmatch's enum but have no RomM counterpart yet.
55+
PLAYMATCH_SUPPORTED_SOURCES: frozenset[str] = frozenset(
56+
{"igdb", "moby", "ss", "launchbox", "sgdb"}
3557
)
3658

3759

3860
class GameMatchType(str, Enum):
3961
SHA256 = "SHA256"
4062
SHA1 = "SHA1"
4163
MD5 = "MD5"
42-
FileNameAndSize = "FileNameAndSize"
43-
NoMatch = "NoMatch"
64+
FILE_NAME_AND_SIZE = "FileNameAndSize"
65+
NO_MATCH = "NoMatch"
4466

4567

4668
class PlaymatchExternalMetadata(TypedDict):
@@ -55,6 +77,17 @@ class PlaymatchExternalMetadata(TypedDict):
5577

5678
class PlaymatchRomMatch(TypedDict):
5779
igdb_id: int | None
80+
moby_id: int | None
81+
ss_id: int | None
82+
launchbox_id: int | None
83+
sgdb_id: int | None
84+
ra_id: int | None
85+
hasheous_id: int | None
86+
tgdb_id: int | None
87+
flashpoint_id: str | None
88+
hltb_id: int | None
89+
gamelist_id: str | None
90+
libretro_id: str | None
5891

5992

6093
class PlaymatchHandler(MetadataHandler):
@@ -124,7 +157,7 @@ async def _request(self, url: str, query: dict) -> dict:
124157
detail="Can't connect to Playmatch, check your internet connection",
125158
) from exc
126159
except json.JSONDecodeError as exc:
127-
log.error("Error decoding JSON response from ScreenScraper: %s", exc)
160+
log.error("Error decoding JSON response from Playmatch: %s", exc)
128161
return {}
129162

130163
async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch:
@@ -135,15 +168,30 @@ async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch:
135168
:return: A PlaymatchRomMatch objects containing the matched ROM information.
136169
:raises HTTPException: If the request fails or the service is unavailable.
137170
"""
171+
fallback_rom = PlaymatchRomMatch(
172+
igdb_id=None,
173+
moby_id=None,
174+
ss_id=None,
175+
launchbox_id=None,
176+
sgdb_id=None,
177+
ra_id=None,
178+
hasheous_id=None,
179+
tgdb_id=None,
180+
flashpoint_id=None,
181+
hltb_id=None,
182+
gamelist_id=None,
183+
libretro_id=None,
184+
)
185+
138186
if not self.is_enabled():
139-
return PlaymatchRomMatch(igdb_id=None)
187+
return fallback_rom
140188

141189
first_file = next(
142190
(file for file in files if file.file_size_bytes > 0),
143191
None,
144192
)
145193
if first_file is None:
146-
return PlaymatchRomMatch(igdb_id=None)
194+
return fallback_rom
147195

148196
try:
149197
response = await self._request(
@@ -157,45 +205,61 @@ async def lookup_rom(self, files: list[RomFile]) -> PlaymatchRomMatch:
157205
)
158206
except httpx.HTTPStatusError:
159207
# We silently fail if the service is unavailable as this should not block the rest of RomM.
160-
return PlaymatchRomMatch(igdb_id=None)
208+
return fallback_rom
161209

162210
game_match_type = response.get("gameMatchType", None)
163-
if game_match_type == GameMatchType.NoMatch:
211+
if game_match_type == GameMatchType.NO_MATCH:
164212
log.debug("No match found for the provided ROM file.")
165-
return PlaymatchRomMatch(igdb_id=None)
213+
return fallback_rom
166214

167215
externalMetadata = response.get("externalMetadata", [])
168216
if len(externalMetadata) == 0:
169217
log.debug("No external metadata found for the matched ROM file.")
170-
return PlaymatchRomMatch(igdb_id=None)
171-
172-
igdb_id = None
218+
return fallback_rom
173219

220+
result = fallback_rom
174221
for metadata in externalMetadata:
175222
provider_name = metadata.get("providerName", None)
176223
provider_game_id = metadata.get("providerId", None)
177-
if provider_name == PlaymatchProvider.IGDB and provider_game_id is not None:
224+
if not provider_name or provider_game_id is None:
225+
continue
226+
227+
attr = PLAYMATCH_TAG_TO_ATTR.get(provider_name.upper())
228+
if not attr or attr not in PLAYMATCH_LOOKUP_ROM_ATTRS:
229+
continue
230+
231+
try:
232+
parsed_id = int(provider_game_id)
233+
except (TypeError, ValueError):
178234
log.debug(
179-
"Playmatch found IGDB match with IGDB ID: %s", provider_game_id
235+
"Playmatch returned non-int ID for %s: %r",
236+
provider_name,
237+
provider_game_id,
180238
)
181-
igdb_id = int(provider_game_id)
239+
continue
240+
241+
log.debug("Playmatch found %s match with id: %s", provider_name, parsed_id)
242+
result[attr] = parsed_id # trunk-ignore(mypy/literal-required)
182243

183-
return PlaymatchRomMatch(igdb_id=igdb_id)
244+
return result
184245

185246
@staticmethod
186247
def is_manual_match(form_fields_set: set[str]) -> bool:
187248
"""True if the submitted form contains any Playmatch-tracked provider id field."""
188-
return any(attr in form_fields_set for attr, _ in _PLAYMATCH_PROVIDER_TAGS)
249+
return any(attr in form_fields_set for attr in PLAYMATCH_TAG_TO_ATTR.values())
189250

190251
async def submit_manual_match_suggestion(self, rom: Rom) -> None:
191-
"""Fire-and-forget suggestion POST. No-ops if disabled or no provider IDs are set; never raises."""
252+
"""
253+
Fire-and-forget suggestion POST.
254+
No-ops if disabled or no provider IDs are set.
255+
"""
192256
try:
193257
if not self.is_enabled():
194258
return
195259

196260
mappings = [
197261
{"provider": tag, "providerId": str(getattr(rom, attr))}
198-
for attr, tag in _PLAYMATCH_PROVIDER_TAGS
262+
for tag, attr in PLAYMATCH_TAG_TO_ATTR.items()
199263
if getattr(rom, attr, None)
200264
]
201265
if not mappings:

0 commit comments

Comments
 (0)