Files
romm/backend/handler/metadata/igdb_handler.py

1227 lines
44 KiB
Python

import functools
import re
from typing import Final, NotRequired, TypedDict
import httpx
import pydash
from adapters.services.igdb_types import GameCategory
from config import IGDB_CLIENT_ID, IGDB_CLIENT_SECRET, IS_PYTEST_RUN
from fastapi import HTTPException, status
from handler.redis_handler import async_cache
from logger.logger import log
from unidecode import unidecode as uc
from utils.context import ctx_httpx_client
from .base_hander import (
PS2_OPL_REGEX,
SONY_SERIAL_REGEX,
SWITCH_PRODUCT_ID_REGEX,
SWITCH_TITLEDB_REGEX,
MetadataHandler,
)
# Used to display the IGDB API status in the frontend
IGDB_API_ENABLED: Final = bool(IGDB_CLIENT_ID) and bool(IGDB_CLIENT_SECRET)
PS1_IGDB_ID: Final = 7
PS2_IGDB_ID: Final = 8
PSP_IGDB_ID: Final = 38
SWITCH_IGDB_ID: Final = 130
ARCADE_IGDB_IDS: Final = [52, 79, 80]
class IGDBPlatform(TypedDict):
slug: str
igdb_id: int | None
name: NotRequired[str]
category: NotRequired[str]
generation: NotRequired[str]
family_name: NotRequired[str]
family_slug: NotRequired[str]
url: NotRequired[str]
url_logo: NotRequired[str]
logo_path: NotRequired[str]
class IGDBMetadataPlatform(TypedDict):
igdb_id: int
name: str
class IGDBAgeRating(TypedDict):
rating: str
category: str
rating_cover_url: str
class IGDBRelatedGame(TypedDict):
id: int
name: str
slug: str
type: str
cover_url: str
class IGDBMetadata(TypedDict):
total_rating: str
aggregated_rating: str
first_release_date: int | None
youtube_video_id: str | None
genres: list[str]
franchises: list[str]
alternative_names: list[str]
collections: list[str]
companies: list[str]
game_modes: list[str]
age_ratings: list[IGDBAgeRating]
platforms: list[IGDBMetadataPlatform]
expansions: list[IGDBRelatedGame]
dlcs: list[IGDBRelatedGame]
remasters: list[IGDBRelatedGame]
remakes: list[IGDBRelatedGame]
expanded_games: list[IGDBRelatedGame]
ports: list[IGDBRelatedGame]
similar_games: list[IGDBRelatedGame]
class IGDBRom(TypedDict):
igdb_id: int | None
slug: NotRequired[str]
name: NotRequired[str]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[str]]
igdb_metadata: NotRequired[IGDBMetadata]
def extract_metadata_from_igdb_rom(rom: dict) -> IGDBMetadata:
return IGDBMetadata(
{
"youtube_video_id": str(pydash.get(rom, "videos[0].video_id", None)),
"total_rating": str(round(rom.get("total_rating", 0.0), 2)),
"aggregated_rating": str(round(rom.get("aggregated_rating", 0.0), 2)),
"first_release_date": rom.get("first_release_date", None),
"genres": pydash.map_(rom.get("genres", []), "name"),
"franchises": pydash.compact(
[rom.get("franchise.name", None)]
+ pydash.map_(rom.get("franchises", []), "name")
),
"alternative_names": pydash.map_(rom.get("alternative_names", []), "name"),
"collections": pydash.map_(rom.get("collections", []), "name"),
"game_modes": pydash.map_(rom.get("game_modes", []), "name"),
"companies": pydash.map_(rom.get("involved_companies", []), "company.name"),
"platforms": [
IGDBMetadataPlatform(igdb_id=p.get("id", ""), name=p.get("name", ""))
for p in rom.get("platforms", [])
],
"age_ratings": [
IGDB_AGE_RATINGS[r["rating"]]
for r in rom.get("age_ratings", [])
if r["rating"] in IGDB_AGE_RATINGS
],
"expansions": [
IGDBRelatedGame(
id=e["id"],
slug=e["slug"],
name=e["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(e, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="expansion",
)
for e in rom.get("expansions", [])
],
"dlcs": [
IGDBRelatedGame(
id=d["id"],
slug=d["slug"],
name=d["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(d, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="dlc",
)
for d in rom.get("dlcs", [])
],
"remasters": [
IGDBRelatedGame(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="remaster",
)
for r in rom.get("remasters", [])
],
"remakes": [
IGDBRelatedGame(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(r, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="remake",
)
for r in rom.get("remakes", [])
],
"expanded_games": [
IGDBRelatedGame(
id=g["id"],
slug=g["slug"],
name=g["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(g, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="expanded",
)
for g in rom.get("expanded_games", [])
],
"ports": [
IGDBRelatedGame(
id=p["id"],
slug=p["slug"],
name=p["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(p, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="port",
)
for p in rom.get("ports", [])
],
"similar_games": [
IGDBRelatedGame(
id=s["id"],
slug=s["slug"],
name=s["name"],
cover_url=MetadataHandler._normalize_cover_url(
pydash.get(s, "cover.url", "").replace("t_thumb", "t_1080p")
),
type="similar",
)
for s in rom.get("similar_games", [])
],
}
)
class IGDBBaseHandler(MetadataHandler):
def __init__(self) -> None:
self.BASE_URL = "https://api.igdb.com/v4"
self.platform_endpoint = f"{self.BASE_URL}/platforms"
self.platform_version_endpoint = f"{self.BASE_URL}/platform_versions"
self.platforms_fields = PLATFORMS_FIELDS
self.games_endpoint = f"{self.BASE_URL}/games"
self.games_fields = GAMES_FIELDS
self.search_endpoint = f"{self.BASE_URL}/search"
self.search_fields = SEARCH_FIELDS
self.pagination_limit = 200
self.twitch_auth = TwitchAuth()
self.headers = {
"Client-ID": IGDB_CLIENT_ID,
"Accept": "application/json",
}
@staticmethod
def check_twitch_token(func):
@functools.wraps(func)
async def wrapper(*args):
token = await args[0].twitch_auth.get_oauth_token()
args[0].headers["Authorization"] = f"Bearer {token}"
return await func(*args)
return wrapper
async def _request(self, url: str, data: str, timeout: int = 120) -> list:
httpx_client = ctx_httpx_client.get()
masked_headers = {}
try:
masked_headers = self._mask_sensitive_values(self.headers)
log.debug(
"API request: URL=%s, Headers=%s, Content=%s, Timeout=%s",
url,
masked_headers,
f"{data} limit {self.pagination_limit};",
timeout,
)
res = await httpx_client.post(
url,
content=f"{data} limit {self.pagination_limit};",
headers=self.headers,
timeout=timeout,
)
res.raise_for_status()
return res.json()
except httpx.LocalProtocolError as e:
if str(e) == "Illegal header value b'Bearer '":
log.critical("IGDB Error: Invalid IGDB_CLIENT_ID or IGDB_CLIENT_SECRET")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Invalid IGDB credentials",
) from e
else:
log.critical("Connection error: can't connect to IGDB")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Can't connect to IGDB, check your internet connection",
) from e
except httpx.NetworkError as exc:
log.critical("Connection error: can't connect to IGDB")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Can't connect to IGDB, check your internet connection",
) from exc
except httpx.HTTPStatusError as err:
# Retry once if the auth token is invalid
if err.response.status_code != 401:
log.error(err)
return [] # All requests to the IGDB API return a list
# Attempt to force a token refresh if the token is invalid
log.warning("Twitch token invalid: fetching a new one...")
token = await self.twitch_auth._update_twitch_token()
self.headers["Authorization"] = f"Bearer {token}"
except httpx.TimeoutException:
# Retry once the request if it times out
pass
try:
log.debug(
"Making a second attempt API request: URL=%s, Headers=%s, Content=%s, Timeout=%s",
url,
masked_headers,
f"{data} limit {self.pagination_limit};",
timeout,
)
res = await httpx_client.post(
url,
content=f"{data} limit {self.pagination_limit};",
headers=self.headers,
timeout=timeout,
)
res.raise_for_status()
except httpx.HTTPError as err:
# Log the error and return an empty list if the request fails again
log.error(err)
return []
return res.json()
async def _search_rom(
self, search_term: str, platform_igdb_id: int, with_category: bool = False
) -> dict | None:
if not platform_igdb_id:
return None
search_term = uc(search_term)
if with_category:
categories = (
GameCategory.EXPANDED_GAME,
GameCategory.MAIN_GAME,
GameCategory.PORT,
GameCategory.REMAKE,
GameCategory.REMASTER,
)
category_filter = f"& category=({','.join(map(str, categories))})"
else:
category_filter = ""
def is_exact_match(rom: dict, search_term: str) -> bool:
search_term_lower = search_term.lower()
if rom["slug"].lower() == search_term_lower:
return True
search_term_normalized = self._normalize_exact_match(search_term)
# Check both the ROM name and alternative names for an exact match.
rom_names = [rom["name"]] + [
alternative_name["name"]
for alternative_name in rom.get("alternative_names", [])
]
return any(
(
rom_name.lower() == search_term_lower
or self._normalize_exact_match(rom_name) == search_term_normalized
)
for rom_name in rom_names
)
log.debug("Searching in games endpoint with category %s", category_filter)
roms = await self._request(
self.games_endpoint,
data=f'search "{search_term}"; fields {",".join(self.games_fields)}; where platforms=[{platform_igdb_id}] {category_filter};',
)
for rom in roms:
# Return early if an exact match is found.
if is_exact_match(rom, search_term):
return rom
log.debug("Searching expanded in search endpoint")
roms_expanded = await self._request(
self.search_endpoint,
data=f'fields {",".join(self.search_fields)}; where game.platforms=[{platform_igdb_id}] & (name ~ *"{search_term}"* | alternative_name ~ *"{search_term}"*);',
)
if roms_expanded:
log.debug(
"Searching expanded in games endpoint for expanded game %s",
roms_expanded[0]["game"],
)
extra_roms = await self._request(
self.games_endpoint,
f'fields {",".join(self.games_fields)}; where id={roms_expanded[0]["game"]["id"]};',
)
for rom in extra_roms:
# Return early if an exact match is found.
if is_exact_match(rom, search_term):
return rom
roms.extend(extra_roms)
return roms[0] if roms else None
@check_twitch_token
async def get_platform(self, slug: str) -> IGDBPlatform:
if not IGDB_API_ENABLED:
return IGDBPlatform(igdb_id=None, slug=slug)
platforms = await self._request(
self.platform_endpoint,
data=f'fields {",".join(self.platforms_fields)}; where slug="{slug.lower()}";',
)
platform = pydash.get(platforms, "[0]", None)
if platform:
return IGDBPlatform(
igdb_id=platform.get("id", None),
slug=slug,
name=platform.get("name", slug),
category=IGDB_PLATFORM_CATEGORIES.get(
platform.get("category", 0), "Unknown"
),
generation=platform.get("generation", None),
family_name=pydash.get(platform, "platform_family.name", None),
family_slug=pydash.get(platform, "platform_family.slug", None),
url=platform.get("url", None),
url_logo=self._normalize_cover_url(
pydash.get(platform, "platform_logo.url", "").replace(
"t_thumb", "t_1080p"
)
),
)
# Check if platform is a version if not found
platform_versions = await self._request(
self.platform_version_endpoint,
data=f'fields {",".join(self.platforms_fields)}; where slug="{slug.lower()}";',
)
version = pydash.get(platform_versions, "[0]", None)
if version:
return IGDBPlatform(
igdb_id=version["id"],
slug=slug,
name=version["name"],
)
return IGDBPlatform(igdb_id=None, slug=slug)
@check_twitch_token
async def get_rom(self, fs_name: str, platform_igdb_id: int) -> IGDBRom:
from handler.filesystem import fs_rom_handler
if not IGDB_API_ENABLED:
return IGDBRom(igdb_id=None)
if not platform_igdb_id:
return IGDBRom(igdb_id=None)
search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name)
fallback_rom = IGDBRom(igdb_id=None)
# Support for PS2 OPL filename format
match = PS2_OPL_REGEX.match(fs_name)
if platform_igdb_id == PS2_IGDB_ID and match:
search_term = await self._ps2_opl_format(match, search_term)
fallback_rom = IGDBRom(igdb_id=None, name=search_term)
# Support for sony serial filename format (PS, PS3, PS3)
match = SONY_SERIAL_REGEX.search(fs_name, re.IGNORECASE)
if platform_igdb_id == PS1_IGDB_ID and match:
search_term = await self._ps1_serial_format(match, search_term)
fallback_rom = IGDBRom(igdb_id=None, name=search_term)
if platform_igdb_id == PS2_IGDB_ID and match:
search_term = await self._ps2_serial_format(match, search_term)
fallback_rom = IGDBRom(igdb_id=None, name=search_term)
if platform_igdb_id == PSP_IGDB_ID and match:
search_term = await self._psp_serial_format(match, search_term)
fallback_rom = IGDBRom(igdb_id=None, name=search_term)
# Support for switch titleID filename format
match = SWITCH_TITLEDB_REGEX.search(fs_name)
if platform_igdb_id == SWITCH_IGDB_ID and match:
search_term, index_entry = await self._switch_titledb_format(
match, search_term
)
if index_entry:
fallback_rom = IGDBRom(
igdb_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for switch productID filename format
match = SWITCH_PRODUCT_ID_REGEX.search(fs_name)
if platform_igdb_id == SWITCH_IGDB_ID and match:
search_term, index_entry = await self._switch_productid_format(
match, search_term
)
if index_entry:
fallback_rom = IGDBRom(
igdb_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for MAME arcade filename format
if platform_igdb_id in ARCADE_IGDB_IDS:
search_term = await self._mame_format(search_term)
fallback_rom = IGDBRom(igdb_id=None, name=search_term)
search_term = self.normalize_search_term(search_term)
log.debug("Searching for %s on IGDB with category", search_term)
rom = await self._search_rom(search_term, platform_igdb_id, with_category=True)
if not rom:
log.debug("Searching for %s on IGDB without category", search_term)
rom = await self._search_rom(search_term, platform_igdb_id)
# Split the search term since igdb struggles with colons
if not rom and ":" in search_term:
for term in search_term.split(":")[::-1]:
log.debug(
"Searching for %s on IGDB without category after splitting semicolon",
term,
)
rom = await self._search_rom(term, platform_igdb_id)
if rom:
break
# Some MAME games have two titles split by a slash
if not rom and "/" in search_term:
for term in search_term.split("/"):
log.debug(
"Searching for %s on IGDB without category after splitting slash",
term,
)
rom = await self._search_rom(term.strip(), platform_igdb_id)
if rom:
break
if not rom:
return fallback_rom
return IGDBRom(
igdb_id=rom["id"],
slug=rom["slug"],
name=rom["name"],
summary=rom.get("summary", ""),
url_cover=self._normalize_cover_url(
rom.get("cover", {}).get("url", "")
).replace("t_thumb", "t_1080p"),
url_screenshots=[
self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom.get("screenshots", [])
],
igdb_metadata=extract_metadata_from_igdb_rom(rom),
)
@check_twitch_token
async def get_rom_by_id(self, igdb_id: int) -> IGDBRom:
if not IGDB_API_ENABLED:
return IGDBRom(igdb_id=None)
roms = await self._request(
self.games_endpoint,
f'fields {",".join(self.games_fields)}; where id={igdb_id};',
)
rom = pydash.get(roms, "[0]", None)
if not rom:
return IGDBRom(igdb_id=None)
return IGDBRom(
igdb_id=rom["id"],
slug=rom["slug"],
name=rom["name"],
summary=rom.get("summary", ""),
url_cover=self._normalize_cover_url(
rom.get("cover", {}).get("url", "")
).replace("t_thumb", "t_1080p"),
url_screenshots=[
self._normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom.get("screenshots", [])
],
igdb_metadata=extract_metadata_from_igdb_rom(rom),
)
@check_twitch_token
async def get_matched_rom_by_id(self, igdb_id: int) -> IGDBRom | None:
if not IGDB_API_ENABLED:
return None
rom = await self.get_rom_by_id(igdb_id)
return rom if rom["igdb_id"] else None
@check_twitch_token
async def get_matched_roms_by_name(
self, search_term: str, platform_igdb_id: int | None
) -> list[IGDBRom]:
if not IGDB_API_ENABLED:
return []
if not platform_igdb_id:
return []
search_term = uc(search_term)
matched_roms = await self._request(
self.games_endpoint,
data=f'search "{search_term}"; fields {",".join(self.games_fields)}; where platforms=[{platform_igdb_id}];',
)
alternative_matched_roms = await self._request(
self.search_endpoint,
data=f'fields {",".join(self.search_fields)}; where game.platforms=[{platform_igdb_id}] & (name ~ *"{search_term}"* | alternative_name ~ *"{search_term}"*);',
)
if alternative_matched_roms:
alternative_roms_ids = []
for rom in alternative_matched_roms:
alternative_roms_ids.append(
rom.get("game").get("id", "")
if "game" in rom.keys()
else rom.get("id", "")
)
id_filter = " | ".join(
list(
map(
lambda rom: (
f'id={rom.get("game").get("id", "")}'
if "game" in rom.keys()
else f'id={rom.get("id", "")}'
),
alternative_matched_roms,
)
)
)
alternative_matched_roms = await self._request(
self.games_endpoint,
f'fields {",".join(self.games_fields)}; where {id_filter};',
)
matched_roms.extend(alternative_matched_roms)
# Use a dictionary to keep track of unique ids
unique_ids: dict[str, dict[str, str]] = {}
# Use a list comprehension to filter duplicates based on the 'id' key
matched_roms = [
unique_ids.setdefault(rom["id"], rom)
for rom in matched_roms
if rom["id"] not in unique_ids
]
return [
IGDBRom(
{ # type: ignore[misc]
k: v
for k, v in {
"igdb_id": rom["id"],
"slug": rom["slug"],
"name": rom["name"],
"summary": rom.get("summary", ""),
"url_cover": self._normalize_cover_url(
pydash.get(rom, "cover.url", "").replace(
"t_thumb", "t_1080p"
)
),
"url_screenshots": [
self._normalize_cover_url(s.get("url", "")).replace( # type: ignore[attr-defined]
"t_thumb", "t_720p"
)
for s in rom.get("screenshots", [])
],
"igdb_metadata": extract_metadata_from_igdb_rom(rom),
}.items()
if v
}
)
for rom in matched_roms
]
class TwitchAuth(MetadataHandler):
def __init__(self):
self.BASE_URL = "https://id.twitch.tv/oauth2/token"
self.params = {
"client_id": IGDB_CLIENT_ID,
"client_secret": IGDB_CLIENT_SECRET,
"grant_type": "client_credentials",
}
self.masked_params = self._mask_sensitive_values(self.params)
self.timeout = 10
async def _update_twitch_token(self) -> str:
if not IGDB_API_ENABLED:
return ""
token = None
expires_in = 0
httpx_client = ctx_httpx_client.get()
try:
log.debug(
"API request: URL=%s, Params=%s, Timeout=%s",
self.BASE_URL,
self.masked_params,
self.timeout,
)
res = await httpx_client.post(
url=self.BASE_URL,
params=self.params,
timeout=self.timeout,
)
if res.status_code == 400:
log.critical("IGDB Error: Invalid IGDB_CLIENT_ID or IGDB_CLIENT_SECRET")
return ""
response_json = res.json()
token = response_json.get("access_token", "")
expires_in = response_json.get("expires_in", 0)
except httpx.NetworkError:
log.critical("Can't connect to IGDB, check your internet connection.")
return ""
if not token or expires_in == 0:
return ""
# Set token in Redis to expire some seconds before it actually expires.
await async_cache.set("romm:twitch_token", token, ex=expires_in - 10)
log.info("Twitch token fetched!")
return token
async def get_oauth_token(self) -> str:
# Use a fake token when running tests
if IS_PYTEST_RUN:
return "test_token"
if not IGDB_API_ENABLED:
return ""
# Fetch the token cache
token = await async_cache.get("romm:twitch_token")
if not token:
log.warning("Twitch token invalid: fetching a new one...")
return await self._update_twitch_token()
return token
PLATFORMS_FIELDS = (
"id",
"name",
"category",
"generation",
"url",
"platform_family.name",
"platform_family.slug",
"platform_logo.url",
)
GAMES_FIELDS = (
"id",
"name",
"slug",
"summary",
"total_rating",
"aggregated_rating",
"first_release_date",
"artworks.url",
"cover.url",
"screenshots.url",
"platforms.id",
"platforms.name",
"alternative_names.name",
"genres.name",
"franchise.name",
"franchises.name",
"collections.name",
"game_modes.name",
"involved_companies.company.name",
"expansions.id",
"expansions.slug",
"expansions.name",
"expansions.cover.url",
"expanded_games.id",
"expanded_games.slug",
"expanded_games.name",
"expanded_games.cover.url",
"dlcs.id",
"dlcs.name",
"dlcs.slug",
"dlcs.cover.url",
"remakes.id",
"remakes.slug",
"remakes.name",
"remakes.cover.url",
"remasters.id",
"remasters.slug",
"remasters.name",
"remasters.cover.url",
"ports.id",
"ports.slug",
"ports.name",
"ports.cover.url",
"similar_games.id",
"similar_games.slug",
"similar_games.name",
"similar_games.cover.url",
"age_ratings.rating",
"videos.video_id",
)
SEARCH_FIELDS = ("game.id", "name")
# Generated from the following code on https://www.igdb.com/platforms/:
# Array.from(document.querySelectorAll(".media-body a")).map(a => ({
# slug: a.href.split("/")[4],
# name: a.innerText
# }))
IGDB_PLATFORM_LIST = (
{
"name": "1292 Advanced Programmable Video System",
"slug": "1292-advanced-programmable-video-system",
},
{"name": "3DO Interactive Multiplayer", "slug": "3do"},
{"name": "Nintendo 3DS", "slug": "3ds"},
{"name": "Acorn Archimedes", "slug": "acorn-archimedes"},
{"name": "Acorn Electron", "slug": "acorn-electron"},
{"name": "Amstrad CPC", "slug": "acpc"},
{"name": "AirConsole", "slug": "airconsole"},
{"name": "Amazon Fire TV", "slug": "amazon-fire-tv"},
{"name": "Amiga", "slug": "amiga"},
{"name": "Amiga CD32", "slug": "amiga-cd32"},
{"name": "Amstrad PCW", "slug": "amstrad-pcw"},
{"name": "Android", "slug": "android"},
{"name": "Apple IIGS", "slug": "apple-iigs"},
{"name": "Apple Pippin", "slug": "apple-pippin"},
{"name": "Apple II", "slug": "appleii"},
{"name": "Arcade", "slug": "arcade"},
{"name": "Arcadia 2001", "slug": "arcadia-2001"},
{"name": "Arduboy", "slug": "arduboy"},
{"name": "Bally Astrocade", "slug": "astrocade"},
{"name": "Atari Jaguar CD", "slug": "atari-jaguar-cd"},
{"name": "Atari ST/STE", "slug": "atari-st"},
{"name": "Atari 2600", "slug": "atari2600"},
{"name": "Atari 5200", "slug": "atari5200"},
{"name": "Atari 7800", "slug": "atari7800"},
{"name": "Atari 8-bit", "slug": "atari8bit"},
{"name": "AY-3-8500", "slug": "ay-3-8500"},
{"name": "AY-3-8603", "slug": "ay-3-8603"},
{"name": "AY-3-8605", "slug": "ay-3-8605"},
{"name": "AY-3-8606", "slug": "ay-3-8606"},
{"name": "AY-3-8607", "slug": "ay-3-8607"},
{"name": "AY-3-8610", "slug": "ay-3-8610"},
{"name": "AY-3-8710", "slug": "ay-3-8710"},
{"name": "AY-3-8760", "slug": "ay-3-8760"},
{"name": "BBC Microcomputer System", "slug": "bbcmicro"},
{"name": "BlackBerry OS", "slug": "blackberry"},
{"name": "Blu-ray Player", "slug": "blu-ray-player"},
{"name": "Web browser", "slug": "browser"},
{"name": "Commodore Plus/4", "slug": "c-plus-4"},
{"name": "Commodore 16", "slug": "c16"},
{"name": "Commodore C64/128/MAX", "slug": "c64"},
{
"name": "Call-A-Computer time-shared mainframe computer system",
"slug": "call-a-computer",
},
{"name": "Casio Loopy", "slug": "casio-loopy"},
{"name": "CDC Cyber 70", "slug": "cdccyber70"},
{"name": "ColecoVision", "slug": "colecovision"},
{"name": "Commodore CDTV", "slug": "commodore-cdtv"},
{"name": "Commodore PET", "slug": "cpet"},
{"name": "Dreamcast", "slug": "dc"},
{"name": "Donner Model 30", "slug": "donner30"},
{"name": "DOS", "slug": "dos"},
{"name": "Dragon 32/64", "slug": "dragon-32-slash-64"},
{"name": "DVD Player", "slug": "dvd-player"},
{"name": "EDSAC", "slug": "edsac--1"},
{"name": "Epoch Cassette Vision", "slug": "epoch-cassette-vision"},
{"name": "Epoch Super Cassette Vision", "slug": "epoch-super-cassette-vision"},
{"name": "Evercade", "slug": "evercade"},
{"name": "Exidy Sorcerer", "slug": "exidy-sorcerer"},
{"name": "Fairchild Channel F", "slug": "fairchild-channel-f"},
{"name": "Family Computer", "slug": "famicom"},
{"name": "Family Computer Disk System", "slug": "fds"},
{"name": "FM-7", "slug": "fm-7"},
{"name": "FM Towns", "slug": "fm-towns"},
{"name": "Game & Watch", "slug": "g-and-w"},
{"name": "Gamate", "slug": "gamate"},
{"name": "Game.com", "slug": "game-dot-com"},
{"name": "Sega Game Gear", "slug": "gamegear"},
{"name": "Game Boy", "slug": "gb"},
{"name": "Game Boy Advance", "slug": "gba"},
{"name": "Game Boy Color", "slug": "gbc"},
{"name": "Gear VR", "slug": "gear-vr"},
{"name": "Sega Mega Drive/Genesis", "slug": "genesis-slash-megadrive"},
{"name": "Gizmondo", "slug": "gizmondo"},
{"name": "Handheld Electronic LCD", "slug": "handheld-electronic-lcd"},
{"name": "HP 2100", "slug": "hp2100"},
{"name": "HP 3000", "slug": "hp3000"},
{"name": "Hyper Neo Geo 64", "slug": "hyper-neo-geo-64"},
{"name": "HyperScan", "slug": "hyperscan"},
{"name": "Intellivision", "slug": "intellivision"},
{"name": "Intellivision Amico", "slug": "intellivision-amico"},
{"name": "iOS", "slug": "ios"},
{"name": "Atari Jaguar", "slug": "jaguar"},
{"name": "Leapster", "slug": "leapster"},
{
"name": "Leapster Explorer/LeadPad Explorer",
"slug": "leapster-explorer-slash-leadpad-explorer",
},
{"name": "LeapTV", "slug": "leaptv"},
{"name": "Legacy Computer", "slug": "legacy-computer"},
{"name": "Linux", "slug": "linux"},
{"name": "Atari Lynx", "slug": "lynx"},
{"name": "Mac", "slug": "mac"},
{"name": "Mega Duck/Cougar Boy", "slug": "mega-duck-slash-cougar-boy"},
{"name": "Meta Quest 2", "slug": "meta-quest-2"},
{"name": "Meta Quest 3", "slug": "meta-quest-3"},
{"name": "Microvision", "slug": "microvision--1"},
{"name": "Legacy Mobile Device", "slug": "mobile"},
{"name": "MSX", "slug": "msx"},
{"name": "MSX2", "slug": "msx2"},
{"name": "Nintendo 64", "slug": "n64"},
{"name": "Nintendo DS", "slug": "nds"},
{"name": "NEC PC-6000 Series", "slug": "nec-pc-6000-series"},
{"name": "Neo Geo CD", "slug": "neo-geo-cd"},
{"name": "Neo Geo Pocket", "slug": "neo-geo-pocket"},
{"name": "Neo Geo Pocket Color", "slug": "neo-geo-pocket-color"},
{"name": "Neo Geo AES", "slug": "neogeoaes"},
{"name": "Neo Geo MVS", "slug": "neogeomvs"},
{"name": "Nintendo Entertainment System", "slug": "nes"},
{"name": "New Nintendo 3DS", "slug": "new-nintendo-3ds"},
{"name": "N-Gage", "slug": "ngage"},
{"name": "Nintendo GameCube", "slug": "ngc"},
{"name": "Ferranti Nimrod Computer", "slug": "nimrod"},
{"name": "Nintendo 64DD", "slug": "nintendo-64dd"},
{"name": "Nintendo DSi", "slug": "nintendo-dsi"},
{"name": "Nintendo PlayStation", "slug": "nintendo-playstation"},
{"name": "Nuon", "slug": "nuon"},
{"name": "Oculus Go", "slug": "oculus-go"},
{"name": "Oculus Quest", "slug": "oculus-quest"},
{"name": "Oculus Rift", "slug": "oculus-rift"},
{"name": "Odyssey", "slug": "odyssey--1"},
{
"name": "Odyssey 2 / Videopac G7000",
"slug": "odyssey-2-slash-videopac-g7000",
},
{"name": "OnLive Game System", "slug": "onlive-game-system"},
{"name": "OOParts", "slug": "ooparts"},
{"name": "Ouya", "slug": "ouya"},
{"name": "Palm OS", "slug": "palm-os"},
{"name": "Panasonic Jungle", "slug": "panasonic-jungle"},
{"name": "Panasonic M2", "slug": "panasonic-m2"},
{"name": "PC-50X Family", "slug": "pc-50x-family"},
{"name": "PC-8800 Series", "slug": "pc-8800-series"},
{"name": "PC-9800 Series", "slug": "pc-9800-series"},
{"name": "PC-FX", "slug": "pc-fx"},
{"name": "PDP-8", "slug": "pdp-8--1"},
{"name": "PDP-1", "slug": "pdp1"},
{"name": "PDP-10", "slug": "pdp10"},
{"name": "PDP-11", "slug": "pdp11"},
{"name": "Philips CD-i", "slug": "philips-cd-i"},
{"name": "PLATO", "slug": "plato--1"},
{"name": "Playdate", "slug": "playdate"},
{"name": "Playdia", "slug": "playdia"},
{"name": "Plug & Play", "slug": "plug-and-play"},
{"name": "PocketStation", "slug": "pocketstation"},
{"name": "Pokémon mini", "slug": "pokemon-mini"},
{"name": "PlayStation", "slug": "ps"},
{"name": "PlayStation 2", "slug": "ps2"},
{"name": "PlayStation 3", "slug": "ps3"},
{"name": "PlayStation 4", "slug": "ps4--1"},
{"name": "PlayStation 5", "slug": "ps5"},
{"name": "PlayStation Portable", "slug": "psp"},
{"name": "PlayStation Vita", "slug": "psvita"},
{"name": "PlayStation VR", "slug": "psvr"},
{"name": "PlayStation VR2", "slug": "psvr2"},
{"name": "R-Zone", "slug": "r-zone"},
{"name": "Satellaview", "slug": "satellaview"},
{"name": "Sega Saturn", "slug": "saturn"},
{"name": "SDS Sigma 7", "slug": "sdssigma7"},
{"name": "Sega Pico", "slug": "sega-pico"},
{"name": "Sega 32X", "slug": "sega32"},
{"name": "Sega CD", "slug": "segacd"},
{"name": "Xbox Series X", "slug": "series-x"},
{"name": "Super Famicom", "slug": "sfam"},
{"name": "SG-1000", "slug": "sg1000"},
{"name": "Sharp MZ-2200", "slug": "sharp-mz-2200"},
{"name": "Sharp X68000", "slug": "sharp-x68000"},
{"name": "Sinclair QL", "slug": "sinclair-ql"},
{"name": "Sinclair ZX81", "slug": "sinclair-zx81"},
{"name": "Sega Master System/Mark III", "slug": "sms"},
{"name": "Super Nintendo Entertainment System", "slug": "snes"},
{"name": "Sol-20", "slug": "sol-20"},
{"name": "Google Stadia", "slug": "stadia"},
{"name": "PC Engine SuperGrafx", "slug": "supergrafx"},
{"name": "SwanCrystal", "slug": "swancrystal"},
{"name": "Nintendo Switch", "slug": "switch"},
{"name": "Tatung Einstein", "slug": "tatung-einstein"},
{
"name": "Terebikko / See 'n Say Video Phone",
"slug": "terebikko-slash-see-n-say-video-phone",
},
{"name": "Thomson MO5", "slug": "thomson-mo5"},
{"name": "Texas Instruments TI-99", "slug": "ti-99"},
{"name": "TRS-80", "slug": "trs-80"},
{"name": "TRS-80 Color Computer", "slug": "trs-80-color-computer"},
{
"name": "Turbografx-16/PC Engine CD",
"slug": "turbografx-16-slash-pc-engine-cd",
},
{"name": "TurboGrafx-16/PC Engine", "slug": "turbografx16--1"},
{"name": "Virtual Console", "slug": "vc"},
{"name": "VC 4000", "slug": "vc-4000"},
{"name": "Vectrex", "slug": "vectrex"},
{"name": "Commodore VIC-20", "slug": "vic-20"},
{"name": "Virtual Boy", "slug": "virtualboy"},
{"name": "visionOS", "slug": "visionos"},
{
"name": "Visual Memory Unit / Visual Memory System",
"slug": "visual-memory-unit-slash-visual-memory-system",
},
{"name": "V.Smile", "slug": "vsmile"},
{
"name": "Watara/QuickShot Supervision",
"slug": "watara-slash-quickshot-supervision",
},
{"name": "Wii", "slug": "wii"},
{"name": "Wii U", "slug": "wiiu"},
{"name": "PC (Microsoft Windows)", "slug": "win"},
{"name": "Windows Mobile", "slug": "windows-mobile"},
{"name": "Windows Phone", "slug": "winphone"},
{"name": "WonderSwan", "slug": "wonderswan"},
{"name": "WonderSwan Color", "slug": "wonderswan-color"},
{"name": "Sharp X1", "slug": "x1"},
{"name": "Xbox", "slug": "xbox"},
{"name": "Xbox 360", "slug": "xbox360"},
{"name": "Xbox One", "slug": "xboxone"},
{"name": "Zeebo", "slug": "zeebo"},
{"name": "Tapwave Zodiac", "slug": "zod"},
{"name": "ZX Spectrum", "slug": "zxs"},
)
IGDB_PLATFORM_CATEGORIES: dict[int, str] = {
0: "Unknown",
1: "Console",
2: "Arcade",
3: "Platform",
4: "Operative System",
5: "Portable Console",
6: "Computer",
}
IGDB_AGE_RATINGS: dict[int, IGDBAgeRating] = {
1: {
"rating": "Three",
"category": "PEGI",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/pegi/pegi_3.png",
},
2: {
"rating": "Seven",
"category": "PEGI",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/pegi/pegi_7.png",
},
3: {
"rating": "Twelve",
"category": "PEGI",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/pegi/pegi_12.png",
},
4: {
"rating": "Sixteen",
"category": "PEGI",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/pegi/pegi_16.png",
},
5: {
"rating": "Eighteen",
"category": "PEGI",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/pegi/pegi_18.png",
},
6: {
"rating": "RP",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_rp.png",
},
7: {
"rating": "EC",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_ec.png",
},
8: {
"rating": "E",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_e.png",
},
9: {
"rating": "E10",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_e10.png",
},
10: {
"rating": "T",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_t.png",
},
11: {
"rating": "M",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_m.png",
},
12: {
"rating": "AO",
"category": "ESRB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/esrb/esrb_ao.png",
},
13: {
"rating": "CERO_A",
"category": "CERO",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/cero/cero_a.png",
},
14: {
"rating": "CERO_B",
"category": "CERO",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/cero/cero_b.png",
},
15: {
"rating": "CERO_C",
"category": "CERO",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/cero/cero_c.png",
},
16: {
"rating": "CERO_D",
"category": "CERO",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/cero/cero_d.png",
},
17: {
"rating": "CERO_Z",
"category": "CERO",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/cero/cero_z.png",
},
18: {
"rating": "USK_0",
"category": "USK",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/usk/usk_0.png",
},
19: {
"rating": "USK_6",
"category": "USK",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/usk/usk_6.png",
},
20: {
"rating": "USK_12",
"category": "USK",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/usk/usk_12.png",
},
21: {
"rating": "USK_16",
"category": "USK",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/usk/usk_16.png",
},
22: {
"rating": "USK_18",
"category": "USK",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/usk/usk_18.png",
},
23: {
"rating": "GRAC_ALL",
"category": "GRAC",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/grac/grac_all.png",
},
24: {
"rating": "GRAC_Twelve",
"category": "GRAC",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/grac/grac_12.png",
},
25: {
"rating": "GRAC_Fifteen",
"category": "GRAC",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/grac/grac_15.png",
},
26: {
"rating": "GRAC_Eighteen",
"category": "GRAC",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/grac/grac_18.png",
},
27: {
"rating": "GRAC_TESTING",
"category": "GRAC",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/grac/grac_testing.png",
},
28: {
"rating": "CLASS_IND_L",
"category": "CLASS_IND",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/class_ind/class_ind_l.png",
},
29: {
"rating": "CLASS_IND_Ten",
"category": "CLASS_IND",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/class_ind/class_ind_10.png",
},
30: {
"rating": "CLASS_IND_Twelve",
"category": "CLASS_IND",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/class_ind/class_ind_12.png",
},
31: {
"rating": "ACB_G",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_g.png",
},
32: {
"rating": "ACB_PG",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_pg.png",
},
33: {
"rating": "ACB_M",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_m.png",
},
34: {
"rating": "ACB_MA15",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_ma15.png",
},
35: {
"rating": "ACB_R18",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_r18.png",
},
36: {
"rating": "ACB_RC",
"category": "ACB",
"rating_cover_url": "https://www.igdb.com/icons/rating_icons/acb/acb_rc.png",
},
}