mirror of
https://github.com/RetroGameSets/RGSX.git
synced 2026-03-21 09:15:27 +01:00
update scraper to use tgdb api
This commit is contained in:
@@ -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(''', "'")
|
||||
description = description.replace('"', '"')
|
||||
description = description.replace('&', '&')
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user