import argparse import datetime import json import re import sys import time import urllib.error import urllib.parse import urllib.request from envfile import env_value from jsonfile import PROJECT_ROOT TOKEN_URL = "https://id.twitch.tv/oauth2/token" GAMES_URL = "https://api.igdb.com/v4/games" EXTERNAL_GAMES_URL = "https://api.igdb.com/v4/external_games" IMAGE_URL = "https://images.igdb.com/igdb/image/upload/{size}/{image_id}.jpg" TOKEN_CACHE_PATH = PROJECT_ROOT / "tools" / "content" / ".cache" / "igdb_token.json" TOKEN_EXPIRY_MARGIN_SECONDS = 300 SUMMARY_MAX_LENGTH = 600 GAME_FIELDS = ", ".join([ "name", "slug", "url", "summary", "first_release_date", "total_rating", "genres.name", "platforms.name", "involved_companies.developer", "involved_companies.publisher", "involved_companies.company.name", "cover.image_id", "artworks.image_id", "screenshots.image_id", ]) IGDB_SLUG_PATTERN = re.compile(r"igdb\.com/games/([\w-]+)") STORE_ID_PATTERNS = { "store.steampowered.com": re.compile(r"store\.steampowered\.com/app/(\d+)"), "xbox.com": re.compile(r"xbox\.com/.*?/([0-9A-Za-z]{12})(?:[/?#]|$)"), } LINK_NOISE_SEGMENTS = {"store", "products", "product", "games", "game", "app", "p", "us", "en-us"} SEGMENT_ID_PATTERN = re.compile(r"\d+|[0-9A-Z]{12}") EPIC_SUFFIX_PATTERN = re.compile(r"-(?=[0-9a-f]*\d)[0-9a-f]{6}$") NAME_NOISE_PATTERN = re.compile(r"\b(switch|ps4|ps5|pc|xbox)\b", re.I) class IgdbError(ValueError): pass def credentials() -> tuple[str, str]: client_id = env_value("IGDB_CLIENT_ID") client_secret = env_value("IGDB_CLIENT_SECRET") if not client_id or not client_secret: raise IgdbError("Set IGDB_CLIENT_ID and IGDB_CLIENT_SECRET in .env to look up games") return client_id, client_secret def cached_token() -> str | None: try: cached = json.loads(TOKEN_CACHE_PATH.read_text(encoding="utf-8")) except (OSError, ValueError): return None return cached["token"] if cached.get("expiresAt", 0) > time.time() else None def fetch_token(client_id: str, client_secret: str) -> str: parameters = urllib.parse.urlencode({"client_id": client_id, "client_secret": client_secret, "grant_type": "client_credentials"}) request = urllib.request.Request(f"{TOKEN_URL}?{parameters}", method="POST") try: with urllib.request.urlopen(request, timeout=15) as response: result = json.load(response) except urllib.error.HTTPError as error: raise IgdbError(f"Twitch refused the IGDB keys ({error.code}). Check IGDB_CLIENT_ID and IGDB_CLIENT_SECRET") from error TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) expires_at = time.time() + result["expires_in"] - TOKEN_EXPIRY_MARGIN_SECONDS TOKEN_CACHE_PATH.write_text(json.dumps({"token": result["access_token"], "expiresAt": expires_at}), encoding="utf-8") return result["access_token"] def query(url: str, body: str) -> list[dict]: client_id, client_secret = credentials() token = cached_token() or fetch_token(client_id, client_secret) headers = {"Client-ID": client_id, "Authorization": f"Bearer {token}", "Accept": "application/json"} request = urllib.request.Request(url, data=body.encode("utf-8"), headers=headers, method="POST") try: with urllib.request.urlopen(request, timeout=15) as response: return json.load(response) except urllib.error.HTTPError as error: if error.code == 401: TOKEN_CACHE_PATH.unlink(missing_ok=True) raise IgdbError(f"IGDB returned {error.code}: {error.read().decode('utf-8', errors='replace')[:200]}") from error def quoted(text: str) -> str: return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' def game_by_id(identifier: str) -> dict | None: results = query(GAMES_URL, f"fields {GAME_FIELDS}; where id = {int(identifier)};") return results[0] if results else None def game_by_slug(slug: str) -> dict | None: results = query(GAMES_URL, f"fields {GAME_FIELDS}; where slug = {quoted(slug)};") return results[0] if results else None def game_by_store_link(link: str) -> dict | None: for host, pattern in STORE_ID_PATTERNS.items(): match = pattern.search(link) if not match: continue uid = match.group(1).upper() if host == "xbox.com" else match.group(1) matches = query(EXTERNAL_GAMES_URL, f"fields game, url; where uid = {quoted(uid)}; limit 50;") same_store = [item for item in matches if host in (item.get("url") or "")] chosen = same_store or (matches if len(matches) == 1 else []) if chosen and chosen[0].get("game"): return game_by_id(str(chosen[0]["game"])) return None def search_games(name: str) -> list[dict]: return query(GAMES_URL, f"search {quoted(name)}; fields {GAME_FIELDS}; limit 10;") def search_game(name: str) -> dict | None: results = search_games(name) exact = [game for game in results if game.get("name", "").lower() == name.lower()] return (exact or results or [None])[0] def name_from_link(link: str) -> str: segments = [segment for segment in urllib.parse.urlparse(link).path.split("/") if segment] words = [segment for segment in segments if segment.lower() not in LINK_NOISE_SEGMENTS and not SEGMENT_ID_PATTERN.fullmatch(segment)] guess = EPIC_SUFFIX_PATTERN.sub("", words[-1]) if words else "" guess = NAME_NOISE_PATTERN.sub("", re.sub(r"[-_]+", " ", guess)) return " ".join(guess.split()) def find_game(link: str | None, name: str | None) -> dict: game = None if link and IGDB_SLUG_PATTERN.search(link): game = game_by_slug(IGDB_SLUG_PATTERN.search(link).group(1)) if not game and link: game = game_by_store_link(link) search_name = name or (name_from_link(link) if link else "") if not game and search_name: game = search_game(search_name) if not game: raise IgdbError(f"Could not find {name or link} on IGDB. Try an IGDB link like https://www.igdb.com/games/hades-ii") return game def image_url(image: dict | None, size: str) -> str | None: return IMAGE_URL.format(size=size, image_id=image["image_id"]) if image and image.get("image_id") else None def first_image(game: dict, key: str) -> dict | None: images = game.get(key) or [] return images[0] if images else None def company_names(game: dict, role: str) -> list[str]: return [item["company"]["name"] for item in game.get("involved_companies") or [] if item.get(role) and item.get("company")] def short_summary(text: str | None) -> str | None: if not text or len(text) <= SUMMARY_MAX_LENGTH: return text return text[:SUMMARY_MAX_LENGTH].rsplit(" ", 1)[0] + "..." def release_date(game: dict) -> str | None: seconds = game.get("first_release_date") if not isinstance(seconds, (int, float)): return None return datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc).date().isoformat() def details(game: dict) -> dict: wide_image = first_image(game, "artworks") or first_image(game, "screenshots") return { "igdbId": str(game["id"]), "name": game.get("name"), "igdbUrl": game.get("url"), "released": release_date(game), "platforms": [platform["name"] for platform in game.get("platforms") or [] if platform.get("name")], "developers": company_names(game, "developer"), "publishers": company_names(game, "publisher"), "genres": [genre["name"] for genre in game.get("genres") or [] if genre.get("name")], "summary": short_summary(game.get("summary")), "score": round(game["total_rating"]) if game.get("total_rating") is not None else None, "imageSource": image_url(wide_image, "t_1080p"), "coverSource": image_url(game.get("cover"), "t_cover_big"), } def main() -> int: parser = argparse.ArgumentParser(description="Look up a video game on IGDB") parser.add_argument("text", help="a store link, an IGDB link or a game name") arguments = parser.parse_args() is_link = arguments.text.startswith("http") try: result = details(find_game(arguments.text if is_link else None, None if is_link else arguments.text)) except (OSError, ValueError) as error: print(f"Error: {error}", file=sys.stderr) return 1 print(json.dumps(result, indent="\t", ensure_ascii=False)) return 0 if __name__ == "__main__": sys.exit(main())