- Add game info scraper test (does not save information) : Push "confirm/download" button for 3sec to see picture/info of  a game
- correct ps3 decrypt for some games with special characters inside name
This commit is contained in:
skymike03
2025-10-20 19:02:28 +02:00
parent acac05ea26
commit 059c38d8d6
7 changed files with 880 additions and 203 deletions

View File

@@ -1,77 +0,0 @@
# Symlink Option Feature
## Overview
This feature adds a simple toggle option to append the platform folder name to the download path, creating a symlink-friendly structure for external storage.
## How It Works
When the symlink option is **disabled** (default):
- Super Nintendo ROMs download to: `../roms/snes/`
- PlayStation 2 ROMs download to: `../roms/ps2/`
When the symlink option is **enabled**:
- Super Nintendo ROMs download to: `../roms/snes/snes/`
- PlayStation 2 ROMs download to: `../roms/ps2/ps2/`
This allows users to create symlinks from the platform folder to external storage locations.
## Usage
1. Open the pause menu (P key or Start button)
2. Navigate to "Symlink Option" (second to last option, before Quit)
3. Press Enter to toggle the option on/off
4. The menu will show the current status: "Symlink option enabled" or "Symlink option disabled"
## Implementation Details
### Files Added
- `symlink_settings.py` - Core functionality for managing the symlink option
### Files Modified
- `display.py` - Added symlink option to pause menu with dynamic status display
- `controls.py` - Added handling for symlink option toggle
- `network.py` - Modified download functions to use symlink paths when enabled
- Language files - Added translation strings for all supported languages
### Configuration
The symlink setting is stored in `symlink_settings.json` in the save folder:
```json
{
"use_symlink_path": false
}
```
### API Functions
- `get_symlink_option()` - Get current symlink option status
- `set_symlink_option(enabled)` - Enable/disable the symlink option
- `apply_symlink_path(base_path, platform_folder)` - Apply symlink path modification
## Example Use Case
1. Enable the symlink option
2. **Optional**: Create a symlink: `ln -s /external/storage/snes ../roms/snes/snes`
- If you don't create the symlink, the nested directories will be created automatically when you download ROMs
3. Download ROMs - the nested directories (like `../roms/snes/snes/`) will be created automatically if they don't exist
4. Now Super Nintendo ROMs will download to the external storage via the symlink (if created) or to the local nested directory
## Features
- **Simple Toggle**: Easy on/off switch in the pause menu
- **Persistent Settings**: Option is remembered between sessions
- **Multi-language Support**: Full internationalization
- **Backward Compatible**: Disabled by default, doesn't affect existing setups
- **Platform Agnostic**: Works with all platforms automatically
- **Automatic Directory Creation**: Nested directories are created automatically if they don't exist
## Technical Notes
- The option is disabled by default
- Settings are stored in JSON format
- Path modification is applied at download time
- Works with both regular downloads and 1fichier downloads
- No impact on existing ROMs or folder structure
- Missing directories are automatically created using `os.makedirs(dest_dir, exist_ok=True)`

View File

