Coverage for src/ph/compatibility.py: 99.0%

249 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 08:17 +0000

1"""Optional compatibility ratings and cached title matching.""" 

2 

3import json 

4import logging 

5import os 

6import re 

7import ssl 

8import unicodedata 

9from collections.abc import Callable, Mapping, Sequence 

10from concurrent.futures import ThreadPoolExecutor 

11from dataclasses import dataclass 

12from difflib import SequenceMatcher 

13from html import unescape 

14from pathlib import Path 

15from time import time 

16from urllib.parse import urljoin, urlparse 

17from urllib.request import Request, urlopen 

18 

19from ph.cache_policy import DEFAULT_CATALOGUE_TTL_DAYS, catalogue_ttl_seconds 

20from ph.models import Platform, SearchResult 

21from ph.store import USER_AGENT 

22 

23COMPATIBILITY_CATALOGUE_URL = "https://r36sgamelist.com" 

24LOGGER = logging.getLogger(__name__) 

25 

26_SCRIPT_PATTERN = re.compile(r'(?:src|href)="([^"?#]+\.js(?:\?[^"#]*)?)"') 

27_GAME_PATTERN = re.compile( 

28 r'\{"name":"((?:\\.|[^"\\])*)","console":"((?:\\.|[^"\\])*)",' 

29 r'"slug":"((?:\\.|[^"\\])*)"' 

30) 

31_BRACKETED_TEXT = re.compile(r"\s*[\[(][^\])]*[\])]\s*") 

32_NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]+") 

33_MATCH_THRESHOLD = 0.82 

34_AMBIGUITY_MARGIN = 0.04 

35_TITLE_NOISE = frozenset( 

36 { 

37 "beta", 

38 "demo", 

39 "en", 

40 "eng", 

41 "europe", 

42 "eur", 

43 "fr", 

44 "fre", 

45 "germany", 

46 "hack", 

47 "ita", 

48 "italy", 

49 "japan", 

50 "jpn", 

51 "korea", 

52 "proto", 

53 "prototype", 

54 "rev", 

55 "revision", 

56 "spa", 

57 "spain", 

58 "uk", 

59 "usa", 

60 "version", 

61 } 

62) 

63 

64# The upstream source assigns compatibility at console level. A title match 

65# confirms that the game is listed; actual performance can vary by device. 

66_CONSOLE_LEVELS: dict[str, str] = { 

67 "arcade": "Perfect", 

68 "capcom play system i": "Perfect", 

69 "capcom play system ii": "Perfect", 

70 "dreamcast": "Limited", 

71 "famicom": "Perfect", 

72 "gameboy advance": "Perfect", 

73 "gameboy color": "Perfect", 

74 "mega drive": "Perfect", 

75 "nes": "Perfect", 

76 "neogeo": "Perfect", 

77 "neogeo pocket": "Perfect", 

78 "neogeo pocket color": "Perfect", 

79 "nintendo 64": "Playable", 

80 "nintendo ds": "Limited", 

81 "pc engine": "Perfect", 

82 "playstation 1": "Perfect", 

83 "psp": "Limited", 

84 "sega genesis": "Perfect", 

85 "sega naomi": "Limited", 

86 "sfc": "Perfect", 

87 "snes": "Perfect", 

88} 

89 

