Files
romm/backend/handler/metadata/igdb_handler.py
Michael Manganiello 71ac92bfb6 fix: Consider IGDB alternatives when checking for exact match
IGDB provides alternative names for games, which we are currently not
considering when checking for an exact match.

This change starts considering alternative names, in addition to the
game's name and slug, when checking for an exact match.
2024-11-16 20:58:20 -03:00

1126 lines
40 KiB
Python

import functools
import re
import time
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 sync_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]
class IGDBAgeRating(TypedDict):
rating: str
category: str
rating_cover_url: str
class IGDBMetadataPlatform(TypedDict):
igdb_id: int
name: 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, video_id: str | None = None
) -> IGDBMetadata:
return IGDBMetadata(
{
"youtube_video_id": video_id,
"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=pydash.get(e, "cover.url", ""),
type="expansion",
)
for e in rom.get("expansions", [])
],
"dlcs": [
IGDBRelatedGame(
id=d["id"],
slug=d["slug"],
name=d["name"],
cover_url=pydash.get(d, "cover.url", ""),
type="dlc",
)
for d in rom.get("dlcs", [])
],
"remasters": [
IGDBRelatedGame(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=pydash.get(r, "cover.url", ""),
type="remaster",
)
for r in rom.get("remasters", [])
],
"remakes": [
IGDBRelatedGame(
id=r["id"],
slug=r["slug"],
name=r["name"],
cover_url=pydash.get(r, "cover.url", ""),
type="remake",
)
for r in rom.get("remakes", [])
],
"expanded_games": [
IGDBRelatedGame(
id=g["id"],
slug=g["slug"],
name=g["name"],
cover_url=pydash.get(g, "cover.url", ""),
type="expanded",
)
for g in rom.get("expanded_games", [])
],
"ports": [
IGDBRelatedGame(
id=p["id"],
slug=p["slug"],
name=p["name"],
cover_url=pydash.get(p, "cover.url", ""),
type="port",
)
for p in rom.get("ports", [])
],
"similar_games": [
IGDBRelatedGame(
id=s["id"],
slug=s["slug"],
name=s["name"],
cover_url=pydash.get(s, "cover.url", ""),
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.video_endpoint = f"{self.BASE_URL}/game_videos"
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()
try:
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.NetworkError as exc:
log.critical("Connection error: can't connect to IGDB", exc_info=True)
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:
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)
category_filter: str = (
f"& (category={GameCategory.MAIN_GAME} | category={GameCategory.EXPANDED_GAME})"
if with_category
else ""
)
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
)
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
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:
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["id"],
slug=slug,
name=platform["name"],
)
# 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, file_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(file_name)
fallback_rom = IGDBRom(igdb_id=None)
# Support for PS2 OPL filename format
match = PS2_OPL_REGEX.match(file_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(file_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(file_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(file_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)
rom = await self._search_rom(search_term, platform_igdb_id, with_category=True)
if not rom:
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]:
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("/"):
rom = await self._search_rom(term.strip(), platform_igdb_id)
if rom:
break
if not rom:
return fallback_rom
# Get the video ID for the game
video_ids = await self._request(
self.video_endpoint,
f'fields video_id; where game={rom["id"]};',
)
video_id = pydash.get(video_ids, "[0].video_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, video_id),
)
@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)
# Get the video ID for the game
video_ids = await self._request(
self.video_endpoint,
f'fields video_id; where game={rom["id"]};',
)
video_id = pydash.get(video_ids, "[0].video_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, video_id),
)
@check_twitch_token
async def get_matched_roms_by_id(self, igdb_id: int) -> list[IGDBRom]:
if not IGDB_API_ENABLED:
return []
rom = await self.get_rom_by_id(igdb_id)
return [rom] if rom["igdb_id"] else []
@check_twitch_token
async def get_matched_roms_by_name(
self, search_term: str, platform_igdb_id: int
) -> 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(
"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:
async def _update_twitch_token(self) -> str:
token = None
expires_in = 0
if not IGDB_API_ENABLED:
return ""
httpx_client = ctx_httpx_client.get()
try:
res = await httpx_client.post(
url="https://id.twitch.tv/oauth2/token",
params={
"client_id": IGDB_CLIENT_ID,
"client_secret": IGDB_CLIENT_SECRET,
"grant_type": "client_credentials",
},
timeout=10,
)
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 in <expires_in> seconds
sync_cache.set("romm:twitch_token", token, ex=expires_in - 10)
sync_cache.set("romm:twitch_token_expires_at", time.time() + 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 = sync_cache.get("romm:twitch_token")
token_expires_at = sync_cache.get("romm:twitch_token_expires_at")
if not token or time.time() > float(token_expires_at or 0):
log.warning("Twitch token invalid: fetching a new one...")
return await self._update_twitch_token()
return token
PLATFORMS_FIELDS = ["id", "name"]
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",
]
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 = [
{"slug": "visionos", "name": "visionOS"},
{"slug": "meta-quest-3", "name": "Meta Quest 3"},
{"slug": "atari2600", "name": "Atari 2600"},
{"slug": "psvr2", "name": "PlayStation VR2"},
{"slug": "switch", "name": "Nintendo Switch"},
{"slug": "evercade", "name": "Evercade"},
{"slug": "android", "name": "Android"},
{"slug": "mac", "name": "Mac"},
{"slug": "win", "name": "PC (Microsoft Windows)"},
{"slug": "oculus-quest", "name": "Oculus Quest"},
{"slug": "playdate", "name": "Playdate"},
{"slug": "series-x", "name": "Xbox Series X"},
{"slug": "meta-quest-2", "name": "Meta Quest 2"},
{"slug": "ps5", "name": "PlayStation 5"},
{"slug": "oculus-rift", "name": "Oculus Rift"},
{"slug": "xboxone", "name": "Xbox One"},
{"slug": "leaptv", "name": "LeapTV"},
{"slug": "new-nintendo-3ds", "name": "New Nintendo 3DS"},
{"slug": "gear-vr", "name": "Gear VR"},
{"slug": "psvr", "name": "PlayStation VR"},
{"slug": "3ds", "name": "Nintendo 3DS"},
{"slug": "winphone", "name": "Windows Phone"},
{"slug": "arduboy", "name": "Arduboy"},
{"slug": "ps4--1", "name": "PlayStation 4"},
{"slug": "oculus-go", "name": "Oculus Go"},
{"slug": "psvita", "name": "PlayStation Vita"},
{"slug": "wiiu", "name": "Wii U"},
{"slug": "ouya", "name": "Ouya"},
{"slug": "wii", "name": "Wii"},
{"slug": "ps3", "name": "PlayStation 3"},
{"slug": "psp", "name": "PlayStation Portable"},
{"slug": "nintendo-dsi", "name": "Nintendo DSi"},
{
"slug": "leapster-explorer-slash-leadpad-explorer",
"name": "Leapster Explorer/LeadPad Explorer",
},
{"slug": "xbox360", "name": "Xbox 360"},
{"slug": "nds", "name": "Nintendo DS"},
{"slug": "ps2", "name": "PlayStation 2"},
{"slug": "arcade", "name": "Arcade"},
{"slug": "zeebo", "name": "Zeebo"},
{"slug": "windows-mobile", "name": "Windows Mobile"},
{"slug": "ios", "name": "iOS"},
{"slug": "mobile", "name": "Legacy Mobile Device"},
{"slug": "blu-ray-player", "name": "Blu-ray Player"},
{"slug": "hyperscan", "name": "HyperScan"},
{"slug": "gizmondo", "name": "Gizmondo"},
{"slug": "gba", "name": "Game Boy Advance"},
{"slug": "ngage", "name": "N-Gage"},
{"slug": "vsmile", "name": "V.Smile"},
{"slug": "n64", "name": "Nintendo 64"},
{"slug": "leapster", "name": "Leapster"},
{"slug": "zod", "name": "Tapwave Zodiac"},
{"slug": "wonderswan-color", "name": "WonderSwan Color"},
{"slug": "xbox", "name": "Xbox"},
{"slug": "ngc", "name": "Nintendo GameCube"},
{"slug": "wonderswan", "name": "WonderSwan"},
{"slug": "pokemon-mini", "name": "Pokémon mini"},
{"slug": "nuon", "name": "Nuon"},
{"slug": "ps", "name": "PlayStation"},
{"slug": "nintendo-64dd", "name": "Nintendo 64DD"},
{"slug": "neo-geo-pocket-color", "name": "Neo Geo Pocket Color"},
{"slug": "dvd-player", "name": "DVD Player"},
{"slug": "pocketstation", "name": "PocketStation"},
{
"slug": "visual-memory-unit-slash-visual-memory-system",
"name": "Visual Memory Unit / Visual Memory System",
},
{"slug": "blackberry", "name": "BlackBerry OS"},
{"slug": "dc", "name": "Dreamcast"},
{"slug": "gbc", "name": "Game Boy Color"},
{"slug": "gb", "name": "Game Boy"},
{"slug": "neo-geo-pocket", "name": "Neo Geo Pocket"},
{"slug": "snes", "name": "Super Nintendo Entertainment System"},
{"slug": "genesis-slash-megadrive", "name": "Sega Mega Drive/Genesis"},
{"slug": "sfam", "name": "Super Famicom"},
{"slug": "game-dot-com", "name": "Game.com"},
{"slug": "hyper-neo-geo-64", "name": "Hyper Neo Geo 64"},
{"slug": "satellaview", "name": "Satellaview"},
{"slug": "palm-os", "name": "Palm OS"},
{"slug": "apple-pippin", "name": "Apple Pippin"},
{"slug": "sega32", "name": "Sega 32X"},
{"slug": "neo-geo-cd", "name": "Neo Geo CD"},
{"slug": "virtualboy", "name": "Virtual Boy"},
{"slug": "atari-jaguar-cd", "name": "Atari Jaguar CD"},
{"slug": "saturn", "name": "Sega Saturn"},
{"slug": "casio-loopy", "name": "Casio Loopy"},
{"slug": "sega-pico", "name": "Sega Pico"},
{"slug": "r-zone", "name": "R-Zone"},
{"slug": "sms", "name": "Sega Master System/Mark III"},
{"slug": "playdia", "name": "Playdia"},
{"slug": "pc-fx", "name": "PC-FX"},
{"slug": "3do", "name": "3DO Interactive Multiplayer"},
{
"slug": "terebikko-slash-see-n-say-video-phone",
"name": "Terebikko / See 'n Say Video Phone",
},
{"slug": "jaguar", "name": "Atari Jaguar"},
{"slug": "segacd", "name": "Sega CD"},
{"slug": "nes", "name": "Nintendo Entertainment System"},
{"slug": "amiga-cd32", "name": "Amiga CD32"},
{"slug": "famicom", "name": "Family Computer"},
{"slug": "mega-duck-slash-cougar-boy", "name": "Mega Duck/Cougar Boy"},
{"slug": "amiga", "name": "Amiga"},
{
"slug": "watara-slash-quickshot-supervision",
"name": "Watara/QuickShot Supervision",
},
{"slug": "philips-cd-i", "name": "Philips CD-i"},
{"slug": "gamegear", "name": "Sega Game Gear"},
{"slug": "neogeoaes", "name": "Neo Geo AES"},
{"slug": "linux", "name": "Linux"},
{"slug": "turbografx-16-slash-pc-engine-cd", "name": "Turbografx-16/PC Engine CD"},
{"slug": "neogeomvs", "name": "Neo Geo MVS"},
{"slug": "commodore-cdtv", "name": "Commodore CDTV"},
{"slug": "lynx", "name": "Atari Lynx"},
{"slug": "gamate", "name": "Gamate"},
{"slug": "bbcmicro", "name": "BBC Microcomputer System"},
{"slug": "turbografx16--1", "name": "TurboGrafx-16/PC Engine"},
{"slug": "supergrafx", "name": "PC Engine SuperGrafx"},
{"slug": "fm-towns", "name": "FM Towns"},
{"slug": "pc-9800-series", "name": "PC-9800 Series"},
{"slug": "apple-iigs", "name": "Apple IIGS"},
{"slug": "x1", "name": "Sharp X1"},
{"slug": "sharp-x68000", "name": "Sharp X68000"},
{"slug": "acorn-archimedes", "name": "Acorn Archimedes"},
{"slug": "c64", "name": "Commodore C64/128/MAX"},
{"slug": "fds", "name": "Family Computer Disk System"},
{"slug": "dragon-32-slash-64", "name": "Dragon 32/64"},
{"slug": "acorn-electron", "name": "Acorn Electron"},
{"slug": "acpc", "name": "Amstrad CPC"},
{"slug": "atari-st", "name": "Atari ST/STE"},
{"slug": "tatung-einstein", "name": "Tatung Einstein"},
{"slug": "amstrad-pcw", "name": "Amstrad PCW"},
{"slug": "epoch-super-cassette-vision", "name": "Epoch Super Cassette Vision"},
{"slug": "atari7800", "name": "Atari 7800"},
{"slug": "hp3000", "name": "HP 3000"},
{"slug": "atari5200", "name": "Atari 5200"},
{"slug": "c16", "name": "Commodore 16"},
{"slug": "sinclair-ql", "name": "Sinclair QL"},
{"slug": "thomson-mo5", "name": "Thomson MO5"},
{"slug": "c-plus-4", "name": "Commodore Plus/4"},
{"slug": "sg1000", "name": "SG-1000"},
{"slug": "vectrex", "name": "Vectrex"},
{"slug": "sharp-mz-2200", "name": "Sharp MZ-2200"},
{"slug": "nec-pc-6000-series", "name": "NEC PC-6000 Series"},
{"slug": "msx2", "name": "MSX2"},
{"slug": "msx", "name": "MSX"},
{"slug": "colecovision", "name": "ColecoVision"},
{"slug": "intellivision", "name": "Intellivision"},
{"slug": "vic-20", "name": "Commodore VIC-20"},
{"slug": "zxs", "name": "ZX Spectrum"},
{"slug": "arcadia-2001", "name": "Arcadia 2001"},
{"slug": "fm-7", "name": "FM-7"},
{"slug": "trs-80", "name": "TRS-80"},
{"slug": "epoch-cassette-vision", "name": "Epoch Cassette Vision"},
{"slug": "dos", "name": "DOS"},
{"slug": "ti-99", "name": "Texas Instruments TI-99"},
{"slug": "sinclair-zx81", "name": "Sinclair ZX81"},
{"slug": "pc-8800-series", "name": "PC-8800 Series"},
{"slug": "microvision--1", "name": "Microvision"},
{"slug": "g-and-w", "name": "Game & Watch"},
{"slug": "atari8bit", "name": "Atari 8-bit"},
{"slug": "trs-80-color-computer", "name": "TRS-80 Color Computer"},
{
"slug": "1292-advanced-programmable-video-system",
"name": "1292 Advanced Programmable Video System",
},
{"slug": "odyssey-2-slash-videopac-g7000", "name": "Odyssey 2 / Videopac G7000"},
{"slug": "exidy-sorcerer", "name": "Exidy Sorcerer"},
{"slug": "pc-50x-family", "name": "PC-50X Family"},
{"slug": "vc-4000", "name": "VC 4000"},
{"slug": "appleii", "name": "Apple II"},
{"slug": "astrocade", "name": "Bally Astrocade"},
{"slug": "ay-3-8500", "name": "AY-3-8500"},
{"slug": "cpet", "name": "Commodore PET"},
{"slug": "fairchild-channel-f", "name": "Fairchild Channel F"},
{"slug": "ay-3-8610", "name": "AY-3-8610"},
{"slug": "ay-3-8605", "name": "AY-3-8605"},
{"slug": "ay-3-8603", "name": "AY-3-8603"},
{"slug": "ay-3-8710", "name": "AY-3-8710"},
{"slug": "ay-3-8760", "name": "AY-3-8760"},
{"slug": "ay-3-8606", "name": "AY-3-8606"},
{"slug": "ay-3-8607", "name": "AY-3-8607"},
{"slug": "sol-20", "name": "Sol-20"},
{"slug": "odyssey--1", "name": "Odyssey"},
{"slug": "plato--1", "name": "PLATO"},
{"slug": "cdccyber70", "name": "CDC Cyber 70"},
{"slug": "sdssigma7", "name": "SDS Sigma 7"},
{"slug": "pdp11", "name": "PDP-11"},
{"slug": "hp2100", "name": "HP 2100"},
{"slug": "pdp10", "name": "PDP-10"},
{
"slug": "call-a-computer",
"name": "Call-A-Computer time-shared mainframe computer system",
},
{"slug": "pdp-8--1", "name": "PDP-8"},
{"slug": "nintendo-playstation", "name": "Nintendo PlayStation"},
{"slug": "pdp1", "name": "PDP-1"},
{"slug": "donner30", "name": "Donner Model 30"},
{"slug": "edsac--1", "name": "EDSAC"},
{"slug": "nimrod", "name": "Ferranti Nimrod Computer"},
{"slug": "swancrystal", "name": "SwanCrystal"},
{"slug": "panasonic-jungle", "name": "Panasonic Jungle"},
{"slug": "handheld-electronic-lcd", "name": "Handheld Electronic LCD"},
{"slug": "intellivision-amico", "name": "Intellivision Amico"},
{"slug": "legacy-computer", "name": "Legacy Computer"},
{"slug": "panasonic-m2", "name": "Panasonic M2"},
{"slug": "browser", "name": "Web browser"},
{"slug": "ooparts", "name": "OOParts"},
{"slug": "stadia", "name": "Google Stadia"},
{"slug": "plug-and-play", "name": "Plug & Play"},
{"slug": "amazon-fire-tv", "name": "Amazon Fire TV"},
{"slug": "onlive-game-system", "name": "OnLive Game System"},
{"slug": "vc", "name": "Virtual Console"},
{"slug": "airconsole", "name": "AirConsole"},
]
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",
},
}