@@ -478,6 +478,73 @@ async def main():
# Gestion de la répétition automatique des actions
process_key_repeats(sources, joystick, screen)
# Gestion de l'appui long sur confirm dans le menu game pour ouvrir le scraper
if (config.menu_state == "game" and
config.confirm_press_start_time > 0 and
not config.confirm_long_press_triggered):
press_duration = current_time - config.confirm_press_start_time
if press_duration >= config.confirm_long_press_threshold:
# Appui long détecté, ouvrir le scraper
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
game_name = games[config.current_game][0]
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
config.previous_menu_state = "game"
config.menu_state = "scraper"
config.scraper_game_name = game_name
config.scraper_platform_name = platform
config.scraper_loading = True
config.scraper_error_message = ""
config.scraper_image_surface = None
config.scraper_image_url = ""
config.scraper_description = ""
config.scraper_genre = ""
config.scraper_release_date = ""
config.scraper_game_page_url = ""
config.needs_redraw = True
config.confirm_long_press_triggered = True # Éviter de déclencher plusieurs fois
logger.debug(f"Appui long détecté ({press_duration}ms), ouverture du scraper pour {game_name}")
# Lancer la recherche des métadonnées dans un thread séparé
def scrape_async():
from scraper import get_game_metadata, download_image_to_surface
logger.info(f"Scraping métadonnées pour {game_name} sur {platform}")
metadata = get_game_metadata(game_name, platform)
# Vérifier si on a une erreur
if "error" in metadata:
config.scraper_error_message = metadata["error"]
config.scraper_loading = False
config.needs_redraw = True
logger.error(f"Erreur de scraping: {metadata['error']}")
return
# Mettre à jour les métadonnées textuelles
config.scraper_description = metadata.get("description", "")
config.scraper_genre = metadata.get("genre", "")
config.scraper_release_date = metadata.get("release_date", "")
config.scraper_game_page_url = metadata.get("game_page_url", "")
# Télécharger l'image si disponible
image_url = metadata.get("image_url")
if image_url:
logger.info(f"Téléchargement de l'image: {image_url}")
image_surface = download_image_to_surface(image_url)
if image_surface:
config.scraper_image_surface = image_surface
config.scraper_image_url = image_url
else:
logger.warning("Échec du téléchargement de l'image")
config.scraper_loading = False
config.needs_redraw = True
logger.info("Scraping terminé")
import threading
thread = threading.Thread(target=scrape_async, daemon=True)
thread.start()
# Gestion des événements
events = pygame.event.get()
for event in events:
@@ -594,6 +661,7 @@ async def main():
"history_game_options",
"history_show_folder",
"history_scraper_info",
"scraper", # Ajout du scraper pour gérer les contrôles
"history_error_details",
"history_confirm_delete",
"history_extract_archive",
@@ -830,7 +898,7 @@ async def main():
# Gestion des téléchargements
if config.download_tasks:
for task_id, (task, url, game_name, platform_name) in list(config.download_tasks.items()):
logger.debug(f"[DOWNLOAD_CHECK] Checking task {task_id}: done={task.done()}, game={game_name}")
#logger.debug(f"[DOWNLOAD_CHECK] Checking task {task_id}: done={task.done()}, game={game_name}")
if task.done():
logger.debug(f"[DOWNLOAD_COMPLETE] Task {task_id} is done, processing result for {game_name}")
try:
@@ -840,9 +908,9 @@ async def main():
message = message.split("https://")[0].strip()
logger.debug(f"[HISTORY_SEARCH] Searching in {len(config.history)} history entries for url={url[:50]}...")
for entry in config.history:
logger.debug(f"[HISTORY_ENTRY] Checking: url_match={entry['url'] == url}, status={entry['status']}, game={entry.get('game_name')}")
#logger.debug(f"[HISTORY_ENTRY] Checking: url_match={entry['url'] == url}, status={entry['status']}, game={entry.get('game_name')}")
if entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
logger.debug(f"[HISTORY_MATCH] Found matching entry for {game_name}, updating status")
#logger.debug(f"[HISTORY_MATCH] Found matching entry for {game_name}, updating status")
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
@@ -994,6 +1062,9 @@ async def main():
elif config.menu_state == "history_show_folder":
from display import draw_history_show_folder
draw_history_show_folder(screen)
elif config.menu_state == "scraper":
from display import draw_scraper_screen
draw_scraper_screen(screen)
elif config.menu_state == "history_scraper_info":
from display import draw_history_scraper_info
draw_history_scraper_info(screen)

View File

@@ -13,7 +13,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.3.0.0"
app_version = "2.3.0.1"
def get_application_root():
@@ -307,6 +307,18 @@ confirm_cancel_selection = 0 # confirmation annulation téléchargement
# Tracking des jeux téléchargés
downloaded_games = {} # Dict {platform_name: {game_name: {"timestamp": "...", "size": "..."}}}
# Scraper de métadonnées
scraper_image_surface = None # Surface Pygame contenant l'image scrapée
scraper_image_url = "" # URL de l'image actuellement affichée
scraper_game_name = "" # Nom du jeu en cours de scraping
scraper_platform_name = "" # Nom de la plateforme en cours de scraping
scraper_loading = False # Indicateur de chargement en cours
scraper_error_message = "" # Message d'erreur du scraper
scraper_description = "" # Description du jeu
scraper_genre = "" # Genre(s) du jeu
scraper_release_date = "" # Date de sortie du jeu
scraper_game_page_url = "" # URL de la page du jeu sur TheGamesDB
# CLES API / PREMIUM HOSTS
API_KEY_1FICHIER = ""
API_KEY_ALLDEBRID = ""
@@ -328,6 +340,10 @@ current_music_name = None # Nom de la piste musicale actuelle
music_popup_start_time = 0 # Timestamp de début du popup musique
error_message = "" # Message d'erreur à afficher
# Détection d'appui long sur confirm (menu game)
confirm_press_start_time = 0 # Timestamp du début de l'appui sur confirm
confirm_long_press_threshold = 2000 # Durée en ms pour déclencher l'appui long (2 secondes)
confirm_long_press_triggered = False # Flag pour éviter de déclencher plusieurs fois
# Tenter la récupération de la famille de police sauvegardée
try:

