Files
romm/backend/handler/metadata/hasheous_handler.py
T
Georges-Antoine AssiandClaude Opus 5.5 f8b3173bea feat: searchable alternative titles for ROMs (#5111)
* feat: searchable alternative titles for ROMs

Add an editable "Alternative titles" field to manual metadata, folded
into the search titles alongside provider aliases. IGDB localization
titles now join the ROM's alternative names, and the canonical IGDB
name is kept as one when a regional title is displayed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: list hand-added titles in alternative_names, test the field

Address Greptile review on #5111: the response's alternative_names now
appends the hand-added titles to the first provider's, and the Alternative
titles field gets a component test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* test: type the alternative titles field lookup

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: code-review pass on alternative titles

Validate title lists from client-written metadata in one helper, list
hand-added titles first so the search text cap never drops them, and
let the titles combobox keep commas inside a title.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* refactor: simplify pass on alternative titles

Dedupe IGDB titles once in build_igdb_rom, inline the manual title
source, and reword the combobox comma comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: review-polish pass on alternative titles

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* docs: humanizer pass on alternative titles

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: hand-set alternative titles override, providers join

A non-empty manual list replaces every provider's titles, in the list
and in search; otherwise every provider's titles are joined. IGDB and
Hasheous store every title they know, the canonical name and regional
localizations included, and the ROM's displayed name is left out only
when listing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: validate manual metadata on ROM update

A malformed raw_manual_metadata is rejected with a 422 instead of being
stored and failing every later response for that ROM.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix: list each alternative title once however it is spaced

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-04 13:12:40 -04:00

1625 lines
44 KiB
Python

import json
from collections.abc import Callable
from typing import Any, Final, NotRequired, TypedDict
import httpx2
import pydash
import yarl
from fastapi import status
from adapters.services.response_validation import parse_response
from config import DEV_MODE, HASHEOUS_API_ENABLED, HASHEOUS_API_URL
from handler.filesystem.base_handler import (
normalize_provider_values,
provider_language_name,
provider_region_name,
)
from logger.logger import log
from models.rom import RomFile
from utils import get_version
from utils.context import ctx_httpx_client
from utils.datetime import parse_utc_timestamp
from utils.platform_slugs import UniversalPlatformSlug as UPS
from .base_handler import BaseRom, MetadataHandler, unavailable
from .igdb_handler import (
IGDB_AGE_RATINGS,
IGDBMetadata,
IGDBMetadataPlatform,
)
class HasheousMetadata(TypedDict):
tosec_match: bool
mame_arcade_match: bool
mame_mess_match: bool
nointro_match: bool
redump_match: bool
mame_redump_match: bool
whdload_match: bool
ra_match: bool
fbneo_match: bool
puredos_match: bool
# What the dump our hashes matched says of itself, kept by the scan.
dump_regions: NotRequired[list[str]]
dump_languages: NotRequired[list[str]]
dump_tags: NotRequired[list[str]]
class HasheousPlatform(TypedDict):
slug: str
hasheous_id: int | None
name: NotRequired[str]
igdb_id: NotRequired[int | None]
tgdb_id: NotRequired[int | None]
ra_id: NotRequired[int | None]
class HasheousRom(BaseRom):
hasheous_id: int | None
regions: NotRequired[list[str]]
languages: NotRequired[list[str]]
igdb_id: NotRequired[int | None]
slug: NotRequired[str]
igdb_metadata: NotRequired[IGDBMetadata]
tgdb_id: NotRequired[int | None]
hasheous_metadata: NotRequired[HasheousMetadata]
ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG = {UPS.DC: ["bin", "chd", "cue"]}
# Signature sources whose region data is preferred, as Hasheous spells them.
# The curated per-region dumps describe one release; the rest lump variants
# together or, for ScreenScraper, carry the game's whole release spread.
PREFERRED_SIGNATURE_SOURCES: Final = ("NoIntros", "Redump")
# Codes Hasheous prints as ISO-3166 that a filename shortcode claims for
# somewhere else: CH is Switzerland here and China in a No-Intro name, AS is
# American Samoa and Asia. The name printed beside them decides instead.
_AMBIGUOUS_COUNTRY_CODES: Final = frozenset({"ch", "as"})
def _country_name(code: str) -> str | None:
if code.strip().lower() in _AMBIGUOUS_COUNTRY_CODES:
return None
return provider_region_name(code)
def _tags_from_signatures(
signatures: dict[str, Any],
field: str,
resolve: Callable[[str], str | None],
) -> list[str]:
"""Read one dump's countries or languages out of the matched signatures.
Only `rom` is read, never the `game` beside it, which spans every release
of the title. A resolved code wins over the name Hasheous printed.
"""
if not isinstance(signatures, dict):
return []
ordered_sources = [
source for source in PREFERRED_SIGNATURE_SOURCES if source in signatures
] + [source for source in signatures if source not in PREFERRED_SIGNATURE_SOURCES]
# Every field below comes straight off the wire, so none of its shapes are
# assumed: a raise here would abort the scan of the rom.
for source in ordered_sources:
entries = signatures.get(source)
for entry in entries if isinstance(entries, list) else []:
tags = pydash.get(entry, ["rom", field])
if not isinstance(tags, dict):
continue
values = normalize_provider_values(
(
resolve(code) or (name if isinstance(name, str) else "")
for code, name in tags.items()
if isinstance(code, str)
),
resolve,
)
if values:
return values
return []
def _involved_company_names(rom: dict[str, Any], role: str) -> list[str]:
"""Company names for an IGDB involvement role.
The proxy keys its expanded lists by id, so involvements arrive as a dict
rather than the list IGDB itself returns.
"""
involved = pydash.values(rom.get("involved_companies", {}))
return pydash.compact(
pydash.map_([c for c in involved if c.get(role)], "company.name")
)
def extract_metadata_from_igdb_rom(rom: dict[str, Any]) -> IGDBMetadata:
return IGDBMetadata(
{
"youtube_video_id": (
list(rom["videos"].values())[0]["video_id"]
if rom.get("videos")
else None
),
"total_rating": str(round(rom.get("total_rating", 0.0), 2)),
"total_rating_count": rom.get("total_rating_count"),
"aggregated_rating": str(round(rom.get("aggregated_rating", 0.0), 2)),
"first_release_date": parse_utc_timestamp(
rom.get("first_release_date"), iso=True
),
"genres": pydash.map_(rom.get("genres", {}), "name"),
"franchises": pydash.compact(
[rom.get("franchise.name", None)]
+ pydash.map_(rom.get("franchises", {}), "name")
),
"alternative_names": pydash.uniq(
pydash.compact(
[
rom.get("name"),
*pydash.map_(rom.get("alternative_names", {}), "name"),
*pydash.map_(rom.get("game_localizations", {}), "name"),
]
)
),
"collections": pydash.map_(rom.get("collections", {}), "name"),
"game_modes": pydash.map_(rom.get("game_modes", {}), "name"),
# Not in `expandColumns`, so the proxy returns bare ids with no names.
"keywords": [],
"themes": [],
"player_perspectives": [],
"companies": pydash.compact(
pydash.map_(rom.get("involved_companies", {}), "company.name")
),
"publishers": _involved_company_names(rom, "publisher"),
"developers": _involved_company_names(rom, "developer"),
"platforms": [
IGDBMetadataPlatform(igdb_id=p.get("id", ""), name=p.get("name", ""))
for p in pydash.map_(rom.get("platforms", {}))
],
"age_ratings": [
IGDB_AGE_RATINGS[r]
for r in pydash.map_(rom.get("age_ratings", {}), "rating_category")
if r in IGDB_AGE_RATINGS
],
"expansions": [],
"dlcs": [],
"ports": [],
"remakes": [],
"remasters": [],
"similar_games": [],
"expanded_games": [],
# TODO: extract multiplayer modes and derive player count
"multiplayer_modes": [],
"player_count": "1",
}
)
class HasheousHandler(MetadataHandler):
def __init__(self) -> None:
self.BASE_URL = HASHEOUS_API_URL
# Cover art is linked relative to the site root, not the API path.
try:
self.BASE_ORIGIN = str(yarl.URL(self.BASE_URL).origin())
except ValueError:
log.warning(
"Invalid HASHEOUS_API_URL %r, cover art URLs may be wrong",
self.BASE_URL,
)
self.BASE_ORIGIN = ""
self.healthcheck_endpoint = f"{self.BASE_URL}/HealthCheck"
self.platform_endpoint = f"{self.BASE_URL}/Lookup/Platforms"
self.games_endpoint = f"{self.BASE_URL}/Lookup/ByHash"
self.proxy_igdb_game_endpoint = f"{self.BASE_URL}/MetadataProxy/IGDB/Game"
self.proxy_igdb_cover_endpoint = f"{self.BASE_URL}/MetadataProxy/IGDB/Cover"
self.app_api_key = (
"UUvh9ef_CddMM4xXO1iqxl9FqEt764v33LU-UiGFc0P34odXjMP9M6MTeE4JZRxZ"
if DEV_MODE
else "JNoFBA-jEh4HbxuxEHM6MVzydKoAXs9eCcp2dvcg5LRCnpp312voiWmjuaIssSzS"
)
@classmethod
def is_enabled(cls) -> bool:
"""Return whether this metadata handler is enabled."""
return HASHEOUS_API_ENABLED
async def heartbeat(self) -> bool:
if not self.is_enabled():
return False
httpx_client = ctx_httpx_client.get()
try:
response = await httpx_client.get(self.healthcheck_endpoint)
response.raise_for_status()
except Exception as e:
log.error("Error checking Hasheous API: %s", e)
return False
return bool(response)
async def _request(
self,
url: str,
method: str = "POST",
params: dict[str, Any] | None = None,
data: dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any]:
httpx_client = ctx_httpx_client.get()
# Normalize method to uppercase
method = method.upper()
if method not in ["GET", "POST"]:
raise ValueError(f"Unsupported HTTP method: {method}")
try:
log.debug(
"API request: Method=%s, URL=%s, Params=%s, Data=%s",
method,
url,
params,
data,
)
# Prepare request kwargs
request_kwargs: dict[str, Any] = {
"url": url,
"params": params,
"headers": {
"Content-Type": "application/json-patch+json",
"User-Agent": f"RomM/{get_version()}",
"X-Client-API-Key": self.app_api_key,
},
"timeout": 120,
}
# Add method-specific parameters
if method == "POST":
request_kwargs["json"] = data
res = await httpx_client.request(method, **request_kwargs)
res.raise_for_status()
return parse_response(dict[str, Any], res.content, source="Hasheous") or {}
except httpx2.HTTPStatusError as exc:
# Check if its a 404 error
if exc.response.status_code == status.HTTP_404_NOT_FOUND:
log.debug("Game not found in Hasheous API")
return {}
log.error(
"Hasheous API returned an error: %s %s",
exc.response.status_code,
exc.response.text,
)
raise unavailable("Hasheous") from exc
except httpx2.NetworkError as exc:
log.critical("Connection error: can't connect to Hasheous")
raise unavailable("Hasheous") from exc
except json.decoder.JSONDecodeError as exc:
# Log the error and return an empty dict if the response is not valid JSON
log.error(exc)
return {}
except httpx2.TimeoutException as exc:
log.error("Hasheous API timed out: %s", exc)
raise unavailable("Hasheous") from exc
def get_platform(self, slug: str) -> HasheousPlatform:
if slug not in HASHEOUS_PLATFORM_LIST:
return HasheousPlatform(hasheous_id=None, slug=slug)
platform = HASHEOUS_PLATFORM_LIST[UPS(slug)]
return HasheousPlatform(
hasheous_id=platform["id"],
slug=slug,
name=platform["name"],
igdb_id=platform["igdb_id"],
tgdb_id=platform["tgdb_id"],
ra_id=platform["ra_id"],
)
async def _lookup_by_hashes(
self, data: list[dict[str, str | None]]
) -> dict[str, Any]:
return await self._request(
self.games_endpoint,
params={
"returnAllSources": "true",
"returnFields": "Signatures, Metadata, Attributes",
},
data=data,
)
async def lookup_rom(
self, platform_slug: str, files: list[RomFile]
) -> tuple[HasheousRom, bool]:
"""Identify a ROM by its file hashes.
Returns a HasheousRom with the matched IDs, or an empty match if the
lookup fails. The lookup is best-effort and never raises to the caller,
so an unreachable Hasheous can't abort a scan.
The second value tells the two empty matches apart: True means Hasheous
answered and knows nothing about these hashes, False means we never got
an answer (disabled, no hashes to send, or the request failed). Only the
former is safe to treat as "this ROM is not in any database".
"""
fallback_rom = HasheousRom(hasheous_id=None, igdb_id=None, tgdb_id=None)
if not self.is_enabled():
return fallback_rom, False
filtered_files = [
file
for file in files
if file.file_size_bytes > 0
and file.is_top_level
and file.file_extension.lower() != "m3u"
and (
UPS(platform_slug) not in ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG
or file.file_extension
in ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG[UPS(platform_slug)]
)
]
# One request carries every top-level file's hashes, but Hasheous only
# answers it when it knows all of them.
data: list[dict[str, Any]] = []
for file in filtered_files:
hashes = file.lookup_hashes
file_hashes: dict[str, str | None] = {
"mD5": hashes.md5,
"shA1": hashes.sha1,
"crc": hashes.crc,
}
# Drop empty hashes and skip files that have none.
file_hashes = {key: value for key, value in file_hashes.items() if value}
if file_hashes:
data.append(file_hashes)
if not data:
log.warning(
"No hashes provided for Hasheous lookup. "
"At least one of md5, sha1, or crc is required."
)
return fallback_rom, False
try:
hasheous_game = await self._lookup_by_hashes(data)
# A sidecar file or an off-set disc hides the match the rest would
# make, so each file is asked alone and every answer must agree.
per_file_games: dict[Any, dict[str, Any]] = {}
if not hasheous_game and len(data) > 1:
for file_hashes in data:
game = await self._lookup_by_hashes([file_hashes])
if game:
per_file_games[game.get("id")] = game
if len(per_file_games) > 1:
break
except Exception as exc:
log.error("Hasheous hash lookup failed, skipping: %s", exc)
return fallback_rom, False
if len(per_file_games) > 1:
log.warning(
"Hasheous matched files of one rom to different games %s, skipping",
list(per_file_games),
)
return fallback_rom, False
if per_file_games:
hasheous_game = next(iter(per_file_games.values()))
if not hasheous_game:
return fallback_rom, True
metadata = hasheous_game.get("metadata", [])
attributes = hasheous_game.get("attributes", [])
signatures = hasheous_game.get("signatures", {})
igdb_id = None
tgdb_id = None
for meta in metadata:
if meta["source"] == "IGDB":
try:
# TEMP: Hasheous is slowly replacing slugs with IDs
igdb_id = int(meta["immutableId"])
except ValueError, TypeError:
log.debug(
f"Found an IGDB slug instead of an ID: {meta['immutableId']}"
)
pass
elif meta["source"] == "TheGamesDb":
tgdb_id = meta["immutableId"]
url_cover = ""
for attr in attributes:
if attr["attributeName"] == "Logo":
url_cover = f"{self.BASE_ORIGIN}{attr['link']}"
break
return (
HasheousRom(
hasheous_id=hasheous_game["id"],
name=hasheous_game.get("name", ""),
regions=_tags_from_signatures(signatures, "country", _country_name),
languages=_tags_from_signatures(
signatures, "language", provider_language_name
),
igdb_id=int(igdb_id) if igdb_id else None,
tgdb_id=int(tgdb_id) if tgdb_id else None,
url_cover=url_cover,
# Keys are Hasheous' SignatureSourceType names, spelled exactly
# as its API returns them.
hasheous_metadata=HasheousMetadata(
tosec_match="TOSEC" in signatures,
mame_arcade_match="MAMEArcade" in signatures,
mame_mess_match="MAMEMess" in signatures,
nointro_match="NoIntros" in signatures,
redump_match="Redump" in signatures,
mame_redump_match="MAMERedump" in signatures,
whdload_match="WHDLoad" in signatures,
ra_match="RetroAchievements" in signatures,
fbneo_match="FBNeo" in signatures,
puredos_match="PureDOSDAT" in signatures,
),
),
True,
)
async def get_igdb_game(self, hasheous_rom: HasheousRom) -> HasheousRom:
if not self.is_enabled():
return hasheous_rom
igdb_id = hasheous_rom.get("igdb_id", None)
if igdb_id is None:
log.info("No IGDB ID provided for Hasheous IGDB game lookup.")
return hasheous_rom
igdb_game = await self._request(
self.proxy_igdb_game_endpoint,
params={
"Id": igdb_id,
"expandColumns": "age_ratings, alternative_names, collections, cover, dlcs, expanded_games, franchise, franchises, game_localizations, game_modes, genres, involved_companies, platforms, ports, remakes, screenshots, similar_games, videos",
},
method="GET",
)
if not igdb_game:
log.debug(f"No Hasheous game found for IGDB ID {igdb_id}.")
return hasheous_rom
return HasheousRom(
{
**hasheous_rom,
"slug": igdb_game.get("slug") or hasheous_rom.get("slug") or "",
"name": igdb_game.get("name") or hasheous_rom.get("name") or "",
"summary": igdb_game.get("summary", ""),
"url_cover": self.normalize_cover_url(
pydash.get(igdb_game, "cover.url", "")
).replace("t_thumb", "t_1080p")
or hasheous_rom.get("url_cover", ""),
"url_screenshots": [
self.normalize_cover_url(s.get("url", "")).replace(
"t_thumb", "t_720p"
)
for s in pydash.get(igdb_game, "screenshots", {}).values()
]
or hasheous_rom.get("url_screenshots", []),
"igdb_metadata": extract_metadata_from_igdb_rom(igdb_game),
}
)
class SlugToHasheousId(TypedDict):
id: int
name: str
igdb_id: int | None
igdb_slug: str | None
tgdb_id: int | None
ra_id: int | None
HASHEOUS_PLATFORM_LIST: dict[UPS, SlugToHasheousId] = {
UPS._3DO: {
"id": 161825,
"igdb_id": 50,
"igdb_slug": "3do",
"name": "3DO Interactive Multiplayer",
"ra_id": 43,
"tgdb_id": None,
},
UPS.N3DS: {
"id": 62,
"igdb_id": 37,
"igdb_slug": "3ds",
"name": "Nintendo 3DS",
"ra_id": 62,
"tgdb_id": None,
},
UPS.N64DD: {
"id": 65,
"igdb_id": 416,
"igdb_slug": "64dd",
"name": "Nintendo 64DD",
"ra_id": None,
"tgdb_id": None,
},
UPS.ACORN_ARCHIMEDES: {
"id": 24,
"igdb_id": 116,
"igdb_slug": "acorn-archimedes",
"name": "Acorn Archimedes",
"ra_id": None,
"tgdb_id": None,
},
UPS.ACORN_ELECTRON: {
"id": 25,
"igdb_id": 134,
"igdb_slug": "acorn-electron",
"name": "Acorn Electron",
"ra_id": None,
"tgdb_id": None,
},
UPS.ACPC: {
"id": 28,
"igdb_id": 25,
"igdb_slug": "acpc",
"name": "Amstrad CPC",
"ra_id": 37,
"tgdb_id": None,
},
UPS.ACTION_MAX: {
"id": 232983,
"igdb_id": None,
"igdb_slug": "",
"name": "Action Max",
"ra_id": None,
"tgdb_id": None,
},
UPS.ADVENTURE_VISION: {
"id": 234388,
"igdb_id": None,
"igdb_slug": "",
"name": "Entex Adventure Vision",
"ra_id": None,
"tgdb_id": None,
},
UPS.ALTAIR_8800: {
"id": 234456,
"igdb_id": None,
"igdb_slug": "",
"name": "MITS Altair 8800",
"ra_id": None,
"tgdb_id": None,
},
UPS.AMIGA: {
"id": 3,
"igdb_id": 16,
"igdb_slug": "amiga",
"name": "Commodore Amiga",
"ra_id": 35,
"tgdb_id": None,
},
UPS.AMIGA_CD32: {
"id": 161823,
"igdb_id": 114,
"igdb_slug": "amiga-cd32",
"name": "Commodore CD32",
"ra_id": None,
"tgdb_id": None,
},
UPS.AMSTRAD_GX4000: {
"id": 61540,
"igdb_id": 506,
"igdb_slug": "amstrad-gx4000",
"name": "Amstrad GX4000",
"ra_id": None,
"tgdb_id": None,
},
UPS.AMSTRAD_PCW: {
"id": 29,
"igdb_id": 154,
"igdb_slug": "amstrad-pcw",
"name": "Amstrad PCW",
"ra_id": None,
"tgdb_id": None,
},
UPS.APF: {
"id": 61738,
"igdb_id": None,
"igdb_slug": "",
"name": "APF Imagination Machine",
"ra_id": None,
"tgdb_id": None,
},
UPS.APPLE: {
"id": 61885,
"igdb_id": None,
"igdb_slug": "",
"name": "Apple I",
"ra_id": None,
"tgdb_id": None,
},
UPS.APPLE_IIGS: {
"id": 21,
"igdb_id": 115,
"igdb_slug": "apple-iigs",
"name": "Apple IIGS",
"ra_id": None,
"tgdb_id": None,
},
UPS.APPLE_LISA: {
"id": 69659,
"igdb_id": None,
"igdb_slug": "",
"name": "Apple Lisa",
"ra_id": None,
"tgdb_id": None,
},
UPS.APPLE_PIPPIN: {
"id": 22,
"igdb_id": 476,
"igdb_slug": "apple-pippin",
"name": "Apple Pippin",
"ra_id": None,
"tgdb_id": None,
},
UPS.APPLEII: {
"id": 20,
"igdb_id": 75,
"igdb_slug": "appleii",
"name": "Apple II",
"ra_id": 38,
"tgdb_id": None,
},
UPS.APPLEIII: {
"id": 63154,
"igdb_id": None,
"igdb_slug": "",
"name": "Apple III",
"ra_id": None,
"tgdb_id": None,
},
UPS.ARCADE: {
"id": 178,
"igdb_id": 52,
"igdb_slug": "arcade",
"name": "Arcade",
"ra_id": 27,
"tgdb_id": None,
},
UPS.ARDUBOY: {
"id": 244294,
"igdb_id": 438,
"igdb_slug": "arduboy",
"name": "Arduboy",
"ra_id": None,
"tgdb_id": None,
},
UPS.ASTROCADE: {
"id": 31,
"igdb_id": 91,
"igdb_slug": "astrocade",
"name": "Bally Astrocade",
"ra_id": None,
"tgdb_id": None,
},
UPS.ATARI_ST: {
"id": 15,
"igdb_id": 63,
"igdb_slug": "atari-st",
"name": "Atari ST/STE",
"ra_id": 36,
"tgdb_id": None,
},
UPS.ATARI2600: {
"id": 12,
"igdb_id": 59,
"igdb_slug": "atari2600",
"name": "Atari 2600",
"ra_id": 25,
"tgdb_id": None,
},
UPS.ATARI5200: {
"id": 17,
"igdb_id": 66,
"igdb_slug": "atari5200",
"name": "Atari 5200",
"ra_id": 50,
"tgdb_id": None,
},
UPS.ATARI7800: {
"id": 16,
"igdb_id": 60,
"igdb_slug": "atari7800",
"name": "Atari 7800",
"ra_id": 51,
"tgdb_id": None,
},
UPS.ATARI8BIT: {
"id": 18,
"igdb_id": 65,
"igdb_slug": "atari8bit",
"name": "Atari 8-bit",
"ra_id": None,
"tgdb_id": None,
},
UPS.ATOM: {
"id": 55099,
"igdb_id": None,
"igdb_slug": "",
"name": "Acorn Atom",
"ra_id": None,
"tgdb_id": None,
},
UPS.BBCMICRO: {
"id": 26,
"igdb_id": 69,
"igdb_slug": "bbcmicro",
"name": "BBC Micro",
"ra_id": None,
"tgdb_id": None,
},
UPS.BEENA: {
"id": 82,
"igdb_id": None,
"igdb_slug": "",
"name": "Sega Advanced Pico Beena",
"ra_id": None,
"tgdb_id": None,
},
UPS.BIT_90: {
"id": 97614,
"igdb_id": None,
"igdb_slug": "",
"name": "Bit Corporation BIT 90",
"ra_id": None,
"tgdb_id": None,
},
UPS.C_PLUS_4: {
"id": 7,
"igdb_id": 94,
"igdb_slug": "c-plus-4",
"name": "Commodore Plus/4",
"ra_id": None,
"tgdb_id": None,
},
UPS.C128: {
"id": 8,
"igdb_id": None,
"igdb_slug": "",
"name": "Commodore 128",
"ra_id": None,
"tgdb_id": None,
},
UPS.C16: {
"id": 6,
"igdb_id": 93,
"igdb_slug": "c16",
"name": "Commodore 16",
"ra_id": None,
"tgdb_id": None,
},
UPS.C64: {
"id": 5,
"igdb_id": 15,
"igdb_slug": "c64",
"name": "Commodore MAX",
"ra_id": None,
"tgdb_id": None,
},
UPS.CAMPUTERS_LYNX: {
"id": 97720,
"igdb_id": None,
"igdb_slug": "",
"name": "Camputers Lynx",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_CFX_9850: {
"id": 97839,
"igdb_id": None,
"igdb_slug": "",
"name": "Casio CFX-9850",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_FP_1000: {
"id": 98757,
"igdb_id": None,
"igdb_slug": "",
"name": "Casio FP-1000 & FP-1100",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_LOOPY: {
"id": 37,
"igdb_id": 380,
"igdb_slug": "casio-loopy",
"name": "Casio Loopy",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_PB_1000: {
"id": 98771,
"igdb_id": None,
"igdb_slug": "",
"name": "Casio PB-1000",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_PV_1000: {
"id": 98793,
"igdb_id": None,
"igdb_slug": "",
"name": "Casio PV-1000",
"ra_id": None,
"tgdb_id": None,
},
UPS.CASIO_PV_2000: {
"id": 98811,
"igdb_id": None,
"igdb_slug": "",
"name": "Casio PV-2000",
"ra_id": None,
"tgdb_id": None,
},
UPS.COLECOVISION: {
"id": 39,
"igdb_id": 68,
"igdb_slug": "colecovision",
"name": "ColecoVision",
"ra_id": 44,
"tgdb_id": None,
},
UPS.COMMANDER_X16: {
"id": 54769,
"igdb_id": None,
"igdb_slug": "",
"name": "8-Bit Productions Commander X16",
"ra_id": None,
"tgdb_id": None,
},
UPS.COMMODORE_CDTV: {
"id": 9,
"igdb_id": 158,
"igdb_slug": "commodore-cdtv",
"name": "Commodore CDTV",
"ra_id": None,
"tgdb_id": None,
},
UPS.CPET: {
"id": 10,
"igdb_id": 90,
"igdb_slug": "cpet",
"name": "Commodore PET",
"ra_id": None,
"tgdb_id": None,
},
UPS.DC: {
"id": 54694,
"igdb_id": 23,
"igdb_slug": "dc",
"name": "Sega Dreamcast",
"ra_id": 40,
"tgdb_id": None,
},
UPS.DOOM: {
"id": 645195,
"igdb_id": None,
"igdb_slug": "",
"name": "PrBoom",
"ra_id": None,
"tgdb_id": None,
},
UPS.DOS: {
"id": 233075,
"igdb_id": 13,
"igdb_slug": "dos",
"name": "Microsoft DOS",
"ra_id": None,
"tgdb_id": None,
},
UPS.EXCALIBUR_64: {
"id": 97612,
"igdb_id": None,
"igdb_slug": "",
"name": "BGR Computers Excalibur 64",
"ra_id": None,
"tgdb_id": None,
},
UPS.FAIRCHILD_CHANNEL_F: {
"id": 43,
"igdb_id": 127,
"igdb_slug": "fairchild-channel-f",
"name": "Fairchild Channel F",
"ra_id": 57,
"tgdb_id": None,
},
UPS.FAMICOM: {
"id": 68,
"igdb_id": 18,
"igdb_slug": "nes",
"name": "Nintendo Entertainment System",
"ra_id": 7,
"tgdb_id": None,
},
UPS.FDS: {
"id": 54692,
"igdb_id": 51,
"igdb_slug": "fds",
"name": "Nintendo Famicom Disk System",
"ra_id": None,
"tgdb_id": None,
},
UPS.FM_TOWNS: {
"id": 238902,
"igdb_id": 118,
"igdb_slug": "fm-towns",
"name": "Fujitsu - FM Towns",
"ra_id": None,
"tgdb_id": None,
},
UPS.GAMATE: {
"id": 97616,
"igdb_id": 378,
"igdb_slug": "gamate",
"name": "Bit Corporation Gamate",
"ra_id": None,
"tgdb_id": None,
},
UPS.GAMEGEAR: {
"id": 84,
"igdb_id": 35,
"igdb_slug": "gamegear",
"name": "Sega Game Gear",
"ra_id": 15,
"tgdb_id": None,
},
UPS.GB: {
"id": 70,
"igdb_id": 33,
"igdb_slug": "gb",
"name": "Nintendo GameBoy",
"ra_id": 4,
"tgdb_id": None,
},
UPS.GBA: {
"id": 71,
"igdb_id": 24,
"igdb_slug": "gba",
"name": "Nintendo Game Boy Advance",
"ra_id": 5,
"tgdb_id": None,
},
UPS.GBC: {
"id": 72,
"igdb_id": 22,
"igdb_slug": "gbc",
"name": "Nintendo Game Boy Color",
"ra_id": 6,
"tgdb_id": None,
},
UPS.GENESIS: {
"id": 86,
"igdb_id": 29,
"igdb_slug": "genesis-slash-megadrive",
"name": "Sega Mega Drive / Genesis",
"ra_id": 1,
"tgdb_id": None,
},
UPS.INTELLIVISION: {
"id": 52,
"igdb_id": 67,
"igdb_slug": "intellivision",
"name": "Mattel Intellivision",
"ra_id": 45,
"tgdb_id": None,
},
UPS.JAGUAR: {
"id": 13,
"igdb_id": 62,
"igdb_slug": "jaguar",
"name": "Atari Jaguar",
"ra_id": 17,
"tgdb_id": None,
},
UPS.LINUX: {
"id": 233076,
"igdb_id": 3,
"igdb_slug": "linux",
"name": "Linux",
"ra_id": None,
"tgdb_id": None,
},
UPS.LYNX: {
"id": 14,
"igdb_id": 61,
"igdb_slug": "lynx",
"name": "Atari Lynx",
"ra_id": 13,
"tgdb_id": None,
},
UPS.MAC: {
"id": 30,
"igdb_id": 14,
"igdb_slug": "mac",
"name": "Apple Mac",
"ra_id": None,
"tgdb_id": None,
},
UPS.AQUARIUS: {
"id": 51,
"igdb_id": None,
"igdb_slug": "",
"name": "Mattel Aquarius",
"ra_id": None,
"tgdb_id": None,
},
UPS.MICROBEE: {
"id": 69714,
"igdb_id": None,
"igdb_slug": "",
"name": "Applied Technology MicroBee",
"ra_id": None,
"tgdb_id": None,
},
UPS.MSX: {
"id": 53,
"igdb_id": 27,
"igdb_slug": "msx",
"name": "MSX",
"ra_id": 29,
"tgdb_id": None,
},
UPS.MSX2: {
"id": 54,
"igdb_id": 53,
"igdb_slug": "msx2",
"name": "MSX 2",
"ra_id": None,
"tgdb_id": None,
},
UPS.MULTIVISION: {
"id": 52922,
"igdb_id": None,
"igdb_slug": "",
"name": "Tsukuda Original Othello Multivision",
"ra_id": None,
"tgdb_id": None,
},
UPS.N64: {
"id": 64,
"igdb_id": 4,
"igdb_slug": "n64",
"name": "Nintendo 64",
"ra_id": 2,
"tgdb_id": None,
},
UPS.NDS: {
"id": 66,
"igdb_id": 20,
"igdb_slug": "nds",
"name": "Nintendo DS",
"ra_id": 18,
"tgdb_id": None,
},
UPS.NEC_PC_6000_SERIES: {
"id": 58,
"igdb_id": 157,
"igdb_slug": "nec-pc-6000-series",
"name": "NEC PC-6000",
"ra_id": None,
"tgdb_id": None,
},
UPS.NEO_GEO_CD: {
"id": 161829,
"igdb_id": 136,
"igdb_slug": "neo-geo-cd",
"name": "Neo Geo CD",
"ra_id": 56,
"tgdb_id": None,
},
UPS.NEO_GEO_POCKET: {
"id": 97,
"igdb_id": 119,
"igdb_slug": "neo-geo-pocket",
"name": "Neo Geo Pocket",
"ra_id": 14,
"tgdb_id": None,
},
UPS.NEO_GEO_POCKET_COLOR: {
"id": 98,
"igdb_id": 120,
"igdb_slug": "neo-geo-pocket-color",
"name": "Neo Geo Pocket Color",
"ra_id": None,
"tgdb_id": None,
},
UPS.NEOGEOAES: {
"id": 96,
"igdb_id": 80,
"igdb_slug": "neogeoaes",
"name": "Neo Geo",
"ra_id": None,
"tgdb_id": None,
},
UPS.NES: {
"id": 68,
"igdb_id": 18,
"igdb_slug": "nes",
"name": "Nintendo Entertainment System",
"ra_id": 7,
"tgdb_id": None,
},
UPS.NEW_NINTENDON3DS: {
"id": 63,
"igdb_id": 137,
"igdb_slug": "new-nintendo-3ds",
"name": "Nintendo New 3DS",
"ra_id": None,
"tgdb_id": None,
},
UPS.NGC: {
"id": 73,
"igdb_id": 21,
"igdb_slug": "ngc",
"name": "Nintendo GameCube",
"ra_id": 16,
"tgdb_id": None,
},
UPS.NINTENDO_DSI: {
"id": 67,
"igdb_id": 159,
"igdb_slug": "nintendo-dsi",
"name": "Nintendo DSi",
"ra_id": 78,
"tgdb_id": None,
},
UPS.ODYSSEY: {
"id": 48,
"igdb_id": 88,
"igdb_slug": "odyssey--1",
"name": "Magnavox Odyssey",
"ra_id": None,
"tgdb_id": None,
},
UPS.ODYSSEY_2: {
"id": 49,
"igdb_id": 133,
"igdb_slug": "odyssey-2-slash-videopac-g7000",
"name": "Magnavox Odyssey 2",
"ra_id": 23,
"tgdb_id": None,
},
UPS.PC_8800_SERIES: {
"id": 57,
"igdb_id": 125,
"igdb_slug": "pc-8800-series",
"name": "NEC PC-8800",
"ra_id": None,
"tgdb_id": None,
},
UPS.PC_9800_SERIES: {
"id": 59,
"igdb_id": 149,
"igdb_slug": "pc-9800-series",
"name": "NEC PC-9000",
"ra_id": None,
"tgdb_id": None,
},
UPS.PC_JR: {
"id": 233269,
"igdb_id": None,
"igdb_slug": "",
"name": "IBM PCjr",
"ra_id": None,
"tgdb_id": None,
},
UPS.PHILIPS_CD_I: {
"id": 161827,
"igdb_id": 117,
"igdb_slug": "philips-cd-i",
"name": "Philips CD-i",
"ra_id": 42,
"tgdb_id": None,
},
UPS.POCKET_CHALLENGE_V2: {
"id": 97550,
"igdb_id": None,
"igdb_slug": "",
"name": "Benesse Pocket Challenge V2",
"ra_id": None,
"tgdb_id": None,
},
UPS.POCKET_CHALLENGE_W: {
"id": 97577,
"igdb_id": None,
"igdb_slug": "",
"name": "Benesse Pocket Challenge W",
"ra_id": None,
"tgdb_id": None,
},
UPS.POCKETSTATION: {
"id": 103,
"igdb_id": 441,
"igdb_slug": "pocketstation",
"name": "Sony PocketStation",
"ra_id": None,
"tgdb_id": None,
},
UPS.POKEMON_MINI: {
"id": 244733,
"igdb_id": 166,
"igdb_slug": "pokemon-mini",
"name": "Nintendo Pokemon Mini",
"ra_id": 24,
"tgdb_id": None,
},
UPS.PS2: {
"id": 101,
"igdb_id": 8,
"igdb_slug": "ps2",
"name": "Sony PlayStation 2",
"ra_id": 21,
"tgdb_id": None,
},
UPS.PS3: {
"id": 161830,
"igdb_id": 9,
"igdb_slug": "ps3",
"name": "Sony Playstation 3",
"ra_id": None,
"tgdb_id": None,
},
UPS.PS4: {
"id": 232986,
"igdb_id": 48,
"igdb_slug": "ps4--1",
"name": "Sony Playstation 4",
"ra_id": None,
"tgdb_id": None,
},
UPS.PS5: {
"id": 232987,
"igdb_id": 167,
"igdb_slug": "ps5",
"name": "Sony Playstation 5",
"ra_id": None,
"tgdb_id": None,
},
UPS.PSP: {
"id": 161831,
"igdb_id": 38,
"igdb_slug": "psp",
"name": "Sony Playstation Portable",
"ra_id": 41,
"tgdb_id": None,
},
UPS.PSVITA: {
"id": 102,
"igdb_id": 46,
"igdb_slug": "psvita",
"name": "Sony PlayStation Vita",
"ra_id": None,
"tgdb_id": None,
},
UPS.PSX: {
"id": 100,
"igdb_id": 7,
"igdb_slug": "ps",
"name": "Sony PlayStation",
"ra_id": 12,
"tgdb_id": None,
},
UPS.RCA_STUDIO_II: {
"id": 234745,
"igdb_id": None,
"igdb_slug": "",
"name": "RCA Studio II",
"ra_id": None,
"tgdb_id": None,
},
UPS.SATURN: {
"id": 54695,
"igdb_id": 32,
"igdb_slug": "saturn",
"name": "Sega Saturn",
"ra_id": 39,
"tgdb_id": None,
},
UPS.SC3000: {
"id": 52165,
"igdb_id": None,
"igdb_slug": "",
"name": "Sega Computer 3000",
"ra_id": None,
"tgdb_id": None,
},
UPS.SEGA_PICO: {
"id": 81,
"igdb_id": 339,
"igdb_slug": "sega-pico",
"name": "Sega Pico",
"ra_id": 68,
"tgdb_id": None,
},
UPS.SEGA32: {
"id": 80,
"igdb_id": 30,
"igdb_slug": "sega32",
"name": "Sega 32X",
"ra_id": 10,
"tgdb_id": None,
},
UPS.SEGACD: {
"id": 161828,
"igdb_id": None,
"igdb_slug": "",
"name": "Sega Mega CD / Sega CD",
"ra_id": 9,
"tgdb_id": None,
},
UPS.SERIES_X_S: {
"id": 232984,
"igdb_id": 169,
"igdb_slug": "series-x-s",
"name": "Microsoft Xbox Series X",
"ra_id": None,
"tgdb_id": None,
},
UPS.SFAM: {
"id": 233081,
"igdb_id": 58,
"igdb_slug": "sfam",
"name": "Super Famicom",
"ra_id": None,
"tgdb_id": None,
},
UPS.SG1000: {
"id": 244470,
"igdb_id": 84,
"igdb_slug": "sg1000",
"name": "SG-1000",
"ra_id": 33,
"tgdb_id": None,
},
UPS.SHARP_X68000: {
"id": 90,
"igdb_id": 121,
"igdb_slug": "sharp-x68000",
"name": "Sharp X68000",
"ra_id": 52,
"tgdb_id": None,
},
UPS.SINCLAIR_QL: {
"id": 92,
"igdb_id": 406,
"igdb_slug": "sinclair-ql",
"name": "Sinclair QL",
"ra_id": None,
"tgdb_id": None,
},
UPS.SMS: {
"id": 85,
"igdb_id": 64,
"igdb_slug": "sms",
"name": "Sega Master System",
"ra_id": 11,
"tgdb_id": None,
},
UPS.SNES: {
"id": 74,
"igdb_id": 19,
"igdb_slug": "snes",
"name": "Super Nintendo Entertainment System",
"ra_id": 3,
"tgdb_id": None,
},
UPS.SUPER_VISION_8000: {
"id": 97267,
"igdb_id": None,
"igdb_slug": "",
"name": "Bandai Super Vision 8000",
"ra_id": None,
"tgdb_id": None,
},
UPS.SWITCH: {
"id": 233067,
"igdb_id": 130,
"igdb_slug": "switch",
"name": "Nintendo Switch",
"ra_id": None,
"tgdb_id": None,
},
UPS.TG16: {
"id": 245372,
"igdb_id": 86,
"igdb_slug": "turbografx16--1",
"name": "TurboGrafx-16/PC Engine",
"ra_id": 8,
"tgdb_id": None,
},
UPS.TI_82: {
"id": 47973,
"igdb_id": None,
"igdb_slug": "",
"name": "Texas Instruments TI-82",
"ra_id": None,
"tgdb_id": None,
},
UPS.TI_83: {
"id": 243852,
"igdb_id": None,
"igdb_slug": "",
"name": "Texas Instruments TI-83",
"ra_id": None,
"tgdb_id": None,
},
UPS.TRS_80: {
"id": 105,
"igdb_id": 126,
"igdb_slug": "trs-80",
"name": "Tandy/RadioShack TRS-80",
"ra_id": None,
"tgdb_id": None,
},
UPS.TRS_80_COLOR_COMPUTER: {
"id": 106,
"igdb_id": 151,
"igdb_slug": "trs-80-color-computer",
"name": "Tandy/RadioShack TRS-80 Color Computer",
"ra_id": None,
"tgdb_id": None,
},
UPS.TURBOGRAFX_CD: {
"id": 247350,
"igdb_id": 150,
"igdb_slug": "turbografx-16-slash-pc-engine-cd",
"name": "Turbografx-16/PC Engine CD",
"ra_id": None,
"tgdb_id": None,
},
UPS.VECTREX: {
"id": 45,
"igdb_id": 70,
"igdb_slug": "vectrex",
"name": "Vectrex",
"ra_id": 46,
"tgdb_id": None,
},
UPS.VIC_20: {
"id": 4,
"igdb_id": 71,
"igdb_slug": "vic-20",
"name": "Commodore VIC20",
"ra_id": None,
"tgdb_id": None,
},
UPS.VIRTUALBOY: {
"id": 75,
"igdb_id": 87,
"igdb_slug": "virtualboy",
"name": "Nintendo Virtual Boy",
"ra_id": 28,
"tgdb_id": None,
},
UPS.SUPERVISION: {
"id": 244828,
"igdb_id": 415,
"igdb_slug": "watara-slash-quickshot-supervision",
"name": "Watara Supervision",
"ra_id": 63,
"tgdb_id": None,
},
UPS.WII: {
"id": 76,
"igdb_id": 5,
"igdb_slug": "wii",
"name": "Nintendo Wii",
"ra_id": None,
"tgdb_id": None,
},
UPS.WIIU: {
"id": 77,
"igdb_id": 41,
"igdb_slug": "wiiu",
"name": "Nintendo WiiU",
"ra_id": None,
"tgdb_id": None,
},
UPS.WIN: {
"id": 233074,
"igdb_id": 6,
"igdb_slug": "win",
"name": "Microsoft Windows",
"ra_id": None,
"tgdb_id": None,
},
UPS.WONDERSWAN: {
"id": 34,
"igdb_id": 57,
"igdb_slug": "wonderswan",
"name": "Bandai WonderSwan",
"ra_id": 53,
"tgdb_id": None,
},
UPS.WONDERSWAN_COLOR: {
"id": 35,
"igdb_id": 123,
"igdb_slug": "wonderswan-color",
"name": "Bandai WonderSwan Color",
"ra_id": None,
"tgdb_id": None,
},
UPS.X1: {
"id": 89,
"igdb_id": 77,
"igdb_slug": "x1",
"name": "Sharp X1",
"ra_id": 64,
"tgdb_id": None,
},
UPS.XBOX: {
"id": 54696,
"igdb_id": 11,
"igdb_slug": "xbox",
"name": "Microsoft Xbox",
"ra_id": None,
"tgdb_id": None,
},
UPS.XBOX360: {
"id": 54697,
"igdb_id": 12,
"igdb_slug": "xbox360",
"name": "Microsoft Xbox 360",
"ra_id": None,
"tgdb_id": None,
},
UPS.XBOXONE: {
"id": 161824,
"igdb_id": 49,
"igdb_slug": "xboxone",
"name": "Microsoft Xbox One",
"ra_id": None,
"tgdb_id": None,
},
UPS.Z88: {
"id": 97718,
"igdb_id": None,
"igdb_slug": "",
"name": "Cambridge Computer Z88",
"ra_id": None,
"tgdb_id": None,
},
UPS.ZX80: {
"id": 232985,
"igdb_id": None,
"igdb_slug": "",
"name": "Sinclair ZX80",
"ra_id": None,
"tgdb_id": None,
},
UPS.ZX81: {
"id": 94,
"igdb_id": 373,
"igdb_slug": "sinclair-zx81",
"name": "Sinclair ZX81",
"ra_id": None,
"tgdb_id": None,
},
UPS.ZXS: {
"id": 93,
"igdb_id": 26,
"igdb_slug": "zxs",
"name": "Sinclair ZX Spectrum",
"ra_id": None,
"tgdb_id": None,
},
}