From 059c38d8d64dbcae5e03c33a070a9b2f2e28fd4f Mon Sep 17 00:00:00 2001 From: skymike03 Date: Mon, 20 Oct 2025 19:02:28 +0200 Subject: [PATCH] v2.3.0.1 - 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 --- SYMLINK_FEATURE.md | 77 -------- ports/RGSX/__main__.py | 77 +++++++- ports/RGSX/config.py | 18 +- ports/RGSX/controls.py | 391 ++++++++++++++++++++++++++++++----------- ports/RGSX/display.py | 201 +++++++++++++++++++-- ports/RGSX/scraper.py | 294 +++++++++++++++++++++++++++++++ ports/RGSX/utils.py | 25 ++- 7 files changed, 880 insertions(+), 203 deletions(-) delete mode 100644 SYMLINK_FEATURE.md create mode 100644 ports/RGSX/scraper.py diff --git a/SYMLINK_FEATURE.md b/SYMLINK_FEATURE.md deleted file mode 100644 index 396bb7f..0000000 --- a/SYMLINK_FEATURE.md +++ /dev/null @@ -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)` diff --git a/ports/RGSX/__main__.py b/ports/RGSX/__main__.py index abea5f1..d11202e 100644 --- a/ports/RGSX/__main__.py +++ b/ports/RGSX/__main__.py @@ -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) diff --git a/ports/RGSX/config.py b/ports/RGSX/config.py index 22b521a..a53988d 100644 --- a/ports/RGSX/config.py +++ b/ports/RGSX/config.py @@ -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: diff --git a/ports/RGSX/controls.py b/ports/RGSX/controls.py index 291e0db..c2ee5c0 100644 --- a/ports/RGSX/controls.py +++ b/ports/RGSX/controls.py @@ -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é diff --git a/ports/RGSX/display.py b/ports/RGSX/display.py index 2ebfb6a..d37163d 100644 --- a/ports/RGSX/display.py +++ b/ports/RGSX/display.py @@ -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) diff --git a/ports/RGSX/scraper.py b/ports/RGSX/scraper.py new file mode 100644 index 0000000..c5ce5fe --- /dev/null +++ b/ports/RGSX/scraper.py @@ -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'', 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('(\d{4}-\d{2}-\d{2})

', 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'

(.*?)

', 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'

Genre\(s\): (.*?)

', 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 diff --git a/ports/RGSX/utils.py b/ports/RGSX/utils.py index aa548a3..1e01e20 100644 --- a/ports/RGSX/utils.py +++ b/ports/RGSX/utils.py @@ -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)}")