View File

@@ -46,6 +46,7 @@ VALID_STATES = [
"history_game_options", # menu options pour un jeu de l'historique
"history_show_folder", # afficher le dossier de téléchargement
"history_scraper_info", # info scraper non implémenté
"scraper", # écran du scraper avec métadonnées
"history_error_details", # détails de l'erreur
"history_confirm_delete", # confirmation suppression jeu
"history_extract_archive" # extraction d'archive
@@ -706,100 +707,15 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "reload_games_data"
config.needs_redraw = True
logger.debug("Passage à reload_games_data depuis game")
# Télécharger le jeu courant
# Télécharger le jeu courant (ou scraper si appui long)
elif is_input_matched(event, "confirm"):
if games:
url = games[config.current_game][1]
game_name = games[config.current_game][0]
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Vérification pour {game_name}, URL: {url}")
# Vérifier d'abord l'extension avant d'ajouter à l'historique
if is_1fichier_url(url):
from utils import ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string
ensure_download_provider_keys(False)
# Avertissement si pas de clé (utilisation mode gratuit)
if missing_all_provider_keys():
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
load_extensions_json()
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
# Afficher un toast de notification
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
logger.debug(f"Début du téléchargement 1fichier: {game_name} pour {platform} depuis {url}, task_id={task_id}")
config.pending_download = None
action = "download"
else:
config.menu_state = "error"
config.error_message = "Extension non supportée ou erreur de téléchargement"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
else:
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
extensions_data = load_extensions_json()
logger.debug(f"Extensions chargées: {len(extensions_data)} systèmes")
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
extensions_data
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
logger.debug(f"Extension check pour {game_name}: is_supported={is_supported}, zip_ok={zip_ok}, allow_unknown={allow_unknown}")
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_rom(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
# Afficher un toast de notification
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
config.pending_download = None
action = "download"
else:
config.menu_state = "error"
try:
config.error_message = _("error_invalid_download_data")
except Exception:
config.error_message = "Invalid download data"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
# Détecter le début de l'appui
if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN):
config.confirm_press_start_time = current_time
config.confirm_long_press_triggered = False
logger.debug(f"Début appui confirm à {current_time}")
# NE PAS télécharger immédiatement, attendre le relâchement
# pour déterminer si c'est un appui long ou court
# Avertissement extension
elif config.menu_state == "extension_warning":
@@ -1024,8 +940,9 @@ def handle_controls(event, sources, joystick, screen):
# Déterminer les options disponibles selon le statut
options = []
# Option commune: dossier de téléchargement
options.append("download_folder")
# Option commune: scraper (toujours disponible)
options.append("scraper")
# Options selon statut
if status == "Download_OK" or status == "Completed":
@@ -1034,15 +951,15 @@ def handle_controls(event, sources, joystick, screen):
ext = os.path.splitext(actual_filename)[1].lower()
if ext in ['.zip', '.rar']:
options.append("extract_archive")
# Scraper et suppression uniquement si le fichier existe
if file_exists:
options.append("scraper")
options.append("delete_game")
elif status in ["Erreur", "Error", "Canceled"]:
options.append("error_info")
options.append("retry")
options.append("delete_game")
# Options communes si le fichier existe
if file_exists:
options.append("download_folder")
options.append("delete_game")
# Option commune: retour
options.append("back")
@@ -1084,11 +1001,60 @@ def handle_controls(event, sources, joystick, screen):
logger.debug(f"Extraction de l'archive {game_name}")
elif selected_option == "scraper":
# Scraper (non implémenté pour le moment)
# Lancer le scraper
config.previous_menu_state = "history_game_options"
config.menu_state = "history_scraper_info"
config.menu_state = "scraper"
config.scraper_game_name = game_name
config.scraper_platform_name = platform
config.scraper_loading = True
config.scraper_error_message = ""
config.scraper_image_surface = None
config.scraper_image_url = ""
config.scraper_description = ""
config.scraper_genre = ""
config.scraper_release_date = ""
config.scraper_game_page_url = ""
config.needs_redraw = True
logger.debug(f"Scraper pour {game_name} (non implémenté)")
logger.debug(f"Lancement du scraper pour {game_name}")
# Lancer la recherche des métadonnées dans un thread séparé
def scrape_async():
from scraper import get_game_metadata, download_image_to_surface
logger.info(f"Scraping métadonnées pour {game_name} sur {platform}")
metadata = get_game_metadata(game_name, platform)
# Vérifier si on a une erreur
if "error" in metadata:
config.scraper_error_message = metadata["error"]
config.scraper_loading = False
config.needs_redraw = True
logger.error(f"Erreur de scraping: {metadata['error']}")
return
# Mettre à jour les métadonnées textuelles
config.scraper_description = metadata.get("description", "")
config.scraper_genre = metadata.get("genre", "")
config.scraper_release_date = metadata.get("release_date", "")
config.scraper_game_page_url = metadata.get("game_page_url", "")
# Télécharger l'image si disponible
image_url = metadata.get("image_url")
if image_url:
logger.info(f"Téléchargement de l'image: {image_url}")
image_surface = download_image_to_surface(image_url)
if image_surface:
config.scraper_image_surface = image_surface
config.scraper_image_url = image_url
else:
logger.warning("Échec du téléchargement de l'image")
config.scraper_loading = False
config.needs_redraw = True
logger.info("Scraping terminé")
thread = threading.Thread(target=scrape_async, daemon=True)
thread.start()
elif selected_option == "delete_game":
# Demander confirmation avant suppression
@@ -1153,7 +1119,26 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = validate_menu_state(config.previous_menu_state)
config.needs_redraw = True
# Information scraper (non implémenté)
# Scraper
elif config.menu_state == "scraper":
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
logger.info(f"Scraper: fermeture demandée")
# Retour au menu précédent
config.menu_state = validate_menu_state(config.previous_menu_state)
# Nettoyer les variables du scraper
config.scraper_image_surface = None
config.scraper_image_url = ""
config.scraper_game_name = ""
config.scraper_platform_name = ""
config.scraper_loading = False
config.scraper_error_message = ""
config.scraper_description = ""
config.scraper_genre = ""
config.scraper_release_date = ""
config.scraper_game_page_url = ""
config.needs_redraw = True
# Information scraper (ancien, gardé pour compatibilité)
elif config.menu_state == "history_scraper_info":
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
config.menu_state = validate_menu_state(config.previous_menu_state)
@@ -1224,7 +1209,7 @@ def handle_controls(event, sources, joystick, screen):
entry = config.history[config.current_history_item]
platform = entry.get("platform", "")
import threading
# threading est déjà importé en haut du fichier (ligne 8)
# Utiliser le chemin réel trouvé (avec ou sans extension)
file_path = getattr(config, 'history_actual_path', None)
@@ -1995,6 +1980,104 @@ def handle_controls(event, sources, joystick, screen):
if config.controls_config.get(action_name, {}).get("type") == "key" and \
config.controls_config.get(action_name, {}).get("key") == event.key:
update_key_state(action_name, False)
# Gestion spéciale pour confirm dans le menu game
if action_name == "confirm" and config.menu_state == "game":
press_duration = current_time - config.confirm_press_start_time
# Si appui court (< 2 secondes) et pas déjà traité par l'appui long
if press_duration < config.confirm_long_press_threshold and not config.confirm_long_press_triggered:
# Déclencher le téléchargement normal
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
url = games[config.current_game][1]
game_name = games[config.current_game][0]
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")
# Vérifier d'abord l'extension avant d'ajouter à l'historique
if is_1fichier_url(url):
from utils import ensure_download_provider_keys, missing_all_provider_keys
ensure_download_provider_keys(False)
# Avertissement si pas de clé (utilisation mode gratuit)
if missing_all_provider_keys():
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
load_extensions_json()
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
logger.debug(f"Début du téléchargement 1fichier: {game_name} pour {platform}, task_id={task_id}")
config.pending_download = None
else:
config.menu_state = "error"
config.error_message = "Extension non supportée ou erreur de téléchargement"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
else:
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
extensions_data = load_extensions_json()
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
extensions_data
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_rom(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
config.pending_download = None
else:
config.menu_state = "error"
try:
config.error_message = _("error_invalid_download_data")
except Exception:
config.error_message = "Invalid download data"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
# Réinitialiser les flags
config.confirm_press_start_time = 0
config.confirm_long_press_triggered = False
elif event.type == pygame.JOYBUTTONUP:
# Vérifier quel bouton a été relâché
@@ -2002,6 +2085,104 @@ def handle_controls(event, sources, joystick, screen):
if config.controls_config.get(action_name, {}).get("type") == "button" and \
config.controls_config.get(action_name, {}).get("button") == event.button:
update_key_state(action_name, False)
# Gestion spéciale pour confirm dans le menu game
if action_name == "confirm" and config.menu_state == "game":
press_duration = current_time - config.confirm_press_start_time
# Si appui court (< 2 secondes) et pas déjà traité par l'appui long
if press_duration < config.confirm_long_press_threshold and not config.confirm_long_press_triggered:
# Déclencher le téléchargement normal (même code que pour KEYUP)
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
url = games[config.current_game][1]
game_name = games[config.current_game][0]
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")
# Vérifier d'abord l'extension avant d'ajouter à l'historique
if is_1fichier_url(url):
from utils import ensure_download_provider_keys, missing_all_provider_keys
ensure_download_provider_keys(False)
# Avertissement si pas de clé (utilisation mode gratuit)
if missing_all_provider_keys():
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
load_extensions_json()
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
logger.debug(f"Début du téléchargement 1fichier: {game_name} pour {platform}, task_id={task_id}")
config.pending_download = None
else:
config.menu_state = "error"
config.error_message = "Extension non supportée ou erreur de téléchargement"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
else:
config.pending_download = check_extension_before_download(url, platform, game_name)
if config.pending_download:
extensions_data = load_extensions_json()
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
extensions_data
)
zip_ok = bool(config.pending_download[3])
allow_unknown = False
try:
from rgsx_settings import get_allow_unknown_extensions
allow_unknown = get_allow_unknown_extensions()
except Exception:
allow_unknown = False
if (not is_supported and not zip_ok) and not allow_unknown:
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportée, passage à extension_warning pour {game_name}")
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_rom(url, platform, game_name, config.pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {game_name}")
config.needs_redraw = True
config.pending_download = None
else:
config.menu_state = "error"
try:
config.error_message = _("error_invalid_download_data")
except Exception:
config.error_message = "Invalid download data"
config.pending_download = None
config.needs_redraw = True
logger.error(f"config.pending_download est None pour {game_name}")
# Réinitialiser les flags
config.confirm_press_start_time = 0
config.confirm_long_press_triggered = False
elif event.type == pygame.JOYAXISMOTION and abs(event.value) < 0.5:
# Vérifier quel axe a été relâché

View File

@@ -762,7 +762,7 @@ def draw_game_list(screen):
logger.debug("Aucune liste de jeux disponible")
message = _("game_no_games")
lines = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font1.get_height() + 5
line_height = config.font.get_height() + 5
text_height = len(lines) * line_height
margin_top_bottom = 20
rect_height = text_height + 2 * margin_top_bottom
@@ -1464,6 +1464,10 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
("history", i18n("controls_action_close_history")),
("cancel", i18n("controls_cancel_back")),
],
"scraper": [
("confirm", i18n("controls_confirm_select")),
("cancel", i18n("controls_cancel_back")),
],
"error": [
("confirm", i18n("controls_confirm_select")),
],
@@ -2770,10 +2774,11 @@ def draw_history_game_options(screen):
options = []
option_labels = []
# Option commune: dossier de téléchargement
options.append("download_folder")
option_labels.append(_("history_option_download_folder"))
# Options communes
options.append("scraper")
option_labels.append(_("history_option_scraper"))
# Options selon statut
if status == "Download_OK" or status == "Completed":
# Vérifier si c'est une archive ET si le fichier existe
@@ -2782,21 +2787,18 @@ def draw_history_game_options(screen):
if ext in ['.zip', '.rar']:
options.append("extract_archive")
option_labels.append(_("history_option_extract_archive"))
# Scraper et suppression uniquement si le fichier existe
if file_exists:
options.append("scraper")
option_labels.append(_("history_option_scraper"))
options.append("delete_game")
option_labels.append(_("history_option_delete_game"))
elif status in ["Erreur", "Error", "Canceled"]:
options.append("error_info")
option_labels.append(_("history_option_error_info"))
options.append("retry")
option_labels.append(_("history_option_retry"))
# Options communes
if file_exists:
options.append("download_folder")
option_labels.append(_("history_option_download_folder"))
options.append("delete_game")
option_labels.append(_("history_option_delete_game"))
# Option commune: retour
options.append("back")
option_labels.append(_("history_option_back"))
@@ -3060,3 +3062,176 @@ def draw_history_extract_archive(screen):
draw_stylized_button(screen, _("button_OK"), rect_x + (rect_width - button_width) // 2, rect_y + text_height + margin_top_bottom, button_width, button_height, selected=True)
def draw_scraper_screen(screen):
screen.blit(OVERLAY, (0, 0))
# Dimensions de l'écran avec marge pour les contrôles en bas
margin = 40
# Calcul exact de la position des contrôles (même formule que draw_controls)
controls_y = config.screen_height - int(config.screen_height * 0.037)
bottom_margin = 10
rect_width = config.screen_width - 2 * margin
rect_height = controls_y - 2 * margin - bottom_margin
rect_x = margin
rect_y = margin
# Fond principal
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
# Titre
title_text = f"Scraper: {config.scraper_game_name}"
title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, rect_y + 40))
screen.blit(title_surface, title_rect)
# Sous-titre avec plateforme
subtitle_text = f"Platform: {config.scraper_platform_name}"
subtitle_surface = config.font.render(subtitle_text, True, THEME_COLORS["title_text"])
subtitle_rect = subtitle_surface.get_rect(center=(config.screen_width // 2, rect_y + 80))
screen.blit(subtitle_surface, subtitle_rect)
# Zone de contenu (après titre et sous-titre)
content_y = rect_y + 120
content_height = rect_height - 140 # Ajusté pour ne pas inclure les marges du bas
# Si chargement en cours
if config.scraper_loading:
loading_text = "Searching for metadata..."
loading_surface = config.font.render(loading_text, True, THEME_COLORS["text"])
loading_rect = loading_surface.get_rect(center=(config.screen_width // 2, config.screen_height // 2))
screen.blit(loading_surface, loading_rect)
# Si erreur
elif config.scraper_error_message:
error_lines = wrap_text(config.scraper_error_message, config.font, rect_width - 80)
line_height = config.font.get_height() + 10
start_y = config.screen_height // 2 - (len(error_lines) * line_height) // 2
for i, line in enumerate(error_lines):
error_surface = config.font.render(line, True, THEME_COLORS["error_text"])
error_rect = error_surface.get_rect(center=(config.screen_width // 2, start_y + i * line_height))
screen.blit(error_surface, error_rect)
# Si données disponibles
else:
# Division en deux colonnes: image à gauche, métadonnées à droite
left_width = int(rect_width * 0.4)
right_width = rect_width - left_width - 20
left_x = rect_x + 20
right_x = left_x + left_width + 20
# === COLONNE GAUCHE: IMAGE ===
if config.scraper_image_surface:
# Calculer la taille max pour l'image
max_image_width = left_width - 20
max_image_height = content_height - 20
# Redimensionner l'image en conservant le ratio
image = config.scraper_image_surface
img_width, img_height = image.get_size()
# Calculer le ratio de redimensionnement
width_ratio = max_image_width / img_width
height_ratio = max_image_height / img_height
scale_ratio = min(width_ratio, height_ratio, 1.0)
new_width = int(img_width * scale_ratio)
new_height = int(img_height * scale_ratio)
# Redimensionner l'image
scaled_image = pygame.transform.smoothscale(image, (new_width, new_height))
# Centrer l'image dans la colonne gauche
image_x = left_x + (left_width - new_width) // 2
image_y = content_y + (content_height - new_height) // 2
# Fond derrière l'image
padding = 10
bg_rect = pygame.Rect(image_x - padding, image_y - padding, new_width + 2 * padding, new_height + 2 * padding)
pygame.draw.rect(screen, THEME_COLORS["fond_image"], bg_rect, border_radius=8)
pygame.draw.rect(screen, THEME_COLORS["neon"], bg_rect, 2, border_radius=8)
# Afficher l'image
screen.blit(scaled_image, (image_x, image_y))
else:
# Pas d'image disponible
no_image_text = "No image available"
no_image_surface = config.font.render(no_image_text, True, THEME_COLORS["title_text"])
no_image_rect = no_image_surface.get_rect(center=(left_x + left_width // 2, content_y + content_height // 2))
screen.blit(no_image_surface, no_image_rect)
# === COLONNE DROITE: METADONNEES (centrées verticalement) ===
line_height = config.font.get_height() + 8
small_line_height = config.small_font.get_height() + 5
# Calculer la hauteur totale des métadonnées pour centrer verticalement
total_metadata_height = 0
# Compter les lignes de genre
if config.scraper_genre:
total_metadata_height += line_height * 2 + 10 # Label + valeur + espace
# Compter les lignes de date
if config.scraper_release_date:
total_metadata_height += line_height * 2 + 10 # Label + valeur + espace
# Compter les lignes de description
if config.scraper_description:
desc_lines = wrap_text(config.scraper_description, config.small_font, right_width - 100)
max_desc_lines = min(len(desc_lines), int((content_height - total_metadata_height - 100) / small_line_height))
total_metadata_height += line_height + 5 # Label + espace
total_metadata_height += max_desc_lines * small_line_height
# Calculer le Y de départ pour centrer verticalement
metadata_y = content_y + (content_height - total_metadata_height) // 2
# Genre
if config.scraper_genre:
genre_label = config.font.render("Genre:", True, THEME_COLORS["neon"])
screen.blit(genre_label, (right_x, metadata_y))
metadata_y += line_height
genre_value = config.font.render(config.scraper_genre, True, THEME_COLORS["text"])
screen.blit(genre_value, (right_x + 10, metadata_y))
metadata_y += line_height + 10
# Date de sortie
if config.scraper_release_date:
date_label = config.font.render("Release Date:", True, THEME_COLORS["neon"])
screen.blit(date_label, (right_x, metadata_y))
metadata_y += line_height
date_value = config.font.render(config.scraper_release_date, True, THEME_COLORS["text"])
screen.blit(date_value, (right_x + 10, metadata_y))
metadata_y += line_height + 10
# Description
if config.scraper_description:
desc_label = config.font.render("Description:", True, THEME_COLORS["neon"])
screen.blit(desc_label, (right_x, metadata_y))
metadata_y += line_height + 5
# Wrapper la description avec plus de padding à droite
desc_lines = wrap_text(config.scraper_description, config.small_font, right_width - 40)
max_desc_lines = min(len(desc_lines), int((content_height - (metadata_y - content_y)) / small_line_height))
for i, line in enumerate(desc_lines[:max_desc_lines]):
desc_surface = config.small_font.render(line, True, THEME_COLORS["text"])
screen.blit(desc_surface, (right_x + 10, metadata_y))
metadata_y += small_line_height
# Si trop de lignes, afficher "..."
if len(desc_lines) > max_desc_lines:
more_text = config.small_font.render("...", True, THEME_COLORS["title_text"])
screen.blit(more_text, (right_x + 10, metadata_y))
# URL de la source en bas (si disponible)
if config.scraper_game_page_url:
url_text = truncate_text_middle(config.scraper_game_page_url, config.small_font, rect_width - 80, is_filename=False)
url_surface = config.small_font.render(url_text, True, THEME_COLORS["title_text"])
url_rect = url_surface.get_rect(center=(config.screen_width // 2, rect_y + rect_height - 20))
screen.blit(url_surface, url_rect)

294
ports/RGSX/scraper.py Normal file
View File

@@ -0,0 +1,294 @@
"""
Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net
"""
import logging
import requests
import re
from io import BytesIO
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
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",
# 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"
}
def get_game_metadata(game_name, platform_name):
"""
Récupère les métadonnées complètes d'un jeu depuis TheGamesDB.net
Args:
game_name (str): Nom du jeu à rechercher
platform_name (str): Nom de la plateforme
Returns:
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}'")
# Obtenir l'ID de la plateforme
platform_id = PLATFORM_MAPPING.get(platform_name)
if not platform_id:
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)
if response.status_code != 200:
return {"error": f"Erreur HTTP {response.status_code}"}
html_content = response.text
# Trouver la première carte avec class 'card border-primary'
card_start = html_content.find('div class="card border-primary"')
if card_start == -1:
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}")
# 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}")
# 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}")
# Si on a l'URL de la page, récupérer la description et le genre
description = None
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}")
except Exception as e:
logger.warning(f"Erreur lors de la récupération de la page du jeu: {e}")
# Construire le résultat
result = {
"image_url": image_url,
"game_page_url": game_page_url,
"description": description,
"genre": genre,
"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"
return result
except requests.RequestException as e:
logger.error(f"Erreur lors de la requête: {str(e)}")
return {"error": f"Erreur réseau: {str(e)}"}
def download_image_to_surface(image_url):
"""
Télécharge une image depuis une URL et la convertit en surface Pygame
Args:
image_url (str): URL de l'image à télécharger
Returns:
pygame.Surface ou None: Surface Pygame contenant l'image, ou None en cas d'erreur
"""
try:
logger.debug(f"Téléchargement de l'image: {image_url}")
response = requests.get(image_url, timeout=10)
if response.status_code != 200:
logger.error(f"Erreur HTTP {response.status_code} lors du téléchargement de l'image")
return None
# Charger l'image depuis les bytes
image_data = BytesIO(response.content)
image_surface = pygame.image.load(image_data)
logger.info("Image téléchargée et chargée avec succès")
return image_surface
except Exception as e:
logger.error(f"Erreur lors du téléchargement de l'image: {str(e)}")
return None

View File

@@ -1338,16 +1338,33 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
# Continuer quand même, l'erreur sera capturée plus tard
if config.OPERATING_SYSTEM == "Windows":
# Utiliser des guillemets doubles et échapper correctement pour PowerShell
# Doubler les guillemets doubles internes pour l'échappement PowerShell
dkey_escaped = dkey_path.replace('"', '""')
ps3dec_escaped = config.PS3DEC_EXE.replace('"', '""')
iso_escaped = iso_path.replace('"', '""')
decrypted_escaped = decrypted_iso_path.replace('"', '""')
cmd = [
"powershell", "-Command",
f"$key = (Get-Content '{dkey_path}' -Raw).Trim(); " +
f"& '{config.PS3DEC_EXE}' d key $key '{iso_path}' '{decrypted_iso_path}'"
f'$key = (Get-Content "{dkey_escaped}" -Raw).Trim(); ' +
f'& "{ps3dec_escaped}" d key $key "{iso_escaped}" "{decrypted_escaped}"'
]
else: # Linux
# Utiliser des guillemets doubles avec échappement pour bash
# Échapper les caractères spéciaux: $, `, \, ", et !
def bash_escape(path):
return path.replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$').replace('`', '\\`')
dkey_escaped = bash_escape(dkey_path)
ps3dec_escaped = bash_escape(config.PS3DEC_LINUX)
iso_escaped = bash_escape(iso_path)
decrypted_escaped = bash_escape(decrypted_iso_path)
cmd = [
"bash", "-c",
f"key=$(cat '{dkey_path}' | tr -d ' \\n\\r\\t'); " +
f"'{config.PS3DEC_LINUX}' d key \"$key\" '{iso_path}' '{decrypted_iso_path}'"
f'key=$(cat "{dkey_escaped}" | tr -d \' \\n\\r\\t\'); ' +
f'"{ps3dec_escaped}" d key "$key" "{iso_escaped}" "{decrypted_escaped}"'
]
logger.debug(f"Commande de décryptage: {' '.join(cmd)}")