mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 23:42:07 +01:00
1210 lines
44 KiB
Python
1210 lines
44 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]
|
|
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()
|
|
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.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:
|
|
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, 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)
|
|
|
|
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_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(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:
|
|
token = None
|
|
expires_in = 0
|
|
|
|
if not IGDB_API_ENABLED:
|
|
return ""
|
|
|
|
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 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",
|
|
"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 = [
|
|
{"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_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",
|
|
},
|
|
}
|