mirror of
https://github.com/rommapp/romm.git
synced 2026-02-19 07:50:57 +01:00
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.
1126 lines
40 KiB
Python
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",
|
|
},
|
|
}
|