90_CONSOLE_ALIASES: dict[str, str] = { 

91 "arcade": "arcade", 

92 "mame": "arcade", 

93 "mame 2003": "arcade", 

94 "mame 2010": "arcade", 

95 "cps1": "capcom play system i", 

96 "cps 1": "capcom play system i", 

97 "capcom play system 1": "capcom play system i", 

98 "cps2": "capcom play system ii", 

99 "cps 2": "capcom play system ii", 

100 "capcom play system 2": "capcom play system ii", 

101 "dc": "dreamcast", 

102 "dreamcast": "dreamcast", 

103 "famicom": "famicom", 

104 "gba": "gameboy advance", 

105 "game boy advance": "gameboy advance", 

106 "gameboy advance": "gameboy advance", 

107 "gbc": "gameboy color", 

108 "game boy color": "gameboy color", 

109 "gameboy color": "gameboy color", 

110 "gen": "mega drive", 

111 "genesis": "sega genesis", 

112 "mega drive": "mega drive", 

113 "sega genesis": "sega genesis", 

114 "nes": "nes", 

115 "nintendo": "nes", 

116 "nintendo entertainment system": "nes", 

117 "neogeo": "neogeo", 

118 "neo geo": "neogeo", 

119 "ngp": "neogeo pocket", 

120 "neo geo pocket": "neogeo pocket", 

121 "neogeo pocket": "neogeo pocket", 

122 "ngpc": "neogeo pocket color", 

123 "neo geo pocket color": "neogeo pocket color", 

124 "neogeo pocket color": "neogeo pocket color", 

125 "n64": "nintendo 64", 

126 "nintendo 64": "nintendo 64", 

127 "nds": "nintendo ds", 

128 "ds": "nintendo ds", 

129 "nintendo ds": "nintendo ds", 

130 "pce": "pc engine", 

131 "pc engine": "pc engine", 

132 "turbografx 16": "pc engine", 

133 "ps1": "playstation 1", 

134 "psx": "playstation 1", 

135 "playstation": "playstation 1", 

136 "playstation 1": "playstation 1", 

137 "psp": "psp", 

138 "playstation portable": "psp", 

139 "naomi": "sega naomi", 

140 "sega naomi": "sega naomi", 

141 "sfc": "sfc", 

142 "snes": "snes", 

143 "super famicom": "sfc", 

144 "super nintendo": "snes", 

145} 

146 

147_UNSUPPORTED_NAMES = frozenset( 

148 { 

149 "3ds", 

150 "gamecube", 

151 "nintendo 3ds", 

152 "nintendo switch", 

153 "nintendo wii", 

154 "nintendo wii u", 

155 "playstation 2", 

156 "playstation 3", 

157 "playstation 4", 

158 "playstation 5", 

159 "playstation vita", 

160 "ps2", 

161 "ps3", 

162 "ps4", 

163 "ps5", 

164 "ps vita", 

165 "psvita", 

166 "switch", 

167 "vita", 

168 "wii", 

169 "wii u", 

170 "wiiu", 

171 "xbox", 

172 "xbox 360", 

173 "xbox one", 

174 "xbox360", 

175 } 

176) 

177 

178type GameKey = tuple[str, str] 

179type FetchText = Callable[[str], str] 

180type TitleIndex = Mapping[str, Sequence[str]] 

181 

182 

183class CompatibilityError(RuntimeError): 

184 """The game compatibility catalogue could not be downloaded or stored.""" 

185 

186 

187@dataclass(frozen=True, slots=True) 

188class CompatibilityInfo: 

189 """Compatibility displayed next to one remote search result.""" 

190 

191 level: str 

192 title_listed: bool 

193 match_score: float | None = None 

194 

195 @property 

196 def short_label(self) -> str: 

197 """Return the compact results-list label.""" 

198 

199 if self.level == "Not listed": 

200 return self.level 

201 if self.title_listed and self.match_score is not None: 

202 return f"{self.level} - {round(self.match_score * 100)}% match" 

203 return f"{self.level}{' - listed' if self.title_listed else ''}" 

204 

205 @property 

206 def detail_label(self) -> str: 

207 """Explain whether the live catalogue reliably matched this title.""" 

208 

209 if self.level == "Not listed": 

210 return "Not listed by r36sgamelist.com" 

211 qualifier = ( 

212 f"title match {round(self.match_score * 100)}%" 

213 if self.title_listed and self.match_score is not None 

214 else "title listed" 

215 if self.title_listed 

216 else "platform rating" 

217 ) 

218 return f"{self.level} ({qualifier})" 

219 

220 

221def normalize_title(value: str) -> str: 

222 """Normalize regional/version title variants for conservative exact matching.""" 

223 

224 without_brackets = _BRACKETED_TEXT.sub(" ", value) 

225 decomposed = unicodedata.normalize("NFKD", without_brackets.casefold()) 

226 ascii_text = "".join( 

227 character for character in decomposed if not unicodedata.combining(character) 

228 ) 

229 return " ".join(_NON_ALPHANUMERIC.sub(" ", ascii_text).split()) 

230 

231 

232def normalize_console(value: str) -> str | None: 

233 """Map remote and ROM-folder console spellings to the compatibility catalogue.""" 

234 

