diff --git a/ports/RGSX/__main__.py b/ports/RGSX/__main__.py index cb71b89..32fdd88 100644 --- a/ports/RGSX/__main__.py +++ b/ports/RGSX/__main__.py @@ -265,6 +265,12 @@ async def main(): #logger.debug(f"Événement transmis à handle_controls dans pause_menu: {event.type}") continue + # Gestion des événements pour le menu de filtrage des plateformes + if config.menu_state == "filter_platforms": + action = handle_controls(event, sources, joystick, screen) + config.needs_redraw = True + continue + if config.menu_state == "accessibility_menu": from accessibility import handle_accessibility_events if handle_accessibility_events(event): @@ -342,9 +348,14 @@ async def main(): logger.debug("Action quit détectée, arrêt de l'application") elif action == "download" and config.menu_state == "game" and config.filtered_games: game = config.filtered_games[config.current_game] - game_name = game[0] if isinstance(game, (list, tuple)) else game - platform = config.platforms[config.current_platform]["name"] # Utiliser le nom de la plateforme - url = game[1] if isinstance(game, (list, tuple)) and len(game) > 1 else None + if isinstance(game, (list, tuple)): + game_name = game[0] + url = game[1] if len(game) > 1 else None + else: # fallback str + game_name = str(game) + url = None + # Nouveau schéma: config.platforms contient déjà platform_name (string) + platform = config.platforms[config.current_platform] if url: logger.debug(f"Vérification pour {game_name}, URL: {url}") # Ajouter une entrée temporaire à l'historique @@ -422,8 +433,12 @@ async def main(): platform = entry["platform"] game_name = entry["game_name"] for game in config.games: - if game[0] == game_name and config.platforms[config.current_platform] == platform: - url = game[1] + if isinstance(game, (list, tuple)) and game and game[0] == game_name and config.platforms[config.current_platform] == platform: + url = game[1] if len(game) > 1 else None + else: + continue + if not url: + continue logger.debug(f"Vérification pour retéléchargement de {game_name}, URL: {url}") if is_1fichier_url(url): if not config.API_KEY_1FICHIER: @@ -604,6 +619,9 @@ async def main(): elif config.menu_state == "pause_menu": draw_pause_menu(screen, config.selected_option) #logger.debug("Rendu de draw_pause_menu") + elif config.menu_state == "filter_platforms": + from display import draw_filter_platforms_menu + draw_filter_platforms_menu(screen) elif config.menu_state == "controls_help": draw_controls_help(screen, config.previous_menu_state) elif config.menu_state == "history": diff --git a/ports/RGSX/assets/music/pixel_racer.mp3 b/ports/RGSX/assets/music/pixel_racer.mp3 index ba6fcc7..ef6c8dc 100644 Binary files a/ports/RGSX/assets/music/pixel_racer.mp3 and b/ports/RGSX/assets/music/pixel_racer.mp3 differ diff --git a/ports/RGSX/assets/unrar.exe b/ports/RGSX/assets/unrar.exe new file mode 100644 index 0000000..2611148 Binary files /dev/null and b/ports/RGSX/assets/unrar.exe differ diff --git a/ports/RGSX/assets/xdvdfs b/ports/RGSX/assets/xdvdfs new file mode 100644 index 0000000..983a4ec Binary files /dev/null and b/ports/RGSX/assets/xdvdfs differ diff --git a/ports/RGSX/config.py b/ports/RGSX/config.py index e4d4531..442cd5d 100644 --- a/ports/RGSX/config.py +++ b/ports/RGSX/config.py @@ -2,10 +2,10 @@ import pygame # type: ignore import os import logging import platform -from rgsx_settings import load_rgsx_settings, save_rgsx_settings, migrate_old_settings +from rgsx_settings import load_rgsx_settings, save_rgsx_settings # Version actuelle de l'application -app_version = "1.9.9.4" +app_version = "2.0.0.0" def get_operating_system(): """Renvoie le nom du système d'exploitation.""" @@ -85,28 +85,22 @@ GAMELISTXML = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(APP_F UPDATE_FOLDER = os.path.join(APP_FOLDER, "update") LANGUAGES_FOLDER = os.path.join(APP_FOLDER, "languages") JSON_EXTENSIONS = os.path.join(APP_FOLDER, "rom_extensions.json") +MUSIC_FOLDER = os.path.join(APP_FOLDER, "assets", "music") #Dossier /saves/ports/rgsx -IMAGES_FOLDER = os.path.join(SAVE_FOLDER, "images", "systemes") +IMAGES_FOLDER = os.path.join(SAVE_FOLDER, "images") GAMES_FOLDER = os.path.join(SAVE_FOLDER, "games") -SOURCES_FILE = os.path.join(SAVE_FOLDER, "sources.json") +SOURCES_FILE = os.path.join(SAVE_FOLDER, "systems_list.json") CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json") HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json") - -# Nouveau fichier unifié pour les paramètres RGSX +API_KEY_1FICHIER = os.path.join(SAVE_FOLDER, "1fichierAPI.txt") RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json") -# Anciens chemins des fichiers de config (conservés temporairement pour la migration) -ACCESSIBILITY_FOLDER = os.path.join(SAVE_FOLDER, "accessibility.json") -LANGUAGE_CONFIG_PATH = os.path.join(SAVE_FOLDER, "language.json") -MUSIC_CONFIG_PATH = os.path.join(SAVE_FOLDER, "music_config.json") -SYMLINK_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "symlink_settings.json") - # URL OTA_SERVER_URL = "https://retrogamesets.fr/softs/" OTA_VERSION_ENDPOINT = os.path.join(OTA_SERVER_URL, "version.json") OTA_UPDATE_ZIP = os.path.join(OTA_SERVER_URL, "RGSX.zip") -OTA_data_ZIP = os.path.join(OTA_SERVER_URL, "rgsx-data.zip") +OTA_data_ZIP = os.path.join(OTA_SERVER_URL, "test.zip") #CHEMINS DES EXECUTABLES UNRAR_EXE = os.path.join(APP_FOLDER,"assets", "unrar.exe") @@ -197,6 +191,12 @@ batch_download_indices = [] # File d'attente des indices de jeux à traiter en batch_in_progress = False # Indique qu'un lot est en cours batch_pending_game = None # Données du jeu en attente de confirmation d'extension +# --- Filtre plateformes (UI) --- +selected_filter_index = 0 # index dans la liste visible triée +filter_platforms_scroll_offset = 0 # défilement si liste longue +filter_platforms_dirty = False # indique si modifications non sauvegardées +filter_platforms_selection = [] # copie de travail des plateformes visibles (bool masque?) structure: list of (name, hidden_bool) + GRID_COLS = 3 # Number of columns in the platform grid GRID_ROWS = 4 # Number of rows in the platform grid diff --git a/ports/RGSX/controls.py b/ports/RGSX/controls.py index c06c7d1..a5a661a 100644 --- a/ports/RGSX/controls.py +++ b/ports/RGSX/controls.py @@ -32,7 +32,7 @@ VALID_STATES = [ "platform", "game", "confirm_exit", "extension_warning", "pause_menu", "controls_help", "history", "controls_mapping", "redownload_game_cache", "restart_popup", "error", "loading", "confirm_clear_history", - "language_select" + "language_select", "filter_platforms" ] def validate_menu_state(state): @@ -1036,7 +1036,13 @@ def handle_controls(event, sources, joystick, screen): config.menu_state = "accessibility_menu" config.needs_redraw = True logger.debug("Passage au menu accessibilité") - elif config.selected_option == 5: # Source toggle + elif config.selected_option == 5: # Filter platforms + # Ne pas écraser previous_menu_state; il référence l'état avant l'ouverture du pause menu + config.menu_state = "filter_platforms" + config.selected_filter_index = 0 + config.filter_platforms_scroll_offset = 0 + config.needs_redraw = True + elif config.selected_option == 6: # Source toggle (index shifted by new option) try: from rgsx_settings import get_sources_mode, set_sources_mode current_mode = get_sources_mode() @@ -1052,13 +1058,13 @@ def handle_controls(event, sources, joystick, screen): logger.info(f"Changement du mode des sources vers {new_mode}") except Exception as e: logger.error(f"Erreur changement mode sources: {e}") - elif config.selected_option == 6: # Redownload game cache + elif config.selected_option == 7: # Redownload game cache config.previous_menu_state = validate_menu_state(config.previous_menu_state) config.menu_state = "redownload_game_cache" config.redownload_confirm_selection = 0 config.needs_redraw = True logger.debug(f"Passage à redownload_game_cache depuis pause_menu") - elif config.selected_option == 7: # Music toggle + elif config.selected_option == 8: # Music toggle config.music_enabled = not config.music_enabled save_music_config() if config.music_enabled: @@ -1070,7 +1076,7 @@ def handle_controls(event, sources, joystick, screen): pygame.mixer.music.stop() config.needs_redraw = True logger.info(f"Musique {'activée' if config.music_enabled else 'désactivée'} via menu pause") - elif config.selected_option == 8: # Symlink option + elif config.selected_option == 9: # Symlink option from rgsx_settings import set_symlink_option, get_symlink_option current_status = get_symlink_option() success, message = set_symlink_option(not current_status) @@ -1078,7 +1084,7 @@ def handle_controls(event, sources, joystick, screen): config.popup_timer = 3000 if success else 5000 config.needs_redraw = True logger.info(f"Symlink option {'activée' if not current_status else 'désactivée'} via menu pause") - elif config.selected_option == 9: # Quit + elif config.selected_option == 10: # Quit config.previous_menu_state = validate_menu_state(config.previous_menu_state) config.menu_state = "confirm_exit" config.confirm_selection = 0 @@ -1117,8 +1123,12 @@ def handle_controls(event, sources, joystick, screen): config.pending_download = None if os.path.exists(config.SOURCES_FILE): try: - os.remove(config.SOURCES_FILE) - logger.debug("Fichier sources.json supprimé avec succès") + if os.path.exists(config.SOURCES_FILE): + os.remove(config.SOURCES_FILE) + logger.debug("Fichier system_list.json supprimé avec succès") + if os.path.exists(config.SAVE_FOLDER + "/sources.json"): + os.remove(config.SAVE_FOLDER + "/sources.json") + logger.debug("Fichier sources.json supprimé avec succès") if os.path.exists(config.GAMES_FOLDER): shutil.rmtree(config.GAMES_FOLDER) logger.debug("Dossier games supprimé avec succès") @@ -1203,6 +1213,71 @@ def handle_controls(event, sources, joystick, screen): config.needs_redraw = True logger.debug("Annulation de la sélection de langue, retour au menu pause") + # Menu filtre plateformes + elif config.menu_state == "filter_platforms": + total_items = len(config.filter_platforms_selection) + action_buttons = 4 + extended_max = total_items + action_buttons - 1 + if is_input_matched(event, "up"): + if config.selected_filter_index > 0: + config.selected_filter_index -= 1 + config.needs_redraw = True + else: + # Wrap vers les boutons (premier bouton) depuis le haut + if total_items > 0: + config.selected_filter_index = total_items + config.needs_redraw = True + elif is_input_matched(event, "down"): + if config.selected_filter_index < extended_max: + config.selected_filter_index += 1 + config.needs_redraw = True + else: + # Wrap retour en haut de la liste + config.selected_filter_index = 0 + config.needs_redraw = True + elif is_input_matched(event, "left"): + if config.selected_filter_index >= total_items: + if config.selected_filter_index > total_items: + config.selected_filter_index -= 1 + config.needs_redraw = True + # sinon ignorer + elif is_input_matched(event, "right"): + if config.selected_filter_index >= total_items: + if config.selected_filter_index < extended_max: + config.selected_filter_index += 1 + config.needs_redraw = True + # sinon ignorer + elif is_input_matched(event, "confirm"): + if config.selected_filter_index < total_items: + name, hidden = config.filter_platforms_selection[config.selected_filter_index] + config.filter_platforms_selection[config.selected_filter_index] = (name, not hidden) + config.filter_platforms_dirty = True + config.needs_redraw = True + else: + btn_idx = config.selected_filter_index - total_items + from rgsx_settings import load_rgsx_settings, save_rgsx_settings + from utils import load_sources + settings = load_rgsx_settings() + if btn_idx == 0: # all visible + config.filter_platforms_selection = [(n, False) for n, _ in config.filter_platforms_selection] + config.filter_platforms_dirty = True + elif btn_idx == 1: # none visible + config.filter_platforms_selection = [(n, True) for n, _ in config.filter_platforms_selection] + config.filter_platforms_dirty = True + elif btn_idx == 2: # apply + hidden_list = [n for n, h in config.filter_platforms_selection if h] + settings["hidden_platforms"] = hidden_list + save_rgsx_settings(settings) + load_sources() + config.filter_platforms_dirty = False + config.menu_state = "pause_menu" + elif btn_idx == 3: # back + config.menu_state = "pause_menu" + config.needs_redraw = True + elif is_input_matched(event, "cancel"): + config.menu_state = "pause_menu" + config.needs_redraw = True + # Gestion des relâchements de touches if event.type == pygame.KEYUP: diff --git a/ports/RGSX/display.py b/ports/RGSX/display.py index 6198f15..83c86d5 100644 --- a/ports/RGSX/display.py +++ b/ports/RGSX/display.py @@ -83,8 +83,20 @@ def draw_stylized_button(screen, text, x, y, width, height, selected=False): # Transition d'image lors de la sélection d'un système def draw_validation_transition(screen, platform_index): - """Affiche une animation de transition fluide pour la sélection d’une plateforme.""" - platform_dict = config.platform_dicts[platform_index] + """Affiche une animation de transition fluide pour la sélection d’une plateforme. + Utilise le mapping par nom pour éviter les décalages d'image si l'ordre d'affichage + diffère de l'ordre de stockage.""" + # Récupérer le nom affiché correspondant à l'index trié + if platform_index < 0 or platform_index >= len(config.platforms): + return + platform_name = config.platforms[platform_index] + platform_dict = getattr(config, 'platform_dict_by_name', {}).get(platform_name) + if not platform_dict: + # Fallback index direct si mapping absent + try: + platform_dict = config.platform_dicts[platform_index] + except Exception: + return image = load_system_image(platform_dict) if not image: return @@ -468,8 +480,16 @@ def draw_platform_grid(screen): scale_base = 1.5 if is_selected else 1.0 scale = scale_base + pulse if is_selected else scale_base - platform_dict = config.platform_dicts[idx] - platform_id = platform_dict.get("platform", str(idx)) + # Récupération robuste du dict via nom + display_name = config.platforms[idx] + platform_dict = getattr(config, 'platform_dict_by_name', {}).get(display_name) + if not platform_dict: + # Fallback index brut + if idx < len(config.platform_dicts): + platform_dict = config.platform_dicts[idx] + else: + continue + platform_id = platform_dict.get("platform_name") or platform_dict.get("platform") or display_name # Utiliser le cache d'images pour éviter de recharger/redimensionner à chaque frame cache_key = f"{platform_id}_{scale:.2f}" @@ -570,15 +590,17 @@ def draw_game_list(screen): return line_height = config.small_font.get_height() + 10 + header_height = line_height # hauteur de l'en-tête identique à une ligne margin_top_bottom = 20 extra_margin_top = 20 extra_margin_bottom = 60 title_height = config.title_font.get_height() + 20 - available_height = config.screen_height - title_height - extra_margin_top - extra_margin_bottom - 2 * margin_top_bottom - items_per_page = available_height // line_height + # Réserver de l'espace pour l'en-tête (header_height) + available_height = config.screen_height - title_height - extra_margin_top - extra_margin_bottom - 2 * margin_top_bottom - header_height + items_per_page = max(1, available_height // line_height) - rect_height = items_per_page * line_height + 2 * margin_top_bottom + rect_height = header_height + items_per_page * line_height + 2 * margin_top_bottom rect_width = int(0.95 * config.screen_width) rect_x = (config.screen_width - rect_width) // 2 rect_y = title_height + extra_margin_top + (config.screen_height - title_height - extra_margin_top - extra_margin_bottom - rect_height) // 2 @@ -622,21 +644,60 @@ def draw_game_list(screen): 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) + # Largeur colonne taille (15%) mini 120px, reste pour nom + size_col_width = max(120, int(rect_width * 0.15)) + name_col_width = rect_width - 40 - size_col_width # padding horizontal 40 + + # ---- En-tête ---- + header_name = _("game_header_name") + header_size = _("game_header_size") + header_y_center = rect_y + margin_top_bottom + header_height // 2 + # Nom aligné gauche + header_name_surface = config.small_font.render(header_name, True, THEME_COLORS["text"]) + header_name_rect = header_name_surface.get_rect() + header_name_rect.midleft = (rect_x + 20, header_y_center) + # Taille alignée droite + header_size_surface = config.small_font.render(header_size, True, THEME_COLORS["text"]) + header_size_rect = header_size_surface.get_rect() + header_size_rect.midright = (rect_x + rect_width - 20, header_y_center) + screen.blit(header_name_surface, header_name_rect) + screen.blit(header_size_surface, header_size_rect) + # Ligne de séparation sous l'en-tête + separator_y = rect_y + margin_top_bottom + header_height + pygame.draw.line(screen, THEME_COLORS["border"], (rect_x + 20, separator_y), (rect_x + rect_width - 20, separator_y), 2) + + # Position de départ des lignes après l'en-tête + list_start_y = rect_y + margin_top_bottom + header_height + for i in range(config.scroll_offset, min(config.scroll_offset + items_per_page, len(games))): - game_name = games[i][0] if isinstance(games[i], (list, tuple)) else games[i] + item = games[i] + if isinstance(item, (list, tuple)) and item: + game_name = item[0] + size_val = item[2] if len(item) > 2 else None + else: + game_name = str(item) + size_val = None + size_text = size_val if (isinstance(size_val, str) and size_val.strip()) else "N/A" is_marked = i in getattr(config, 'selected_games', set()) - # Couleur verte si jeu sous curseur OU marqué en multi-sélection color = THEME_COLORS["fond_lignes"] if (i == config.current_game or is_marked) else THEME_COLORS["text"] - # Préfixe ASCII pour distinguer les jeux marqués (éviter collision glyphes) prefix = "[X] " if is_marked else " " - game_text = truncate_text_middle(prefix + game_name, config.small_font, rect_width - 40, is_filename=False) - text_surface = config.small_font.render(game_text, True, color) - text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + (i - config.scroll_offset) * line_height + line_height // 2)) + truncated_name = truncate_text_middle(prefix + game_name, config.small_font, name_col_width, is_filename=False) + name_surface = config.small_font.render(truncated_name, True, color) + size_surface = config.small_font.render(size_text, True, THEME_COLORS["text"]) + row_center_y = list_start_y + (i - config.scroll_offset) * line_height + line_height // 2 + # Position nom (aligné à gauche dans la boite) + name_rect = name_surface.get_rect() + name_rect.midleft = (rect_x + 20, row_center_y) + size_rect = size_surface.get_rect() + size_rect.midright = (rect_x + rect_width - 20, row_center_y) if i == config.current_game: - glow_surface = pygame.Surface((text_rect.width + 20, text_rect.height + 10), pygame.SRCALPHA) - pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (10, 5, text_rect.width, text_rect.height), border_radius=8) - screen.blit(glow_surface, (text_rect.left - 10, text_rect.top - 5)) - screen.blit(text_surface, text_rect) + glow_width = rect_width - 40 + glow_height = name_rect.height + 10 + glow_surface = pygame.Surface((glow_width, glow_height), pygame.SRCALPHA) + pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (0, 0, glow_width, glow_height), border_radius=8) + screen.blit(glow_surface, (rect_x + 20, row_center_y - glow_height // 2)) + screen.blit(name_surface, name_rect) + screen.blit(size_surface, size_rect) if len(games) > items_per_page: try: @@ -1202,6 +1263,7 @@ def draw_pause_menu(screen, selected_option): _("menu_history"), # 2 _("menu_language"), # 3 _("menu_accessibility"), # 4 + _("menu_filter_platforms"), # 5 new filter option f"{_('menu_games_source_prefix')}: {source_label}", # 5 _("menu_redownload_cache"), # 6 music_option, # 7 @@ -1232,6 +1294,116 @@ def draw_pause_menu(screen, selected_option): # Stocker le nombre total d'options pour la navigation dynamique config.pause_menu_total_options = len(options) +def draw_filter_platforms_menu(screen): + """Affiche le menu de filtrage des plateformes (afficher/masquer).""" + from rgsx_settings import load_rgsx_settings + screen.blit(OVERLAY, (0, 0)) + settings = load_rgsx_settings() + hidden = set(settings.get("hidden_platforms", [])) if isinstance(settings, dict) else set() + + # Initialiser la copie de travail si vide ou taille différente + if not config.filter_platforms_selection or len(config.filter_platforms_selection) != len(config.platform_dicts): + # Liste alphabétique complète (sans filtrer hidden existant) + all_names = sorted([d.get("platform_name", "") for d in config.platform_dicts if d.get("platform_name")]) + config.filter_platforms_selection = [(name, name in hidden) for name in all_names] + config.selected_filter_index = 0 + config.filter_platforms_scroll_offset = 0 + config.filter_platforms_dirty = False + + title_text = _("filter_platforms_title") + title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20)) + title_rect_inflated = title_rect.inflate(80, 40) + title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) + screen.blit(title_surface, title_rect) + + # Zone liste + list_width = int(config.screen_width * 0.7) + list_height = int(config.screen_height * 0.6) + list_x = (config.screen_width - list_width) // 2 + list_y = title_rect_inflated.bottom + 20 + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (list_x, list_y, list_width, list_height), border_radius=12) + pygame.draw.rect(screen, THEME_COLORS["border"], (list_x, list_y, list_width, list_height), 2, border_radius=12) + + line_height = config.small_font.get_height() + 8 + visible_items = list_height // line_height - 1 # laisser un peu d'espace bas + total_items = len(config.filter_platforms_selection) + if config.selected_filter_index < 0: + config.selected_filter_index = 0 + # Ne pas forcer la réduction si on est sur les boutons (indices >= total_items) + # Laisser controls.py gérer la borne max étendue + # Ajuster scroll + if config.selected_filter_index < config.filter_platforms_scroll_offset: + config.filter_platforms_scroll_offset = config.selected_filter_index + elif config.selected_filter_index >= config.filter_platforms_scroll_offset + visible_items: + config.filter_platforms_scroll_offset = config.selected_filter_index - visible_items + 1 + + # Dessiner items + for i in range(config.filter_platforms_scroll_offset, min(config.filter_platforms_scroll_offset + visible_items, total_items)): + name, is_hidden = config.filter_platforms_selection[i] + idx_on_screen = i - config.filter_platforms_scroll_offset + y_center = list_y + 10 + idx_on_screen * line_height + line_height // 2 + selected = (i == config.selected_filter_index) + checkbox = "[ ]" if is_hidden else "[X]" # inversé: coché signifie visible + # Correction: on veut [X] si visible => is_hidden False + checkbox = "[X]" if not is_hidden else "[ ]" + display_text = f"{checkbox} {name}" + color = THEME_COLORS["fond_lignes"] if selected else THEME_COLORS["text"] + text_surface = config.small_font.render(display_text, True, color) + text_rect = text_surface.get_rect(midleft=(list_x + 20, y_center)) + if selected: + glow_surface = pygame.Surface((list_width - 40, line_height), pygame.SRCALPHA) + pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (0, 0, list_width - 40, line_height), border_radius=8) + screen.blit(glow_surface, (list_x + 20, y_center - line_height // 2)) + screen.blit(text_surface, text_rect) + + # Scrollbar + if total_items > visible_items: + scroll_height = int((visible_items / total_items) * (list_height - 20)) + scroll_y = int((config.filter_platforms_scroll_offset / max(1, total_items - visible_items)) * (list_height - 20 - scroll_height)) + pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (list_x + list_width - 25, list_y + 10 + scroll_y, 10, scroll_height), border_radius=4) + + # Boutons d'action + btn_width = 220 + btn_height = int(config.screen_height * 0.0463) + spacing = 30 + buttons_y = list_y + list_height + 20 + center_x = config.screen_width // 2 + actions = [ + ("filter_all", -2), + ("filter_none", -3), + ("filter_apply", -4), + ("filter_back", -5) + ] + # Indice spécial sélection boutons quand selected_filter_index >= total_items + extra_index_base = total_items + # Ajuster selected_filter_index max pour inclure boutons + extended_max = total_items + len(actions) - 1 + if config.selected_filter_index > extended_max: + config.selected_filter_index = extended_max + + for idx, (key, offset) in enumerate(actions): + btn_x = center_x - (len(actions) * (btn_width + spacing) - spacing) // 2 + idx * (btn_width + spacing) + is_selected = (config.selected_filter_index == total_items + idx) + label = _(key) + draw_stylized_button(screen, label, btn_x, buttons_y, btn_width, btn_height, selected=is_selected) + + # Infos bas + hidden_count = sum(1 for _, h in config.filter_platforms_selection if h) + visible_count = total_items - hidden_count + info_text = _("filter_platforms_info").format(visible_count, hidden_count, total_items) + info_surface = config.small_font.render(info_text, True, THEME_COLORS["text"]) + info_rect = info_surface.get_rect(center=(config.screen_width // 2, buttons_y + btn_height + 30)) + screen.blit(info_surface, info_rect) + + if config.filter_platforms_dirty: + dirty_text = _("filter_unsaved_warning") + dirty_surface = config.small_font.render(dirty_text, True, THEME_COLORS["warning_text"]) + dirty_rect = dirty_surface.get_rect(center=(config.screen_width // 2, info_rect.bottom + 25)) + screen.blit(dirty_surface, dirty_rect) + # Menu aide contrôles def draw_controls_help(screen, previous_state): """Affiche la liste des contrôles (aide) avec mise en page adaptative.""" diff --git a/ports/RGSX/languages/de.json b/ports/RGSX/languages/de.json index a246a3a..068479e 100644 --- a/ports/RGSX/languages/de.json +++ b/ports/RGSX/languages/de.json @@ -26,7 +26,7 @@ "error_api_key": "Achtung, du musst deinen API-Schlüssel (nur Premium) in der Datei {0} eingeben", "error_api_key_extended": "Achtung, du musst deinen API-Schlüssel (nur Premium) in der Datei /userdata/saves/ports/rgsx/1fichierAPI.txt einfügen. Öffne die Datei in einem Texteditor und füge den API-Schlüssel ein", "error_invalid_download_data": "Ungültige Downloaddaten", - "error_delete_sources": "Fehler beim Löschen der Datei sources.json oder Ordner", + "error_delete_sources": "Fehler beim Löschen der Datei systems_list.json oder Ordner", "error_extension": "Nicht unterstützte Erweiterung oder Downloadfehler", "error_no_download": "Keine Downloads ausstehend.", @@ -37,6 +37,8 @@ "game_count": "{0} ({1} Spiele)", "game_filter": "Aktiver Filter: {0}", "game_search": "Filtern: {0}", + "game_header_name": "Name", + "game_header_size": "Größe", "history_title": "Downloads ({0})", "history_empty": "Keine Downloads im Verlauf", @@ -78,6 +80,14 @@ "menu_music_toggle": "Musik ein/aus", "menu_music_enabled": "Musik aktiviert: {0}", "menu_music_disabled": "Musik deaktiviert", + "menu_filter_platforms": "Systeme filtern", + "filter_platforms_title": "Systemsichtbarkeit", + "filter_all": "Alle anzeigen", + "filter_none": "Alle ausblenden", + "filter_apply": "Anwenden", + "filter_back": "Zurück", + "filter_platforms_info": "Sichtbar: {0} | Versteckt: {1} / Gesamt: {2}", + "filter_unsaved_warning": "Ungespeicherte Änderungen", "menu_quit": "Beenden", "button_yes": "Ja", diff --git a/ports/RGSX/languages/en.json b/ports/RGSX/languages/en.json index db22fb3..fb343d0 100644 --- a/ports/RGSX/languages/en.json +++ b/ports/RGSX/languages/en.json @@ -26,7 +26,7 @@ "error_api_key": "Please enter your API key (premium only) in the file {0}", "error_api_key_extended": "Please enter your API key (premium only) in the file /userdata/saves/ports/rgsx/1fichierAPI.txt by opening it in a text editor and pasting your API key", "error_invalid_download_data": "Invalid download data", - "error_delete_sources": "Error deleting sources.json file or folders", + "error_delete_sources": "Error deleting systems_list.json file or folders", "error_extension": "Unsupported extension or download error", "error_no_download": "No pending download.", @@ -37,6 +37,8 @@ "game_count": "{0} ({1} games)", "game_filter": "Active filter: {0}", "game_search": "Filter: {0}", + "game_header_name": "Name", + "game_header_size": "Size", "history_title": "Downloads ({0})", "history_empty": "No downloads in history", @@ -78,6 +80,14 @@ "menu_music_toggle": "Toggle music", "menu_music_enabled": "Music enabled: {0}", "menu_music_disabled": "Music disabled", + "menu_filter_platforms": "Filter systems", + "filter_platforms_title": "Systems visibility", + "filter_all": "Show all", + "filter_none": "Hide all", + "filter_apply": "Apply", + "filter_back": "Back", + "filter_platforms_info": "Visible: {0} | Hidden: {1} / Total: {2}", + "filter_unsaved_warning": "Unsaved changes", "menu_quit": "Quit", "button_yes": "Yes", diff --git a/ports/RGSX/languages/es.json b/ports/RGSX/languages/es.json index bd55cf1..28dc62e 100644 --- a/ports/RGSX/languages/es.json +++ b/ports/RGSX/languages/es.json @@ -27,7 +27,7 @@ "error_api_key": "Atención, debes ingresar tu clave API (solo premium) en el archivo {0}", "error_api_key_extended": "Atención, debes ingresar tu clave API (solo premium) en el archivo /userdata/saves/ports/rgsx/1fichierAPI.txt, abrirlo en un editor de texto y pegar la clave API", "error_invalid_download_data": "Datos de descarga no válidos", - "error_delete_sources": "Error al eliminar el archivo sources.json o carpetas", + "error_delete_sources": "Error al eliminar el archivo systems_list.json o carpetas", "error_extension": "Extensión no soportada o error de descarga", "error_no_download": "No hay descargas pendientes.", @@ -38,6 +38,8 @@ "game_count": "{0} ({1} juegos)", "game_filter": "Filtro activo: {0}", "game_search": "Filtrar: {0}", + "game_header_name": "Nombre", + "game_header_size": "Tamaño", "history_title": "Descargas ({0})", "history_empty": "No hay descargas en el historial", @@ -79,6 +81,14 @@ "menu_music_toggle": "Activar/Desactivar música", "menu_music_enabled": "Música activada: {0}", "menu_music_disabled": "Música desactivada", + "menu_filter_platforms": "Filtrar sistemas", + "filter_platforms_title": "Visibilidad de sistemas", + "filter_all": "Mostrar todo", + "filter_none": "Ocultar todo", + "filter_apply": "Aplicar", + "filter_back": "Volver", + "filter_platforms_info": "Visibles: {0} | Ocultos: {1} / Total: {2}", + "filter_unsaved_warning": "Cambios no guardados", "menu_quit": "Salir", "button_yes": "Sí", diff --git a/ports/RGSX/languages/fr.json b/ports/RGSX/languages/fr.json index d5963c5..eea1c5c 100644 --- a/ports/RGSX/languages/fr.json +++ b/ports/RGSX/languages/fr.json @@ -23,7 +23,7 @@ "error_api_key": "Attention il faut renseigner sa clé API (premium only) dans le fichier {0}", "error_api_key_extended": "Attention il faut renseigner sa clé API (premium only) dans le fichier /userdata/saves/ports/rgsx/1fichierAPI.txt à ouvrir dans un éditeur de texte et coller la clé API", "error_invalid_download_data": "Données de téléchargement invalides", - "error_delete_sources": "Erreur lors de la suppression du fichier sources.json ou dossiers", + "error_delete_sources": "Erreur lors de la suppression du fichier systems_list.json ou dossiers", "error_extension": "Extension non supportée ou erreur de téléchargement", "error_no_download": "Aucun téléchargement en attente.", @@ -34,6 +34,8 @@ "game_count": "{0} ({1} jeux)", "game_filter": "Filtre actif : {0}", "game_search": "Filtrer : {0}", + "game_header_name": "Nom", + "game_header_size": "Taille", "history_title": "Téléchargements ({0})", "history_empty": "Aucun téléchargement dans l'historique", @@ -76,6 +78,14 @@ "menu_music_toggle": "Activer/Désactiver la musique", "menu_music_enabled": "Musique activée : {0}", "menu_music_disabled": "Musique désactivée", + "menu_filter_platforms": "Filtrer les systèmes", + "filter_platforms_title": "Affichage des systèmes", + "filter_all": "Tout afficher", + "filter_none": "Tout masquer", + "filter_apply": "Appliquer", + "filter_back": "Retour", + "filter_platforms_info": "Visibles: {0} | Masqués: {1} / Total: {2}", + "filter_unsaved_warning": "Modifications non sauvegardées", "button_yes": "Oui", "button_no": "Non", diff --git a/ports/RGSX/network.py b/ports/RGSX/network.py index 775cb68..355ae41 100644 --- a/ports/RGSX/network.py +++ b/ports/RGSX/network.py @@ -260,7 +260,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas dest_dir = None for platform_dict in config.platform_dicts: - if platform_dict["platform"] == platform: + if platform_dict.get("platform_name") == platform: platform_folder = platform_dict.get("folder", normalize_platform_name(platform)) dest_dir = apply_symlink_path(config.ROMS_FOLDER, platform_folder) logger.debug(f"Répertoire de destination trouvé pour {platform}: {dest_dir}") @@ -298,9 +298,82 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas download_headers = headers.copy() download_headers['Accept'] = 'application/octet-stream, */*' download_headers['Referer'] = 'https://myrient.erista.me/' - response = session.get(url, stream=True, timeout=30, allow_redirects=True, headers=download_headers) - logger.debug(f"Status code: {response.status_code}") - response.raise_for_status() + + # Préparation spécifique archive.org : récupérer quelques pages pour obtenir cookies éventuels + if 'archive.org/download/' in url: + try: + pre_id = url.split('/download/')[1].split('/')[0] + session.get('https://archive.org/robots.txt', timeout=10) + session.get(f'https://archive.org/metadata/{pre_id}', timeout=10) + logger.debug(f"Pré-chargement cookies/metadata archive.org pour {pre_id}") + except Exception as e: + logger.debug(f"Pré-chargement archive.org ignoré: {e}") + # Tentatives multiples avec variations d'en-têtes pour contourner certains 401/403 (archive.org / hotlink protection) + header_variants = [ + download_headers, + { # Variante sans Referer spécifique + 'User-Agent': headers['User-Agent'], + 'Accept': 'application/octet-stream,*/*;q=0.8', + 'Accept-Language': headers['Accept-Language'], + 'Connection': 'keep-alive' + }, + { # Variante minimaliste type curl + 'User-Agent': 'curl/8.4.0', + 'Accept': '*/*' + }, + { # Variante avec Referer archive.org + 'User-Agent': headers['User-Agent'], + 'Accept': '*/*', + 'Referer': 'https://archive.org/' + } + ] + response = None + last_status = None + for attempt, hv in enumerate(header_variants, start=1): + try: + logger.debug(f"Tentative téléchargement {attempt}/{len(header_variants)} avec headers: {hv}") + r = session.get(url, stream=True, timeout=30, allow_redirects=True, headers=hv) + last_status = r.status_code + logger.debug(f"Status code tentative {attempt}: {r.status_code}") + if r.status_code in (401, 403): + # Lire un petit bout pour voir si message utile + try: + snippet = r.text[:200] + logger.debug(f"Réponse {r.status_code} snippet: {snippet}") + except Exception: + pass + continue # Essayer variante suivante + r.raise_for_status() + response = r + break + except requests.RequestException as e: + logger.debug(f"Erreur tentative {attempt}: {e}") + # Si ce n'est pas une erreur auth explicite et qu'on a un code => on sort + if isinstance(e, requests.HTTPError) and last_status not in (401, 403): + break + if response is None: + # Fallback metadata archive.org pour message clair + if 'archive.org/download/' in url: + try: + identifier = url.split('/download/')[1].split('/')[0] + meta_resp = session.get(f'https://archive.org/metadata/{identifier}', timeout=15) + if meta_resp.status_code == 200: + meta_json = meta_resp.json() + if meta_json.get('is_dark'): + raise requests.HTTPError(f"Item archive.org restreint (is_dark=true): {identifier}") + if not meta_json.get('files'): + raise requests.HTTPError(f"Item archive.org sans fichiers listés: {identifier}") + # Fichier peut avoir un nom différent : informer + available = [f.get('name') for f in meta_json.get('files', [])][:10] + raise requests.HTTPError(f"Accès refusé (HTTP {last_status}). Fichiers disponibles exemples: {available}") + else: + raise requests.HTTPError(f"HTTP {last_status} & metadata {meta_resp.status_code} pour {identifier}") + except requests.HTTPError: + raise + except Exception as e: + raise requests.HTTPError(f"HTTP {last_status} après variations; metadata échec: {e}") + auth_msg = f"HTTP {last_status} après variations d'en-têtes" if last_status else "Aucune réponse valide" + raise requests.HTTPError(auth_msg) total_size = int(response.headers.get('content-length', 0)) logger.debug(f"Taille totale: {total_size} octets") @@ -471,7 +544,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= dest_dir = None for platform_dict in config.platform_dicts: - if platform_dict["platform"] == platform: + if platform_dict.get("platform_name") == platform: platform_folder = platform_dict.get("folder", normalize_platform_name(platform)) dest_dir = apply_symlink_path(config.ROMS_FOLDER, platform_folder) break diff --git a/ports/RGSX/rgsx_settings.py b/ports/RGSX/rgsx_settings.py index e3a04b1..e03c647 100644 --- a/ports/RGSX/rgsx_settings.py +++ b/ports/RGSX/rgsx_settings.py @@ -29,9 +29,9 @@ def load_rgsx_settings(): "enabled": False, "target_directory": "" }, - "sources": { # Nouvelle section pour la source des jeux - "mode": "rgsx", # "rgsx" ou "custom" - "custom_url": "" # URL personnalisée pour le ZIP des sources + "sources": { + "mode": "rgsx", + "custom_url": "" } } @@ -44,12 +44,6 @@ def load_rgsx_settings(): if key not in settings: settings[key] = value return settings - else: - # Tenter de migrer depuis les anciens fichiers - migrated_settings = migrate_old_settings() - if migrated_settings: - save_rgsx_settings(migrated_settings) - return migrated_settings except Exception as e: print(f"Erreur lors du chargement de rgsx_settings.json: {str(e)}") @@ -68,96 +62,6 @@ def save_rgsx_settings(settings): print(f"Erreur lors de la sauvegarde de rgsx_settings.json: {str(e)}") -def migrate_old_settings(): - """Migre les anciens fichiers de configuration vers le nouveau format.""" - from config import LANGUAGE_CONFIG_PATH, MUSIC_CONFIG_PATH, ACCESSIBILITY_FOLDER, SYMLINK_SETTINGS_PATH - - migrated_settings = { - "language": "en", - "music_enabled": True, - "accessibility": { - "font_scale": 1.0 - }, - "symlink": { - "enabled": False, - "target_directory": "" - } - } - - files_to_remove = [] # Liste des fichiers à supprimer après migration réussie - - # Migrer language.json - if os.path.exists(LANGUAGE_CONFIG_PATH): - try: - with open(LANGUAGE_CONFIG_PATH, 'r', encoding='utf-8') as f: - content = f.read().strip() - # Gérer le cas où le fichier contient juste une chaîne (pas de JSON) - if content.startswith('"') and content.endswith('"'): - migrated_settings["language"] = content.strip('"') - elif not content.startswith('{'): - # Fichier texte simple sans guillemets - migrated_settings["language"] = content - else: - # Fichier JSON normal - lang_data = json.loads(content) - migrated_settings["language"] = lang_data.get("language", "en") - files_to_remove.append(LANGUAGE_CONFIG_PATH) - except: - pass - - # Migrer music_config.json - if os.path.exists(MUSIC_CONFIG_PATH): - try: - with open(MUSIC_CONFIG_PATH, 'r', encoding='utf-8') as f: - content = f.read().strip() - # Gérer le cas où le fichier contient juste un booléen - if content.lower() in ['true', 'false']: - migrated_settings["music_enabled"] = content.lower() == 'true' - else: - # Fichier JSON normal - music_data = json.loads(content) - migrated_settings["music_enabled"] = music_data.get("music_enabled", True) - files_to_remove.append(MUSIC_CONFIG_PATH) - except: - pass - - # Migrer accessibility.json - if os.path.exists(ACCESSIBILITY_FOLDER): - try: - with open(ACCESSIBILITY_FOLDER, 'r', encoding='utf-8') as f: - acc_data = json.load(f) - migrated_settings["accessibility"] = { - "font_scale": acc_data.get("font_scale", 1.0) - } - files_to_remove.append(ACCESSIBILITY_FOLDER) - except: - pass - - # Migrer symlink_settings.json - if os.path.exists(SYMLINK_SETTINGS_PATH): - try: - with open(SYMLINK_SETTINGS_PATH, 'r', encoding='utf-8') as f: - symlink_data = json.load(f) - migrated_settings["symlink"] = { - "enabled": symlink_data.get("use_symlink_path", False), - "target_directory": symlink_data.get("target_directory", "") - } - files_to_remove.append(SYMLINK_SETTINGS_PATH) - except: - pass - - # Supprimer les anciens fichiers après migration réussie - if files_to_remove: - print(f"Migration réussie. Suppression des anciens fichiers de configuration...") - for file_path in files_to_remove: - try: - os.remove(file_path) - print(f" - Supprimé: {os.path.basename(file_path)}") - except Exception as e: - print(f" - Erreur lors de la suppression de {os.path.basename(file_path)}: {e}") - - return migrated_settings - def load_symlink_settings(): """Load symlink settings from rgsx_settings.json.""" diff --git a/ports/RGSX/utils.py b/ports/RGSX/utils.py index a775a97..c8527a0 100644 --- a/ports/RGSX/utils.py +++ b/ports/RGSX/utils.py @@ -12,9 +12,9 @@ from rgsx_settings import load_rgsx_settings, save_rgsx_settings import zipfile import time import random -from config import JSON_EXTENSIONS, SAVE_FOLDER +import config from history import save_history -from language import _ # Import de la fonction de traduction +from language import _ from datetime import datetime @@ -47,10 +47,10 @@ def detect_non_pc(): def load_extensions_json(): """Charge le fichier JSON contenant les extensions supportées.""" try: - with open(JSON_EXTENSIONS, 'r', encoding='utf-8') as f: + with open(config.JSON_EXTENSIONS, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: - logger.error(f"Erreur lors de la lecture de {JSON_EXTENSIONS}: {e}") + logger.error(f"Erreur lors de la lecture de {config.JSON_EXTENSIONS}: {e}") return [] def check_extension_before_download(url, platform, game_name): @@ -59,7 +59,7 @@ def check_extension_before_download(url, platform, game_name): sanitized_name = sanitize_filename(game_name) extensions_data = load_extensions_json() if not extensions_data: - logger.error(f"Fichier {JSON_EXTENSIONS} vide ou introuvable") + logger.error(f"Fichier {config.JSON_EXTENSIONS} vide ou introuvable") return None is_supported = is_extension_supported(sanitized_name, platform, extensions_data) @@ -80,13 +80,15 @@ def check_extension_before_download(url, platform, game_name): return None # Fonction pour vérifier si l'extension est supportée pour une plateforme donnée -def is_extension_supported(filename, platform, extensions_data): - """Vérifie si l'extension du fichier est supportée pour la plateforme donnée.""" +def is_extension_supported(filename, platform_key, extensions_data): + """Vérifie si l'extension du fichier est supportée pour la plateforme donnée. + platform_key correspond maintenant à l'identifiant utilisé dans config.platforms (platform_name).""" extension = os.path.splitext(filename)[1].lower() - + dest_dir = None for platform_dict in config.platform_dicts: - if platform_dict["platform"] == platform: + # Nouveau schéma: platform_name + if platform_dict.get("platform_name") == platform_key: dest_dir = os.path.join(config.ROMS_FOLDER, platform_dict.get("folder")) break @@ -108,43 +110,225 @@ def is_extension_supported(filename, platform, extensions_data): # Fonction pour charger sources.json def load_sources(): - """Charge les sources depuis sources.json et initialise les plateformes.""" - sources_path = os.path.join(config.SOURCES_FILE) - logger.debug(f"Chargement de {sources_path}") + """Charge la liste de base depuis systems_list.json puis ajoute les plateformes + implicites (fichiers de jeux *.json non listés) à la fin sans réordonner. + + Schéma attendu dans systems_list.json: + [ {"platform_name": str, "folder": str, (optionnel) "platform_image"|"system_image": str }, ... ] + + Règles d'ajout automatique: + - Chaque fichier .json dans config.GAMES_FOLDER est candidat. + - Si ne correspond à aucune entrée existante (case sensitive simple), + on ajoute {"platform_name": , "folder": } en fin de liste. + - Aucun tri n'est appliqué pour préserver l'ordre original défini par l'utilisateur. + """ try: - with open(sources_path, 'r', encoding='utf-8') as f: - sources = json.load(f) - sources = sorted(sources, key=lambda x: x.get("nom", x.get("platform", "")).lower()) - config.platforms = [source["platform"] for source in sources] - config.platform_dicts = sources - config.platform_names = {source["platform"]: source["nom"] for source in sources} - config.games_count = {platform: 0 for platform in config.platforms} # Initialiser à 0 - # Charger les jeux pour chaque plateforme - loaded_platforms = set() # Pour suivre les plateformes déjà loguées - for platform in config.platforms: - games = load_games(platform) - config.games_count[platform] = len(games) - if platform not in loaded_platforms: - loaded_platforms.add(platform) - # Appeler write_unavailable_systems une seule fois après la boucle - write_unavailable_systems() # Assurez-vous que cette fonction est définie + # Détection legacy: si sources.json (ancien format) existe encore, déclencher redownload automatique + legacy_path = os.path.join(config.SAVE_FOLDER, "sources.json") + if os.path.exists(legacy_path): + logger.warning("Ancien fichier sources.json détecté: déclenchement redownload cache jeux") + try: + # Supprimer ancien cache et forcer redémarrage logique comme dans l'option de menu + if os.path.exists(config.SOURCES_FILE): + try: + os.remove(config.SOURCES_FILE) + except Exception: + pass + if os.path.exists(config.GAMES_FOLDER): + shutil.rmtree(config.GAMES_FOLDER, ignore_errors=True) + if os.path.exists(config.IMAGES_FOLDER): + shutil.rmtree(config.IMAGES_FOLDER, ignore_errors=True) + # Renommer legacy pour éviter boucle + try: + os.replace(legacy_path, legacy_path + ".bak") + except Exception: + pass + # Préparer popup redémarrage si contexte graphique chargé + config.popup_message = _("popup_redownload_success") if hasattr(config, 'popup_message') else "Cache jeux réinitialisé" + config.popup_timer = 5000 if hasattr(config, 'popup_timer') else 0 + config.menu_state = "restart_popup" if hasattr(config, 'menu_state') else getattr(config, 'menu_state', 'platform') + config.needs_redraw = True + logger.info("Redownload cache déclenché automatiquement (legacy sources.json)") + return [] # On sort pour laisser le processus de redémarrage gérer le rechargement + except Exception as e: + logger.error(f"Échec redownload automatique depuis legacy sources.json: {e}") + sources = [] + if os.path.exists(config.SOURCES_FILE): + with open(config.SOURCES_FILE, 'r', encoding='utf-8') as f: + sources = json.load(f) + if not isinstance(sources, list): + logger.error("systems_list.json n'est pas une liste JSON valide") + sources = [] + else: + logger.warning(f"Fichier systems_list absent: {config.SOURCES_FILE}") + + # S'assurer que chaque entrée possède la clé platform_image (vide si absente) + for s in sources: + if "platform_image" not in s: + # Supporter ancienne clé system_image -> platform_image si présente + legacy = s.pop("system_image", "") if isinstance(s, dict) else "" + s["platform_image"] = legacy or "" + + existing_names = {s.get("platform_name", "") for s in sources} + added = [] + if os.path.isdir(config.GAMES_FOLDER): + for fname in sorted(os.listdir(config.GAMES_FOLDER)): + if not fname.lower().endswith('.json'): + continue + pname = os.path.splitext(fname)[0] + if not pname or pname in existing_names: + continue + new_entry = {"platform_name": pname, "folder": pname, "platform_image": ""} + sources.append(new_entry) + added.append(pname) + existing_names.add(pname) + + # Déterminer les plateformes orphelines (fichier manquant) + existing_files = set() + if os.path.isdir(config.GAMES_FOLDER): + existing_files = {os.path.splitext(f)[0] for f in os.listdir(config.GAMES_FOLDER) if f.lower().endswith('.json')} + removed = [] + filtered_sources = [] + for entry in sources: + pname = entry.get("platform_name", "") + # Garder seulement si un fichier existe + if pname in existing_files: + filtered_sources.append(entry) + else: + # Ne retirer que si ce n'est pas un nom vide + if pname: + removed.append(pname) + sources = filtered_sources + + if added: + logger.info(f"Plateformes ajoutées automatiquement: {', '.join(added)}") + if removed: + logger.info(f"Plateformes supprimées (fichiers absents): {', '.join(removed)}") + + # Persister si modifications (ajouts ou suppressions) + if added or removed: + try: + # Pas de tri avant persistance: conserver ordre d'origine + ajouts fins + os.makedirs(os.path.dirname(config.SOURCES_FILE), exist_ok=True) + with open(config.SOURCES_FILE, 'w', encoding='utf-8') as f: + json.dump(sources, f, ensure_ascii=False, indent=2) + logger.info("systems_list.json mis à jour (ajouts/suppressions, ordre conservé)") + except Exception as e: + logger.error(f"Échec écriture systems_list.json après maj auto: {e}") + + # Pour l'affichage on veut un tri alphabétique sans toucher l'ordre de persistance + sorted_for_display = sorted(sources, key=lambda x: x.get("platform_name", "").lower()) + + # Construire structures config: platform_dicts = ordre fichier, platforms = tri (avec filtre masqués) + config.platform_dicts = sources # ordre brut fichier + settings = load_rgsx_settings() + hidden = set(settings.get("hidden_platforms", [])) if isinstance(settings, dict) else set() + all_sorted_names = [s.get("platform_name", "") for s in sorted_for_display] + visible_names = [n for n in all_sorted_names if n and n not in hidden] + config.platforms = visible_names + config.platform_names = {p: p for p in config.platforms} + # Nouveau mapping par nom pour éviter décalages index après tri d'affichage + try: + config.platform_dict_by_name = {d.get("platform_name", ""): d for d in config.platform_dicts} + except Exception: + config.platform_dict_by_name = {} + config.games_count = {} + for platform_name in config.platforms: + games = load_games(platform_name) + config.games_count[platform_name] = len(games) + + write_unavailable_systems() return sources except Exception as e: - logger.error(f"Erreur lors du chargement de sources.json : {str(e)}") + logger.error(f"Erreur fusion systèmes + détection jeux: {e}") return [] def load_games(platform_id): - """Charge les jeux pour une plateforme donnée en utilisant platform_id et teste la première URL.""" - games_path = os.path.join(config.GAMES_FOLDER, f"{platform_id}.json") - #logger.debug(f"Chargement des jeux pour {platform_id} depuis {games_path}") + """Charge la liste des jeux pour une plateforme. + + Recherche des fichiers dans config.GAMES_FOLDER selon l'ordre: + 1. .json (nom exact) + 2. normalisé: normalize_platform_name(platform_id).json + 3. .json (valeur 'folder' du dictionnaire de plateforme) + + Format accepté du fichier JSON: + - liste de listes/tuples: [ [name, url], [name, url, size], ... ] + - liste de chaînes: [ name1, name2, ... ] + - liste de dicts: [ {"game_name"|"name"|"title": str, "url"|"download"|"link"|"href": str?, "size"|"filesize"|"length": str?}, ... ] + - dict contenant une clé 'games' avec un des formats ci-dessus + + Retourne une liste normalisée de tuples (name, url_or_None, size_or_None). + """ try: - with open(games_path, 'r', encoding='utf-8') as f: - games = json.load(f) - - logger.debug(f"Jeux chargés pour {platform_id}: {len(games)} jeux") - return games + # Retrouver l'objet plateforme pour accéder éventuellement à 'folder' + platform_dict = None + for pd in config.platform_dicts: + if pd.get("platform_name") == platform_id or pd.get("platform") == platform_id: + platform_dict = pd + break + + candidates = [] + # 1. Nom exact + candidates.append(os.path.join(config.GAMES_FOLDER, f"{platform_id}.json")) + # 2. Nom normalisé + norm = normalize_platform_name(platform_id) + if norm and norm != platform_id: + candidates.append(os.path.join(config.GAMES_FOLDER, f"{norm}.json")) + # 3. Folder déclaré + if platform_dict: + folder_name = platform_dict.get("folder") + if folder_name: + candidates.append(os.path.join(config.GAMES_FOLDER, f"{folder_name}.json")) + + game_file = None + for c in candidates: + if os.path.exists(c): + game_file = c + break + if not game_file: + logger.warning(f"Aucun fichier de jeux trouvé pour {platform_id} (candidats: {candidates})") + return [] + + with open(game_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Si dict avec clé 'games' + if isinstance(data, dict) and 'games' in data: + data = data['games'] + + normalized = [] # (name, url, size) + + def extract_from_dict(d): + name = d.get('game_name') or d.get('name') or d.get('title') or d.get('game') + url = d.get('url') or d.get('download') or d.get('link') or d.get('href') + size = d.get('size') or d.get('filesize') or d.get('length') + if name: + normalized.append((str(name), url if isinstance(url, str) and url.strip() else None, str(size) if size else None)) + + if isinstance(data, list): + for item in data: + if isinstance(item, (list, tuple)): + if len(item) == 0: + continue + name = str(item[0]) + url = item[1] if len(item) > 1 and isinstance(item[1], str) and item[1].strip() else None + size = item[2] if len(item) > 2 and isinstance(item[2], str) and item[2].strip() else None + normalized.append((name, url, size)) + elif isinstance(item, dict): + extract_from_dict(item) + elif isinstance(item, str): + normalized.append((item, None, None)) + else: + normalized.append((str(item), None, None)) + elif isinstance(data, dict): # dict sans 'games' + extract_from_dict(data) + else: + logger.warning(f"Format de fichier jeux inattendu pour {platform_id}: {type(data)}") + + logger.debug(f"Jeux chargés pour {platform_id} depuis {os.path.basename(game_file)}: {len(normalized)} entrées") + return normalized except Exception as e: - logger.error(f"Erreur lors du chargement des jeux pour {platform_id} : {str(e)}") + logger.error(f"Erreur lors du chargement des jeux pour {platform_id}: {e}") return [] def write_unavailable_systems(): @@ -156,12 +340,11 @@ def write_unavailable_systems(): # Formater la date et l'heure pour le nom du fichier current_time = datetime.now() timestamp = current_time.strftime("%d-%m-%Y-%H-%M") - log_dir = os.path.join(os.path.dirname(config.APP_FOLDER), "logs", "RGSX") - log_file = os.path.join(log_dir, f"systemes_unavailable_{timestamp}.txt") + log_file = os.path.join(config.log_dir, f"systemes_unavailable_{timestamp}.txt") try: # Créer le répertoire s'il n'existe pas - os.makedirs(log_dir, exist_ok=True) + os.makedirs(config.log_dir, exist_ok=True) # Écrire les systèmes dans le fichier with open(log_file, 'w', encoding='utf-8') as f: @@ -290,15 +473,28 @@ def wrap_text(text, font, max_width): return lines def load_system_image(platform_dict): - """Charge une image système depuis le chemin spécifié dans system_image.""" - image_path = os.path.join(config.IMAGES_FOLDER, platform_dict.get("system_image", "default.png")) - platform_name = platform_dict.get("platform", "unknown") - #logger.debug(f"Chargement de l'image système pour {platform_name} depuis {image_path}") + """Charge une image système avec priorité: + 1. Fichier nommé exactement .png + 2. Champ platform_image si non vide + 3. Fallback default.png""" + platform_name = platform_dict.get("platform_name", "unknown") + preferred_filename = f"{platform_name}.png" + preferred_path = os.path.join(config.IMAGES_FOLDER, preferred_filename) + + # Normaliser platform_image pouvant être vide + platform_image_field = platform_dict.get("platform_image") or "" + explicit_image_path = os.path.join(config.IMAGES_FOLDER, platform_image_field) if platform_image_field else None + default_path = os.path.join(config.IMAGES_FOLDER, "default.png") + try: - if not os.path.exists(image_path): - logger.error(f"Image introuvable pour {platform_name} à {image_path}") - return None - return pygame.image.load(image_path).convert_alpha() + if os.path.exists(preferred_path): + return pygame.image.load(preferred_path).convert_alpha() + if explicit_image_path and os.path.exists(explicit_image_path): + return pygame.image.load(explicit_image_path).convert_alpha() + if os.path.exists(default_path): + return pygame.image.load(default_path).convert_alpha() + logger.error(f"Aucune image trouvée pour {platform_name} (cherché: {preferred_path}, {explicit_image_path}, default.png)") + return None except Exception as e: logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}") return None @@ -447,16 +643,6 @@ def extract_rar(rar_path, dest_dir, url): if system_type == "Windows": # Sur Windows, utiliser directement config.UNRAR_EXE unrar_exe = config.UNRAR_EXE - if not os.path.exists(unrar_exe): - logger.warning("unrar.exe absent, téléchargement en cours...") - try: - import urllib.request - os.makedirs(os.path.dirname(unrar_exe), exist_ok=True) - urllib.request.urlretrieve(config.unrar_download_exe, unrar_exe) - logger.info(f"unrar.exe téléchargé dans {unrar_exe}") - except Exception as e: - logger.error(f"Impossible de télécharger unrar.exe: {str(e)}") - return False, _("utils_unrar_unavailable") unrar_cmd = [unrar_exe] else: # Linux/Batocera: utiliser 'unrar' du système @@ -657,32 +843,11 @@ def handle_xbox(dest_dir, iso_files): if system_type == "Windows": # Sur Windows; telecharger le fichier exe XDVDFS_EXE = config.XDVDFS_EXE - if not os.path.exists(XDVDFS_EXE): - logger.warning("xdvdfs.exe absent, téléchargement en cours...") - try: - import urllib.request - os.makedirs(os.path.dirname(XDVDFS_EXE), exist_ok=True) - urllib.request.urlretrieve(config.xdvdfs_download_exe, XDVDFS_EXE) - logger.info(f"xdvdfs.exe téléchargé dans {XDVDFS_EXE}") - except Exception as e: - logger.error(f"Impossible de télécharger xdvdfs.exe: {str(e)}") - return False, _("utils_xdvdfs_unavailable") xdvdfs_cmd = [XDVDFS_EXE, "pack"] # Liste avec 2 éléments else: # Linux/Batocera : télécharger le fichier xdvdfs XDVDFS_LINUX = config.XDVDFS_LINUX - if not os.path.exists(XDVDFS_LINUX): - logger.warning("xdvdfs non trouvé, téléchargement en cours...") - try: - import urllib.request - os.makedirs(os.path.dirname(XDVDFS_LINUX), exist_ok=True) - urllib.request.urlretrieve(config.xdvdfs_download_linux, XDVDFS_LINUX) - os.chmod(XDVDFS_LINUX, 0o755) # Rendre exécutable - logger.info(f"xdvdfs téléchargé dans {XDVDFS_LINUX}") - except Exception as e: - logger.error(f"Impossible de télécharger xdvdfs: {str(e)}") - return False, _("utils_xdvdfs_unavailable") # Vérifier les permissions après le téléchargement try: stat_info = os.stat(XDVDFS_LINUX) mode = stat_info.st_mode @@ -792,24 +957,24 @@ def set_music_popup(music_name): def load_api_key_1fichier(): """Charge la clé API 1fichier depuis le dossier de sauvegarde, crée le fichier si absent.""" - api_path = os.path.join(SAVE_FOLDER, "1fichierAPI.txt") - logger.debug(f"Tentative de chargement de la clé API depuis: {api_path}") + + logger.debug(f"Tentative de chargement de la clé API depuis: {config.API_KEY_1FICHIER}") try: # Vérifie si le fichier existe déjà - if not os.path.exists(api_path): + if not os.path.exists(config.API_KEY_1FICHIER): # Crée le dossier parent si nécessaire - os.makedirs(SAVE_FOLDER, exist_ok=True) + os.makedirs(config.SAVE_FOLDER, exist_ok=True) # Crée le fichier vide si absent - with open(api_path, "w") as f: + with open(config.API_KEY_1FICHIER, "w") as f: f.write("") - logger.info(f"Fichier de clé API créé : {api_path}") + logger.info(f"Fichier de clé API créé : {config.API_KEY_1FICHIER}") return "" except OSError as e: logger.error(f"Erreur lors de la création du fichier de clé API : {e}") return "" # Lit la clé API depuis le fichier try: - with open(api_path, "r", encoding="utf-8") as f: + with open(config.API_KEY_1FICHIER, "r", encoding="utf-8") as f: api_key = f.read().strip() logger.debug(f"Clé API 1fichier lue: '{api_key}' (longueur: {len(api_key)})") if not api_key: