update scraper to use tgdb api

This commit is contained in:
skymike03
2025-11-03 22:44:55 +01:00
parent af42c31476
commit 7a061ef0bc
2 changed files with 261 additions and 609 deletions

View File

@@ -1,5 +1,5 @@
"""
Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net
Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net API v1
"""
import logging
import requests
@@ -9,138 +9,167 @@ import pygame
logger = logging.getLogger(__name__)
# Mapping des noms de plateformes vers leurs IDs sur TheGamesDB
# Les noms correspondent exactement à ceux utilisés dans systems_list.json
# Clé API publique pour TheGamesDB
API_KEY = "bdbb4a1ce5f1c12c1bcc119aeb4d4923d3887e22ad336d576e9b9e5da5ecaa3c"
API_BASE_URL = "https://api.thegamesdb.net/v1"
# Mapping des noms de plateformes vers leurs IDs sur TheGamesDB API
# Documentation: https://api.thegamesdb.net/#/Platforms
PLATFORM_MAPPING = {
# Noms exacts du systems_list.json
"3DO Interactive Multiplayer": "25",
"3DS": "4912",
"Adventure Vision": "4974",
"Amiga CD32": "4947",
"Amiga CDTV": "4947", # Même ID que CD32
"Amiga OCS ECS": "4911",
"Apple II": "4942",
"Apple IIGS": "4942", # Même famille
"Arcadia 2001": "4963",
"Archimedes": "4944",
"Astrocade": "4968",
"Atari 2600": "22",
"Atari 5200": "26",
"Atari 7800": "27",
"Atari Lynx": "4924",
"Atari ST": "4937",
"Atom": "5014",
"Channel-F": "4928",
"ColecoVision": "31",
"Commodore 64": "40",
"Commodore Plus4": "5007",
"Commodore VIC-20": "4945",
"CreatiVision": "5005",
"Dos (x86)": "1",
"Dreamcast": "16",
"Family Computer Disk System": "4936",
"Final Burn Neo": "23", # Arcade
"FM-TOWNS": "4932",
"Gamate": "5004",
"Game Boy": "4",
"Game Boy Advance": "5",
"Game Boy Color": "41",
"Game Cube": "2",
"Game Gear": "20",
"Game Master": "4948", # Mega Duck
"Game.com": "4940",
"Jaguar": "28",
"Macintosh": "37",
"Master System": "35",
"Mattel Intellivision": "32",
"Mega CD": "21",
"Mega Drive": "36",
"Mega Duck Cougar Boy": "4948",
"MSX1": "4929",
"MSX2+": "4929",
"Namco System 246 256": "23", # Arcade
"Naomi": "23", # Arcade
"Naomi 2": "23", # Arcade
"Neo-Geo CD": "4956",
"Neo-Geo Pocket": "4922",
"Neo-Geo Pocket Color": "4923",
"Neo-Geo": "24",
"Nintendo 64": "3",
"Nintendo 64 Disk Drive": "3",
"Nintendo DS": "8",
"Nintendo DSi": "8",
"Nintendo Entertainment System": "7",
"Odyssey2": "4927",
"PC Engine": "34",
"PC Engine CD": "4955",
"PC Engine SuperGrafx": "34",
"PC-9800": "4934",
"PlayStation": "10",
"PlayStation 2": "11",
"PlayStation 3": "12",
"PlayStation Portable": "13",
"PlayStation Vita": "39",
"Pokemon Mini": "4957",
"PV-1000": "4964",
"Satellaview": "6", # SNES addon
"Saturn": "17",
"ScummVM": "1", # PC
"Sega 32X": "33",
"Sega Chihiro": "23", # Arcade
"Sega Pico": "4958",
"SG-1000": "4949",
"Sharp X1": "4977",
"SuFami Turbo": "6", # SNES addon
"Super A'Can": "4918", # Pas d'ID exact, utilise Virtual Boy
"Super Cassette Vision": "4966",
"Super Nintendo Entertainment System": "6",
"Supervision": "4959",
"Switch (1Fichier)": "4971",
"TI-99": "4953",
"V.Smile": "4988",
"Vectrex": "4939",
"Virtual Boy": "4918",
"Wii": "9",
"Wii (Virtual Console)": "9",
"Wii U": "38",
"Windows (1Fichier)": "1",
"WonderSwan": "4925",
"WonderSwan Color": "4926",
"Xbox": "14",
"Xbox 360": "15",
"ZX Spectrum": "4913",
"Game and Watch": "4950",
"Nintendo Famicom Disk System": "4936",
"3DO Interactive Multiplayer": 25,
"3DS": 4912,
"Adventure Vision": 4974,
"Amiga CD32": 4947,
"Amiga CDTV": 4947,
"Amiga OCS ECS": 4911,
"Apple II": 4942,
"Apple IIGS": 4942,
"Arcadia 2001": 4963,
"Archimedes": 4944,
"Astrocade": 4968,
"Atari 2600": 22,
"Atari 5200": 26,
"Atari 7800": 27,
"Atari Lynx": 4924,
"Atari ST": 4937,
"Atom": 5014,
"Channel-F": 4928,
"ColecoVision": 31,
"Commodore 64": 40,
"Commodore Plus4": 5007,
"Commodore VIC-20": 4945,
"CreatiVision": 5005,
"Dos (x86)": 1,
"Dreamcast": 16,
"Family Computer Disk System": 4936,
"Final Burn Neo": 23,
"FM-TOWNS": 4932,
"Gamate": 5004,
"Game Boy": 4,
"Game Boy Advance": 5,
"Game Boy Color": 41,
"Game Cube": 2,
"Game Gear": 20,
"Game Master": 4948,
"Game.com": 4940,
"Jaguar": 28,
"Macintosh": 37,
"Master System": 35,
"Mattel Intellivision": 32,
"Mega CD": 21,
"Mega Drive": 36,
"Mega Duck Cougar Boy": 4948,
"MSX1": 4929,
"MSX2+": 4929,
"Namco System 246 256": 23,
"Naomi": 23,
"Naomi 2": 23,
"Neo-Geo CD": 4956,
"Neo-Geo Pocket": 4922,
"Neo-Geo Pocket Color": 4923,
"Neo-Geo": 24,
"Nintendo 64": 3,
"Nintendo 64 Disk Drive": 3,
"Nintendo DS": 8,
"Nintendo DSi": 8,
"Nintendo Entertainment System": 7,
"Odyssey2": 4927,
"PC Engine": 34,
"PC Engine CD": 4955,
"PC Engine SuperGrafx": 34,
"PC-9800": 4934,
"PlayStation": 10,
"PlayStation 2": 11,
"PlayStation 3": 12,
"PlayStation Portable": 13,
"PlayStation Vita": 39,
"Pokemon Mini": 4957,
"PV-1000": 4964,
"Satellaview": 6,
"Saturn": 17,
"ScummVM": 1,
"Sega 32X": 33,
"Sega Chihiro": 23,
"Sega Pico": 4958,
"SG-1000": 4949,
"Sharp X1": 4977,
"SuFami Turbo": 6,
"Super A'Can": 4918,
"Super Cassette Vision": 4966,
"Super Nintendo Entertainment System": 6,
"Supervision": 4959,
"Switch (1Fichier)": 4971,
"TI-99": 4953,
"V.Smile": 4988,
"Vectrex": 4939,
"Virtual Boy": 4918,
"Wii": 9,
"Wii (Virtual Console)": 9,
"Wii U": 38,
"Windows (1Fichier)": 1,
"WonderSwan": 4925,
"WonderSwan Color": 4926,
"Xbox": 14,
"Xbox 360": 15,
"ZX Spectrum": 4913,
"Game and Watch": 4950,
"Nintendo Famicom Disk System": 4936,
# Aliases communs (pour compatibilité)
"3DO": "25",
"NES": "7",
"SNES": "6",
"GBA": "5",
"GBC": "41",
"GameCube": "2",
"N64": "3",
"NDS": "8",
"PSX": "10",
"PS1": "10",
"PS2": "11",
"PS3": "12",
"PSP": "13",
"PS Vita": "39",
"Genesis": "18",
"32X": "33",
"Game & Watch": "4950",
"PC-98": "4934",
"TurboGrafx 16": "34",
"TurboGrafx CD": "4955",
"Mega Duck": "4948",
"Amiga": "4911"
"3DO": 25,
"NES": 7,
"SNES": 6,
"GBA": 5,
"GBC": 41,
"GameCube": 2,
"N64": 3,
"NDS": 8,
"PSX": 10,
"PS1": 10,
"PS2": 11,
"PS3": 12,
"PSP": 13,
"PS Vita": 39,
"Genesis": 18,
"32X": 33,
"Game & Watch": 4950,
"PC-98": 4934,
"TurboGrafx 16": 34,
"TurboGrafx CD": 4955,
"Mega Duck": 4948,
"Amiga": 4911
}
def clean_game_name(game_name):
"""
Nettoie le nom du jeu en supprimant les extensions et tags
Args:
game_name (str): Nom brut du jeu
Returns:
str: Nom nettoyé
"""
clean_name = game_name
# Supprimer les extensions communes
extensions = ['.zip', '.7z', '.rar', '.iso', '.chd', '.cue', '.bin', '.gdi', '.cdi',
'.nsp', '.xci', '.wbfs', '.rvz', '.gcz', '.wad', '.3ds', '.cia']
for ext in extensions:
if clean_name.lower().endswith(ext):
clean_name = clean_name[:-len(ext)]
# Supprimer les tags entre parenthèses et crochets
clean_name = re.sub(r'\s*[\(\[].*?[\)\]]', '', clean_name)
return clean_name.strip()
def get_game_metadata(game_name, platform_name):
"""
Récupère les métadonnées complètes d'un jeu depuis TheGamesDB.net
Récupère les métadonnées complètes d'un jeu depuis TheGamesDB.net API
Args:
game_name (str): Nom du jeu à rechercher
@@ -150,100 +179,128 @@ def get_game_metadata(game_name, platform_name):
dict: Dictionnaire contenant les métadonnées ou message d'erreur
Keys: image_url, game_page_url, description, genre, release_date, error
"""
# Nettoyer le nom du jeu
clean_game_name = game_name
for ext in ['.zip', '.7z', '.rar', '.iso', '.chd', '.cue', '.bin', '.gdi', '.cdi']:
if clean_game_name.lower().endswith(ext):
clean_game_name = clean_game_name[:-len(ext)]
clean_game_name = re.sub(r'\s*[\(\[].*?[\)\]]', '', clean_game_name)
clean_game_name = clean_game_name.strip()
logger.info(f"Recherche métadonnées pour: '{clean_game_name}' sur plateforme '{platform_name}'")
clean_name = clean_game_name(game_name)
logger.info(f"Recherche métadonnées pour: '{clean_name}' sur plateforme '{platform_name}'")
# Obtenir l'ID de la plateforme
platform_id = PLATFORM_MAPPING.get(platform_name)
if not platform_id:
logger.warning(f"Plateforme '{platform_name}' non trouvée dans le mapping")
return {"error": f"Plateforme '{platform_name}' non supportée"}
# Construire l'URL de recherche
base_url = "https://thegamesdb.net/search.php"
params = {
"name": clean_game_name,
"platform_id[]": platform_id
}
try:
# Envoyer la requête GET pour la recherche
logger.debug(f"Recherche sur TheGamesDB: {base_url} avec params={params}")
response = requests.get(base_url, params=params, timeout=10)
# Endpoint: Games/ByGameName
# Documentation: https://api.thegamesdb.net/#/Games/GamesbyName
url = f"{API_BASE_URL}/Games/ByGameName"
params = {
"apikey": API_KEY,
"name": clean_name,
"filter[platform]": platform_id,
"fields": "players,publishers,genres,overview,last_updated,rating,platform,coop,youtube,os,processor,ram,hdd,video,sound,alternates",
"include": "boxart"
}
logger.debug(f"Requête API: {url} avec name='{clean_name}', platform={platform_id}")
response = requests.get(url, params=params, timeout=15)
if response.status_code != 200:
logger.error(f"Erreur HTTP {response.status_code}: {response.text}")
return {"error": f"Erreur HTTP {response.status_code}"}
html_content = response.text
data = response.json()
# Trouver la première carte avec class 'card border-primary'
card_start = html_content.find('div class="card border-primary"')
if card_start == -1:
# Vérifier si des résultats ont été trouvés
if "data" not in data or "games" not in data["data"] or not data["data"]["games"]:
logger.warning(f"Aucun résultat trouvé pour '{clean_name}'")
return {"error": "Aucun résultat trouvé"}
# Extraire l'URL de la page du jeu
href_match = re.search(r'<a href="(\.\/game\.php\?id=\d+)">', html_content[card_start-100:card_start+500])
game_page_url = None
if href_match:
game_page_url = f"https://thegamesdb.net/{href_match.group(1)[2:]}" # Enlever le ./
logger.info(f"Page du jeu trouvée: {game_page_url}")
# Prendre le premier résultat (meilleure correspondance)
games = data["data"]["games"]
game = games[0]
game_id = game.get("id")
# Extraire l'URL de l'image
img_start = html_content.find('<img class="card-img-top"', card_start)
image_url = None
if img_start != -1:
src_match = re.search(r'src="([^"]+)"', html_content[img_start:img_start+200])
if src_match:
image_url = src_match.group(1)
if not image_url.startswith("https://"):
image_url = f"https://thegamesdb.net{image_url}"
logger.info(f"Image trouvée: {image_url}")
logger.info(f"Jeu trouvé: '{game.get('game_title')}' (ID: {game_id})")
# Extraire la date de sortie depuis les résultats de recherche
release_date = None
card_footer_start = html_content.find('class="card-footer', card_start)
if card_footer_start != -1:
# Chercher une date au format YYYY-MM-DD
date_match = re.search(r'<p>(\d{4}-\d{2}-\d{2})</p>', html_content[card_footer_start:card_footer_start+300])
if date_match:
release_date = date_match.group(1)
logger.info(f"Date de sortie trouvée: {release_date}")
# Construire l'URL de la page du jeu
game_page_url = f"https://thegamesdb.net/game.php?id={game_id}"
# Si on a l'URL de la page, récupérer la description et le genre
description = None
# Extraire les métadonnées de base
description = game.get("overview", "").strip() or None
release_date = game.get("release_date", "").strip() or None
# Extraire les genres
genre = None
if game_page_url:
try:
logger.debug(f"Récupération de la page du jeu: {game_page_url}")
game_response = requests.get(game_page_url, timeout=10)
if game_response.status_code == 200:
game_html = game_response.text
# Extraire la description
desc_match = re.search(r'<p class="game-overview">(.*?)</p>', game_html, re.DOTALL)
if desc_match:
description = desc_match.group(1).strip()
# Nettoyer les entités HTML
description = description.replace('&#039;', "'")
description = description.replace('&quot;', '"')
description = description.replace('&amp;', '&')
logger.info(f"Description trouvée ({len(description)} caractères)")
# Extraire le genre
genre_match = re.search(r'<p>Genre\(s\): (.*?)</p>', game_html)
if genre_match:
genre = genre_match.group(1).strip()
logger.info(f"Genre trouvé: {genre}")
if "genres" in game and game["genres"]:
genre_ids = game["genres"]
# Les noms des genres sont dans data.genres
if "genres" in data["data"]:
genre_names = []
for gid in genre_ids:
if str(gid) in data["data"]["genres"]:
genre_names.append(data["data"]["genres"][str(gid)]["name"])
if genre_names:
genre = ", ".join(genre_names)
# Extraire l'image de couverture (boxart)
# Utiliser l'endpoint dédié /v1/Games/Images pour récupérer les images du jeu
image_url = None
try:
images_url = f"{API_BASE_URL}/Games/Images"
images_params = {
"apikey": API_KEY,
"games_id": game_id,
"filter[type]": "boxart"
}
except Exception as e:
logger.warning(f"Erreur lors de la récupération de la page du jeu: {e}")
logger.debug(f"Récupération des images pour game_id={game_id}")
images_response = requests.get(images_url, params=images_params, timeout=10)
if images_response.status_code == 200:
images_data = images_response.json()
# Récupérer l'URL de base
base_url_original = ""
if "data" in images_data and "base_url" in images_data["data"]:
base_url_original = images_data["data"]["base_url"].get("original", "")
# Parcourir les images
if "data" in images_data and "images" in images_data["data"]:
images_dict = images_data["data"]["images"]
# Les images sont organisées par game_id
if str(game_id) in images_dict:
game_images = images_dict[str(game_id)]
# Chercher front boxart en priorité
for img in game_images:
if img.get("type") == "boxart" and img.get("side") == "front":
filename = img.get("filename")
if filename:
image_url = f"{base_url_original}{filename}"
logger.info(f"Image front trouvée: {image_url}")
break
# Si pas de front, prendre n'importe quelle boxart
if not image_url:
for img in game_images:
if img.get("type") == "boxart":
filename = img.get("filename")
if filename:
image_url = f"{base_url_original}{filename}"
logger.info(f"Image boxart trouvée: {image_url}")
break
# Si toujours rien, prendre la première image
if not image_url and game_images:
filename = game_images[0].get("filename")
if filename:
image_url = f"{base_url_original}{filename}"
logger.info(f"Première image trouvée: {image_url}")
else:
logger.warning(f"Erreur lors de la récupération des images: HTTP {images_response.status_code}")
except Exception as img_error:
logger.warning(f"Erreur lors de la récupération des images: {img_error}")
# Construire le résultat
result = {
@@ -254,15 +311,16 @@ def get_game_metadata(game_name, platform_name):
"release_date": release_date
}
# Vérifier qu'on a au moins quelque chose
if not any([image_url, description, genre]):
result["error"] = "Métadonnées incomplètes"
logger.info(f"Métadonnées récupérées: image={bool(image_url)}, desc={bool(description)}, genre={bool(genre)}, date={bool(release_date)}")
return result
except requests.RequestException as e:
logger.error(f"Erreur lors de la requête: {str(e)}")
logger.error(f"Erreur lors de la requête API: {str(e)}")
return {"error": f"Erreur réseau: {str(e)}"}
except Exception as e:
logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
return {"error": f"Erreur: {str(e)}"}
def download_image_to_surface(image_url):