235 normalized = " ".join(_NON_ALPHANUMERIC.sub(" ", value.casefold()).split()) 

236 return _CONSOLE_ALIASES.get(normalized) 

237 

238 

239def is_unsupported_system(value: str) -> bool: 

240 """Return whether a console family is outside the supported handheld profile.""" 

241 

242 normalized = " ".join(_NON_ALPHANUMERIC.sub(" ", value.casefold()).split()) 

243 return normalized in _UNSUPPORTED_NAMES or any( 

244 normalized.startswith(f"{name} ") or normalized.endswith(f" {name}") 

245 for name in _UNSUPPORTED_NAMES 

246 ) 

247 

248 

249def filter_supported_results(results: Sequence[SearchResult]) -> list[SearchResult]: 

250 """Remove explicitly unsupported consoles from all-platform remote results.""" 

251 

252 return [result for result in results if not is_unsupported_system(result.system)] 

253 

254 

255class GameCompatibilityClient: 

256 """Fetch and cache the frontend-only game compatibility index.""" 

257 

258 def __init__( 

259 self, 

260 cache_path: Path, 

261 base_url: str = COMPATIBILITY_CATALOGUE_URL, 

262 timeout_seconds: float = 30.0, 

263 fetch_text: FetchText | None = None, 

264 ttl_seconds: int = catalogue_ttl_seconds(DEFAULT_CATALOGUE_TTL_DAYS), 

265 ) -> None: 

266 self.cache_path = cache_path 

267 self.base_url = base_url.rstrip("/") 

268 self.timeout_seconds = timeout_seconds 

269 self._fetch_text_override = fetch_text 

270 self.ttl_seconds = ttl_seconds 

271 self._game_index: frozenset[GameKey] | None = None 

272 

273 def lookup_many( 

274 self, 

275 results: Sequence[SearchResult], 

276 platform: Platform, 

277 ) -> list[CompatibilityInfo]: 

278 """Return one compatibility record for each result, preserving order.""" 

279 

280 consoles = [self._result_console(result, platform) for result in results] 

281 should_load_index = any(console is not None for console in consoles) 

282 index = self._load_game_index() if should_load_index else frozenset() 

283 match_indexes = _build_match_indexes(index) 

284 return [ 

285 self._lookup(result.title, console, index, match_indexes.get(console, {})) 

286 for result, console in zip(results, consoles, strict=True) 

287 ] 

288 

289 def load(self) -> frozenset[GameKey] | None: 

290 """Load the local catalogue, including a stale but still usable copy.""" 

291 

292 cached = self._read_cache() 

293 if cached is not None: 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was always true

294 self._game_index = cached 

295 return cached 

296 

297 def refresh(self) -> int: 

298 """Explicitly replace the cache with the current frontend catalogue.""" 

299 

300 try: 

301 index = self._download_game_index() 

302 self._write_cache(index, strict=True) 

303 except CompatibilityError: 

304 raise 

305 except (OSError, TimeoutError, ValueError) as error: 

306 raise CompatibilityError( 

307 f"Could not update the compatibility catalogue: {error}" 

308 ) from error 

309 self._game_index = index 

310 LOGGER.info("Compatibility catalogue cached games=%d path=%s", len(index), self.cache_path) 

311 return len(index) 

312 

313 def cache_age_seconds(self) -> float | None: 

314 """Return the age of a valid local cache, or ``None`` when unavailable.""" 

315 

316 payload = self._read_cache_payload() 

317 return None if payload is None else max(0.0, time() - payload[0]) 

318 

319 def cache_is_stale(self) -> bool: 

320 """Return whether the local catalogue exceeds its configured lifetime.""" 

321 

322 age = self.cache_age_seconds() 

323 return age is not None and age > self.ttl_seconds 

324 

325 @staticmethod 

326 def _result_console(result: SearchResult, platform: Platform) -> str | None: 

327 candidates = ( 

328 result.system, 

329 platform.alias, 

330 platform.name, 

331 platform.slug, 

332 *(platform.rom_folders), 

333 ) 

334 return next( 

335 (console for value in candidates if value and (console := normalize_console(value))), 

336 None, 

337 ) 

338 

339 @staticmethod 

