Files
romm/backend/adapters/services/igdb.py
2025-09-15 11:13:33 -04:00

207 lines
6.5 KiB
Python

import asyncio
import http
import json
from collections.abc import Sequence
from functools import partial
from typing import TYPE_CHECKING
import aiohttp
import yarl
from aiohttp.client import ClientTimeout
from fastapi import HTTPException, status
from unidecode import unidecode
from adapters.services.igdb_types import Game
from config import IGDB_CLIENT_ID
from logger.logger import log
from utils import get_version
from utils.context import ctx_aiohttp_session
if TYPE_CHECKING:
from handler.metadata.igdb_handler import TwitchAuth
class IGDBInvalidCredentialsException(Exception):
"""Exception raised when IGDB credentials are invalid."""
async def auth_middleware(
req: aiohttp.ClientRequest,
handler: aiohttp.ClientHandlerType,
*,
twitch_auth: "TwitchAuth",
) -> aiohttp.ClientResponse:
"""IGDB API authentication mechanism.
Reference: https://api-docs.igdb.com/#authentication
"""
token = await twitch_auth.get_oauth_token()
if not token:
raise IGDBInvalidCredentialsException()
req.headers.update(
{
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Client-ID": IGDB_CLIENT_ID,
}
)
return await handler(req)
class IGDBService:
"""Service to interact with the IGDB API.
Reference: https://api-docs.igdb.com/
"""
def __init__(
self,
twitch_auth: "TwitchAuth",
base_url: str | None = None,
) -> None:
self.url = yarl.URL(base_url or "https://api.igdb.com/v4")
self.twitch_auth = twitch_auth
self.auth_middleware = partial(auth_middleware, twitch_auth=self.twitch_auth)
async def _request(
self,
url: str,
search_term: str | None = None,
fields: Sequence[str] | None = None,
where: str | None = None,
limit: int | None = None,
request_timeout: int = 120,
) -> list:
aiohttp_session = ctx_aiohttp_session.get()
content = ""
if search_term:
content += f'search "{unidecode(search_term)}"; '
if fields:
content += f"fields {','.join(fields)}; "
if where:
content += f"where {where}; "
if limit is not None:
content += f"limit {limit}; "
content = content.strip()
log.debug(
"API request: URL=%s, Content=%s, Timeout=%s",
url,
content,
request_timeout,
)
try:
res = await aiohttp_session.post(
url,
data=content,
headers={"user-agent": f"RomM/{get_version()}"},
middlewares=(self.auth_middleware,),
timeout=ClientTimeout(total=request_timeout),
)
res.raise_for_status()
return await res.json()
except aiohttp.ServerTimeoutError:
# Retry the request once if it times out
log.debug("Request to URL=%s timed out. Retrying...", url)
except IGDBInvalidCredentialsException as exc:
log.critical("IGDB Error: Invalid IGDB_CLIENT_ID or IGDB_CLIENT_SECRET")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Invalid IGDB credentials",
) from exc
except aiohttp.ClientConnectionError 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 aiohttp.ClientResponseError as exc:
if exc.status == http.HTTPStatus.UNAUTHORIZED:
# Refresh the token and retry if the auth token is invalid
log.info("Twitch token invalid: fetching a new one...")
await self.twitch_auth._update_twitch_token()
elif exc.status == http.HTTPStatus.TOO_MANY_REQUESTS:
# Retry after 2 seconds if rate limit hit
await asyncio.sleep(2)
else:
# Log the error and return an empty list if the request fails with a different code
log.error(exc)
return []
except json.JSONDecodeError as exc:
log.error("Error decoding JSON response from IGDB: %s", exc)
return []
# Retry the request once if it times out
try:
log.debug(
"API request: URL=%s, Content=%s, Timeout=%s",
url,
content,
request_timeout,
)
res = await aiohttp_session.post(
url,
data=content,
headers={"user-agent": f"RomM/{get_version()}"},
middlewares=(self.auth_middleware,),
timeout=ClientTimeout(total=request_timeout),
)
res.raise_for_status()
return await res.json()
except (aiohttp.ClientResponseError, aiohttp.ServerTimeoutError) as exc:
if (
isinstance(exc, aiohttp.ClientResponseError)
and exc.status == http.HTTPStatus.UNAUTHORIZED
):
return []
log.error(exc)
return []
except json.JSONDecodeError as exc:
log.error("Error decoding JSON response from IGDB: %s", exc)
return []
async def list_games(
self,
*,
search_term: str | None = None,
fields: Sequence[str] | None = None,
where: str | None = None,
limit: int | None = None,
) -> list[Game]:
"""Retrieve games.
Reference: https://api-docs.igdb.com/#game
"""
url = self.url.joinpath("games")
return await self._request(
str(url),
search_term=search_term,
fields=fields,
where=where,
limit=limit,
)
async def search(
self,
*,
search_term: str | None = None,
fields: Sequence[str] | None = None,
where: str | None = None,
limit: int | None = None,
) -> list[dict]:
"""Search for different entities.
Reference: https://api-docs.igdb.com/#search
"""
url = self.url.joinpath("search")
return await self._request(
str(url),
search_term=search_term,
fields=fields,
where=where,
limit=limit,
)