View File

@@ -1,406 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import time
import json
import re
import traceback
from typing import Any, Dict, Tuple, List, Optional
try:
import pygame # type: ignore
except Exception as e:
print("Pygame is required. Install with: pip install pygame")
raise
PROMPTS = [
# Face buttons
"SOUTH_BUTTON - CONFIRM", # A on Xbox
"EAST_BUTTON - CANCEL", # B on Xbox
"WEST_BUTTON - CLEAR HISTORY / SELECT GAMES", # X on Xbox
"NORTH_BUTTON - HISTORY", # Y on Xbox
# Meta
"START - PAUSE",
"SELECT - FILTER",
# D-Pad
"DPAD_UP - MOVE UP",
"DPAD_DOWN - MOVE DOWN",
"DPAD_LEFT - MOVE LEFT",
"DPAD_RIGHT - MOVE RIGHT",
# Bumpers
"LEFT_BUMPER - LB/L1 - Delete last char",
"RIGHT_BUMPER - RB/R1 - Add space",
# Triggers
"LEFT_TRIGGER - LT/L2 - Page +",
"RIGHT_TRIGGER - RT/R2 - Page -",
# Left stick directions
"JOYSTICK_LEFT_UP - MOVE UP",
"JOYSTICK_LEFT_DOWN - MOVE DOWN",
"JOYSTICK_LEFT_LEFT - MOVE LEFT",
"JOYSTICK_LEFT_RIGHT - MOVE RIGHT",
]
INPUT_TIMEOUT_SECONDS = 10 # Temps max par entrée avant "ignored"
# --- Minimal on-screen console (Pygame window) ---
SURFACE = None # type: ignore
FONT = None # type: ignore
LOG_LINES: List[str] = []
MAX_LOG = 300
def init_screen(width: int = 900, height: int = 600) -> None:
global SURFACE, FONT
try:
pygame.display.init()
SURFACE = pygame.display.set_mode((width, height))
pygame.display.set_caption("Controller Tester")
pygame.font.init()
FONT = pygame.font.SysFont("Consolas", 20) or pygame.font.Font(None, 20)
except Exception:
# If display init fails, stay headless but continue
SURFACE = None
FONT = None
def log(msg: str) -> None:
# Print to real console and on-screen log
try:
print(msg)
except Exception:
pass
LOG_LINES.append(str(msg))
if len(LOG_LINES) > MAX_LOG:
del LOG_LINES[: len(LOG_LINES) - MAX_LOG]
draw_log()
def draw_log() -> None:
if SURFACE is None or FONT is None:
return
try:
SURFACE.fill((12, 12, 12))
margin = 12
line_h = FONT.get_height() + 4
# Show the last N lines that fit on screen
max_lines = (SURFACE.get_height() - margin * 2) // line_h
to_draw = LOG_LINES[-max_lines:]
y = margin
for line in to_draw:
surf = FONT.render(line, True, (220, 220, 220))
SURFACE.blit(surf, (margin, y))
y += line_h
pygame.display.flip()
except Exception:
pass
def init_joystick() -> pygame.joystick.Joystick:
pygame.init()
pygame.joystick.init()
if pygame.joystick.get_count() == 0:
log("No joystick detected. Connect a controller and try again.")
sys.exit(1)
js = pygame.joystick.Joystick(0)
js.init()
name = js.get_name()
log(f"Using joystick 0: {name}")
log("")
log(f"Note: each input will auto-ignore after {INPUT_TIMEOUT_SECONDS}s if not present (e.g. missing L2/R2)")
return js
def wait_for_stable(js: pygame.joystick.Joystick, settle_ms: int = 250, deadband: float = 0.05, timeout_ms: int = 2000) -> bool:
"""Wait until axes stop moving (change < deadband) continuously for settle_ms.
Unlike a traditional neutral check, this doesn't assume axes center at 0.
Hats are required to be (0,0) to avoid capturing D-Pad releases.
Returns True if stability achieved, False on timeout.
"""
start = pygame.time.get_ticks()
last = [js.get_axis(i) for i in range(js.get_numaxes())]
stable_since = None
while True:
# Handle window close only (avoid quitting on keyboard here)
for event in pygame.event.get():
if event.type == pygame.QUIT:
log("Window closed. Exiting.")
sys.exit(0)
moved = False
for i in range(js.get_numaxes()):
cur = js.get_axis(i)
if abs(cur - last[i]) > deadband:
moved = True
last[i] = cur
hats_ok = all(js.get_hat(i) == (0, 0) for i in range(js.get_numhats()))
if not moved and hats_ok:
if stable_since is None:
stable_since = pygame.time.get_ticks()
elif pygame.time.get_ticks() - stable_since >= settle_ms:
return True
else:
stable_since = None
if pygame.time.get_ticks() - start > timeout_ms:
return False
draw_log()
pygame.time.wait(10)
def wait_for_event(js: pygame.joystick.Joystick, logical_name: str, axis_threshold: float = 0.6, timeout_sec: int = INPUT_TIMEOUT_SECONDS) -> Tuple[str, Any]:
"""Wait for a joystick event for the given logical control.
Returns a tuple of (kind, data):
- ("button", button_index)
- ("hat", (x, y)) where x,y in {-1,0,1}
- ("axis", {"axis": index, "direction": -1|1})
"""
# Ensure prior motion has settled to avoid capturing a release
wait_for_stable(js)
log("")
deadline = time.time() + max(1, int(timeout_sec))
log(f"Press {logical_name} (Wait {timeout_sec}s to skip/ignore) if not present")
# Flush old events
pygame.event.clear()
while True:
# Update window title with countdown if we have a surface
try:
remaining = int(max(0, deadline - time.time()))
if SURFACE is not None:
pygame.display.set_caption(f"Controller Tester — {logical_name} — {remaining}s left")
except Exception:
pass
for event in pygame.event.get():
# Keyboard helpers
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
log(f"Skipped {logical_name}")
return ("skipped", None)
# No keyboard quit here to avoid accidental exits when using controllers
if event.type == pygame.QUIT:
log("Window closed. Exiting.")
sys.exit(0)
# Buttons
if event.type == pygame.JOYBUTTONDOWN:
log(f"Captured {logical_name}: BUTTON {event.button}")
return ("button", event.button)
# D-Pad (HAT)
if event.type == pygame.JOYHATMOTION:
val = event.value # (x, y)
if val != (0, 0):
log(f"Captured {logical_name}: HAT {val}")
return ("hat", val)
# Axes (sticks, triggers)
if event.type == pygame.JOYAXISMOTION:
axis = event.axis
value = float(event.value)
if abs(value) >= axis_threshold:
direction = 1 if value > 0 else -1
log(f"Captured {logical_name}: AXIS {axis} dir {direction} (raw {value:.2f})")
return ("axis", {"axis": axis, "direction": direction, "raw": value})
draw_log()
# Timeout?
if time.time() >= deadline:
log(f"Ignored {logical_name} (timeout {timeout_sec}s)")
return ("ignored", None)
time.sleep(0.005)
def write_log(path: str, mapping: Dict[str, Tuple[str, Any]], device_name: str) -> None:
lines = []
lines.append("# Controller mapping log\n")
lines.append(f"# Device: {device_name}\n\n")
for name, (kind, data) in mapping.items():
if kind == "button":
lines.append(f"{name} = BUTTON {data}\n")
elif kind == "hat":
lines.append(f"{name} = HAT {data}\n")
elif kind == "axis":
ax = data.get("axis")
direction = data.get("direction")
lines.append(f"{name} = AXIS {ax} dir {direction}\n")
elif kind == "skipped":
lines.append(f"{name} = SKIPPED\n")
elif kind == "ignored":
lines.append(f"{name} = IGNORED\n")
else:
lines.append(f"{name} = UNKNOWN {data}\n")
with open(path, "w", encoding="utf-8") as f:
f.writelines(lines)
log("")
log(f"Saved mapping to: {path}")
# --- JSON preset generation ---
def sanitize_device_name(name: str) -> str:
s = name.strip().lower()
# Replace non-alphanumeric with underscore
s = re.sub(r"[^a-z0-9]+", "_", s)
s = re.sub(r"_+", "_", s).strip("_")
return s or "controller"
def to_json_binding(kind: str, data: Any, display: Optional[str] = None) -> Optional[Dict[str, Any]]:
if kind == "button" and isinstance(data, int):
return {"type": "button", "button": data, **({"display": display} if display else {})}
if kind == "hat" and isinstance(data, (tuple, list)) and len(data) == 2:
val = list(data)
return {"type": "hat", "value": val, **({"display": display} if display else {})}
if kind == "axis" and isinstance(data, dict):
axis = data.get("axis")
direction = data.get("direction")
if isinstance(axis, int) and direction in (-1, 1):
return {"type": "axis", "axis": axis, "direction": int(direction), **({"display": display} if display else {})}
return None
def build_controls_json(mapping: Dict[str, Tuple[str, Any]]) -> Dict[str, Any]:
# Map logical prompts to action keys and preferred display labels
prompt_map = {
"SOUTH_BUTTON - CONFIRM": ("confirm", "A"),
"EAST_BUTTON - CANCEL": ("cancel", "B"),
"WEST_BUTTON - CLEAR HISTORY / SELECT GAMES": ("clear_history", "X"),
"NORTH_BUTTON - HISTORY": ("history", "Y"),
"START - PAUSE": ("start", "Start"),
"SELECT - FILTER": ("filter", "Select"),
"DPAD_UP - MOVE UP": ("up", "↑"),
"DPAD_DOWN - MOVE DOWN": ("down", "↓"),
"DPAD_LEFT - MOVE LEFT": ("left", "←"),
"DPAD_RIGHT - MOVE RIGHT": ("right", "→"),
"LEFT_BUMPER - LB/L1 - Delete last char": ("delete", "LB"),
"RIGHT_BUMPER - RB/R1 - Add space": ("space", "RB"),
# Triggers per prompts: LEFT=page_up, RIGHT=page_down
"LEFT_TRIGGER - LT/L2 - Page +": ("page_up", "LT"),
"RIGHT_TRIGGER - RT/R2 - Page -": ("page_down", "RT"),
# Left stick directions (fallbacks for arrows)
"JOYSTICK_LEFT_UP - MOVE UP": ("up", "J↑"),
"JOYSTICK_LEFT_DOWN - MOVE DOWN": ("down", "J↓"),
"JOYSTICK_LEFT_LEFT - MOVE LEFT": ("left", "J←"),
"JOYSTICK_LEFT_RIGHT - MOVE RIGHT": ("right", "J→"),
}
result: Dict[str, Any] = {}
# First pass: take direct DPAD/face/meta/bumper/trigger bindings
for prompt, (action, disp) in prompt_map.items():
if prompt not in mapping:
continue
kind, data = mapping[prompt]
if kind in ("ignored", "skipped"):
continue
# Prefer DPAD over JOYSTICK for directions: handle fallback later
if action in ("up", "down", "left", "right"):
if prompt.startswith("DPAD_"):
b = to_json_binding(kind, data, disp)
if b:
result[action] = b
# Joystick handled as fallback if DPAD missing
else:
b = to_json_binding(kind, data, disp)
if b:
result[action] = b
# Second pass: fallback to joystick directions if arrows missing
fallbacks = [
("JOYSTICK_LEFT_UP - MOVE UP", "up", "J↑"),
("JOYSTICK_LEFT_DOWN - MOVE DOWN", "down", "J↓"),
("JOYSTICK_LEFT_LEFT - MOVE LEFT", "left", "J←"),
("JOYSTICK_LEFT_RIGHT - MOVE RIGHT", "right", "J→"),
]
for prompt, action, disp in fallbacks:
if action in result:
continue
if prompt in mapping:
kind, data = mapping[prompt]
if kind in ("ignored", "skipped"):
continue
b = to_json_binding(kind, data, disp)
if b:
result[action] = b
return result
def write_controls_json(device_name: str, controls: Dict[str, Any]) -> str:
"""Write the generated controls preset JSON in the same folder as this script.
Also embeds a JSON-safe comment with the device name under the _comment key.
"""
# Same folder as the launched script
base_dir = os.path.dirname(os.path.abspath(__file__))
fname = f"{sanitize_device_name(device_name)}_controller.json"
out_path = os.path.join(base_dir, fname)
# Include the detected device name for auto-preset matching
payload = {"device": device_name}
payload.update(controls)
try:
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=4)
return out_path
except Exception:
return out_path
def main() -> None:
init_screen()
js = init_joystick()
# Print device basics
try:
log(f"Buttons: {js.get_numbuttons()} | Axes: {js.get_numaxes()} | Hats: {js.get_numhats()}")
except Exception:
pass
mapping: Dict[str, Tuple[str, Any]] = {}
for logical in PROMPTS:
kind, data = wait_for_event(js, logical)
mapping[logical] = (kind, data)
# Short, consistent debounce for all inputs
pygame.event.clear()
pygame.time.wait(150)
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "controller_mapping.log")
write_log(log_path, mapping, js.get_name())
# Build and write ready-to-use JSON controls preset
controls = build_controls_json(mapping)
if controls:
out_json = write_controls_json(js.get_name(), controls)
log(f"Saved JSON preset to: {out_json}")
else:
log("No usable inputs captured to build a JSON preset.")
log("Done. Press Q or close the window to exit.")
if __name__ == "__main__":
try:
main()
except SystemExit:
# Allow intentional exits
pass
except Exception:
# Show traceback on screen and wait for window close
tb = traceback.format_exc()
try:
log("")
log("An error occurred:")
for line in tb.splitlines():
log(line)
# Idle until window is closed
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
raise SystemExit(1)
draw_log()
pygame.time.wait(50)
except Exception:
pass
finally:
try:
pygame.joystick.quit()
pygame.quit()
except Exception:
pass