340 def _lookup( 

341 title: str, 

342 console: str | None, 

343 index: frozenset[GameKey], 

344 candidate_index: TitleIndex, 

345 ) -> CompatibilityInfo: 

346 if console is None: 

347 return CompatibilityInfo("Not listed", False) 

348 level = _CONSOLE_LEVELS[console] 

349 normalized = normalize_title(title) 

350 if (console, normalized) in index: 

351 return CompatibilityInfo(level, True, 1.0) 

352 candidates = { 

353 candidate 

354 for key in _title_match_keys(normalized) 

355 for candidate in candidate_index.get(key, ()) 

356 } 

357 ranked = sorted( 

358 (title_match_score(normalized, candidate), candidate) for candidate in candidates 

359 ) 

360 if not ranked: 

361 return CompatibilityInfo(level, False) 

362 best_score, _best_title = ranked[-1] 

363 second_score = ranked[-2][0] if len(ranked) > 1 else 0.0 

364 accepted = best_score >= _MATCH_THRESHOLD and ( 

365 best_score >= 0.97 or best_score - second_score >= _AMBIGUITY_MARGIN 

366 ) 

367 return CompatibilityInfo(level, accepted, best_score if accepted else None) 

368 

369 def _load_game_index(self) -> frozenset[GameKey]: 

370 if self._game_index is not None: 

371 return self._game_index 

372 cached = self._read_cache() 

373 if cached is not None: 

374 self._game_index = cached 

375 return cached 

376 try: 

377 index = self._download_game_index() 

378 except OSError, TimeoutError, ValueError: 

379 index = frozenset() 

380 if index: 

381 self._write_cache(index) 

382 self._game_index = index 

383 return index 

384 

385 def _download_game_index(self) -> frozenset[GameKey]: 

386 home = self._fetch_text(self.base_url) 

387 script_urls = tuple( 

388 dict.fromkeys( 

389 urljoin(f"{self.base_url}/", unescape(match)) 

390 for match in _SCRIPT_PATTERN.findall(home) 

391 if self._same_origin(urljoin(f"{self.base_url}/", unescape(match))) 

392 ) 

393 ) 

394 with ThreadPoolExecutor(max_workers=4) as executor: 

395 documents = (home, *executor.map(self._fetch_optional, script_urls)) 

396 index = frozenset(game for document in documents for game in parse_game_index(document)) 

397 if len(index) < 100: 

398 raise ValueError("r36sgamelist.com did not expose a usable frontend game index") 

399 return index 

400 

401 def _same_origin(self, url: str) -> bool: 

402 expected = urlparse(self.base_url) 

403 parsed = urlparse(url) 

404 return parsed.scheme == expected.scheme and parsed.netloc == expected.netloc 

405 

406 def _fetch_text(self, url: str) -> str: 

407 if self._fetch_text_override is not None: 

408 return self._fetch_text_override(url) 

409 request = Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"}) 

410 context = ssl.create_default_context() 

411 with urlopen(request, timeout=self.timeout_seconds, context=context) as response: 

412 return response.read().decode("utf-8", errors="replace") 

413 

414 def _fetch_optional(self, url: str) -> str: 

415 try: 

416 return self._fetch_text(url) 

417 except OSError, TimeoutError, ValueError: 

418 return "" 

419 

420 def _read_cache(self) -> frozenset[GameKey] | None: 

421 payload = self._read_cache_payload() 

422 return None if payload is None else payload[1] 

423 

424 def _read_cache_payload(self) -> tuple[float, frozenset[GameKey]] | None: 

425 try: 

426 payload = json.loads(self.cache_path.read_text(encoding="utf-8")) 

427 fetched_at = float(payload["fetched_at"]) 

428 games = payload["games"] 

429 if not isinstance(games, list): 

430 return None 

431 index = frozenset((str(item[0]), str(item[1])) for item in games) 

432 except IndexError, KeyError, OSError, TypeError, ValueError, json.JSONDecodeError: 

433 return None 

434 return (fetched_at, index) if index else None 

435 

436 def _write_cache(self, index: frozenset[GameKey], *, strict: bool = False) -> None: 

437 payload = {"fetched_at": time(), "games": sorted(index)} 

438 temporary = self.cache_path.with_name(self.cache_path.name + ".tmp") 

439 try: 

440 self.cache_path.parent.mkdir(parents=True, exist_ok=True) 

441 temporary.write_text( 

442 json.dumps(payload, ensure_ascii=False, separators=(",", ":")), 

443 encoding="utf-8", 

444 ) 

445 os.replace(temporary, self.cache_path) 

446 except OSError as error: 

447 temporary.unlink(missing_ok=True) 

448 if strict: 

449 raise CompatibilityError( 

450 f"Could not save the compatibility catalogue: {error}" 

451 ) from error 

452 LOGGER.warning("Could not cache the compatibility catalogue: %s", error) 

453 

454 

455def parse_game_index(document: str) -> frozenset[GameKey]: 

456 """Extract the title/console objects embedded in Next.js frontend chunks.""" 

457 

458 games: set[GameKey] = set() 

459 for raw_title, raw_console, _raw_slug in _GAME_PATTERN.findall(document): 

460 try: 

461 title = json.loads(f'"{raw_title}"') 

462 console_value = json.loads(f'"{raw_console}"') 

463 except json.JSONDecodeError: 

464 continue 

465 console = normalize_console(console_value) 

466 normalized_title = normalize_title(title) 

467 if console is not None and normalized_title: 

468 games.add((console, normalized_title)) 

469 return frozenset(games) 

470 

471 

472def title_match_score(left: str, right: str) -> float: 

473 """Score normalized titles while discounting common release metadata.""" 

474 

475 left = normalize_title(left) 

476 right = normalize_title(right) 

477 if not left or not right: 

478 return 0.0 

479 if left == right: 

480 return 1.0 

481 left_tokens = left.split() 

482 right_tokens = right.split() 

483 left_core = _core_title_tokens(left_tokens, right_tokens) 

484 right_core = _core_title_tokens(right_tokens, left_tokens) 

485 if left_core and left_core == right_core: 

486 return 0.98 

487 

488 left_value = " ".join(left_core or left_tokens) 

489 right_value = " ".join(right_core or right_tokens) 

490 sequence = SequenceMatcher(None, left_value, right_value, autojunk=False).ratio() 

491 compact = SequenceMatcher( 

492 None, 

493 left_value.replace(" ", ""), 

494 right_value.replace(" ", ""), 

495 autojunk=False, 

496 ).ratio() 

497 left_set = set(left_core or left_tokens) 

498 right_set = set(right_core or right_tokens) 

499 token_score = 2 * len(left_set & right_set) / (len(left_set) + len(right_set)) 

500 return max(sequence * 0.7 + token_score * 0.3, compact * 0.85 + token_score * 0.15) 

501 

502 

503def _core_title_tokens(tokens: Sequence[str], other_tokens: Sequence[str]) -> tuple[str, ...]: 

504 core: list[str] = [] 

505 shared = set(other_tokens) 

506 skip_revision_number = False 

507 for token in tokens: 

508 if token in ("rev", "revision"): 

509 skip_revision_number = True 

510 continue 

511 if token in _TITLE_NOISE and token not in shared: 

512 continue 

513 if skip_revision_number and re.fullmatch(r"v?\d+(?:\.\d+)*", token): 

514 skip_revision_number = False 

515 continue 

516 skip_revision_number = False 

517 core.append(token) 

518 return tuple(core) 

519 

520 

521def _build_match_indexes(index: frozenset[GameKey]) -> dict[str, dict[str, tuple[str, ...]]]: 

522 mutable: dict[str, dict[str, list[str]]] = {} 

523 for console, title in index: 

524 console_index = mutable.setdefault(console, {}) 

525 for key in _title_match_keys(title): 

526 console_index.setdefault(key, []).append(title) 

527 return { 

528 console: {key: tuple(titles) for key, titles in title_index.items()} 

529 for console, title_index in mutable.items() 

530 } 

531 

532 

533def _title_match_keys(title: str) -> frozenset[str]: 

534 tokens = normalize_title(title).split() 

535 meaningful = {token for token in tokens if len(token) >= 3 and token not in {"and", "the"}} 

536 compact = "".join(tokens) 

537 if len(compact) >= 4: 537 ↛ 539line 537 didn't jump to line 539 because the condition on line 537 was always true

538 meaningful.add(f"#{compact[:4]}") 

539 return frozenset(meaningful)