diff --git a/ports/RGSX/__main__.py b/ports/RGSX/__main__.py index 4521dbf..0abfd8d 100644 --- a/ports/RGSX/__main__.py +++ b/ports/RGSX/__main__.py @@ -567,28 +567,27 @@ async def main(): }) config.current_history_item = len(config.history) - 1 # Sélectionner l'entrée en cours if is_1fichier_url(url): - if not config.API_KEY_1FICHIER: - # Fallback AllDebrid - try: - from utils import load_api_key_alldebrid - config.API_KEY_ALLDEBRID = load_api_key_alldebrid() - except Exception: - config.API_KEY_ALLDEBRID = getattr(config, "API_KEY_ALLDEBRID", "") - if not config.API_KEY_1FICHIER and not getattr(config, "API_KEY_ALLDEBRID", ""): + # Utilisation helpers centralisés (utils) + try: + from utils import ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string + keys_info = ensure_download_provider_keys(False) + except Exception as e: + logger.error(f"Impossible de charger les clés via helpers: {e}") + keys_info = {'1fichier': getattr(config,'API_KEY_1FICHIER',''), 'alldebrid': getattr(config,'API_KEY_ALLDEBRID',''), 'realdebrid': getattr(config,'API_KEY_REALDEBRID','')} + if missing_all_provider_keys(): config.previous_menu_state = config.menu_state config.menu_state = "error" try: - both_paths = f"{os.path.join(config.SAVE_FOLDER,'1FichierAPI.txt')} or {os.path.join(config.SAVE_FOLDER,'AllDebridAPI.txt')}" - config.error_message = _("error_api_key").format(both_paths) + config.error_message = _("error_api_key").format(build_provider_paths_string()) except Exception: - config.error_message = "Please enter API key (1fichier or AllDebrid)" - # Mettre à jour l'entrée temporaire avec l'erreur + config.error_message = "Please enter API key (1fichier or AllDebrid or RealDebrid)" + # Mise à jour historique config.history[-1]["status"] = "Erreur" config.history[-1]["progress"] = 0 config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) config.needs_redraw = True - logger.error("Clé API 1fichier et AllDebrid absentes") + logger.error("Aucune clé fournisseur (1fichier/AllDebrid/RealDebrid) disponible") config.pending_download = None continue pending = check_extension_before_download(url, platform_name, game_name) diff --git a/ports/RGSX/config.py b/ports/RGSX/config.py index 4529ce7..b96cada 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.2.1.0" +app_version = "2.2.2.0" def get_application_root(): @@ -64,9 +64,11 @@ HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json") # Séparation chemin / valeur pour éviter les confusions lors du chargement API_KEY_1FICHIER_PATH = os.path.join(SAVE_FOLDER, "1FichierAPI.txt") API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt") +API_KEY_REALDEBRID_PATH = os.path.join(SAVE_FOLDER, "RealDebridAPI.txt") # Valeurs chargées (remplies dynamiquement par utils.load_api_key_*). API_KEY_1FICHIER = "" API_KEY_ALLDEBRID = "" +API_KEY_REALDEBRID = "" RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json") # URL diff --git a/ports/RGSX/controls.py b/ports/RGSX/controls.py index 556c19f..0ae1702 100644 --- a/ports/RGSX/controls.py +++ b/ports/RGSX/controls.py @@ -589,8 +589,9 @@ def handle_controls(event, sources, joystick, screen): config.current_history_item = len(config.history) -1 task_id = str(pygame.time.get_ticks()) if is_1fichier_url(url): - keys = load_api_keys() - if not keys.get('1fichier') and not keys.get('alldebrid'): + from utils import ensure_download_provider_keys, missing_all_provider_keys + ensure_download_provider_keys(False) + if missing_all_provider_keys(): config.history[-1]["status"] = "Erreur" config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) @@ -625,22 +626,22 @@ def handle_controls(event, sources, joystick, screen): config.current_history_item = len(config.history) - 1 # Vérifier d'abord si c'est un lien 1fichier if is_1fichier_url(url): - keys = load_api_keys() - if not keys.get('1fichier') and not keys.get('alldebrid'): + from utils import ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string + ensure_download_provider_keys(False) + if missing_all_provider_keys(): config.previous_menu_state = config.menu_state config.menu_state = "error" try: - both_paths = f"{os.path.join(config.SAVE_FOLDER,'1FichierAPI.txt')} or {os.path.join(config.SAVE_FOLDER,'AllDebridAPI.txt')}" - config.error_message = _("error_api_key").format(both_paths) + config.error_message = _("error_api_key").format(build_provider_paths_string()) except Exception as e: logger.error(f"Erreur lors de la traduction de error_api_key: {str(e)}") - config.error_message = "Please enter API key (1fichier or AllDebrid)" + config.error_message = "Please enter API key (1fichier or AllDebrid or RealDebrid)" config.history[-1]["status"] = "Erreur" config.history[-1]["progress"] = 0 config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) config.needs_redraw = True - logger.error("Clé API 1fichier et AllDebrid absentes, téléchargement impossible.") + logger.error("Clés API manquantes pour tous les fournisseurs (1fichier/AllDebrid/RealDebrid).") config.pending_download = None return action config.pending_download = check_extension_before_download(url, platform, game_name) @@ -741,21 +742,21 @@ def handle_controls(event, sources, joystick, screen): )) config.current_history_item = len(config.history) - 1 if is_1fichier_url(url): - keys = load_api_keys() - if not keys.get('1fichier') and not keys.get('alldebrid'): + from utils import ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string + ensure_download_provider_keys(False) + if missing_all_provider_keys(): config.previous_menu_state = config.menu_state config.menu_state = "error" try: - both_paths = f"{os.path.join(config.SAVE_FOLDER,'1FichierAPI.txt')} or {os.path.join(config.SAVE_FOLDER,'AllDebridAPI.txt')}" - config.error_message = _("error_api_key").format(both_paths) + config.error_message = _("error_api_key").format(build_provider_paths_string()) except Exception: - config.error_message = "Please enter API key (1fichier or AllDebrid)" + config.error_message = "Please enter API key (1fichier or AllDebrid or RealDebrid)" config.history[-1]["status"] = "Erreur" config.history[-1]["progress"] = 0 config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) config.needs_redraw = True - logger.error("Clé API 1fichier et AllDebrid absentes, téléchargement impossible.") + logger.error("Clés API manquantes pour tous les fournisseurs (1fichier/AllDebrid/RealDebrid).") config.pending_download = None return action task_id = str(pygame.time.get_ticks()) @@ -809,8 +810,9 @@ def handle_controls(event, sources, joystick, screen): config.current_history_item = len(config.history) -1 task_id = str(pygame.time.get_ticks()) if is_1fichier_url(url): - keys = load_api_keys() - if not keys.get('1fichier') and not keys.get('alldebrid'): + from utils import ensure_download_provider_keys, missing_all_provider_keys + ensure_download_provider_keys(False) + if missing_all_provider_keys(): config.history[-1]["status"] = "Erreur" config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) @@ -874,8 +876,9 @@ def handle_controls(event, sources, joystick, screen): config.current_history_item = len(config.history) -1 task_id = str(pygame.time.get_ticks()) if is_1fichier_url(url): - keys = load_api_keys() - if not keys.get('1fichier') and not keys.get('alldebrid'): + from utils import ensure_download_provider_keys, missing_all_provider_keys + ensure_download_provider_keys(False) + if missing_all_provider_keys(): config.history[-1]["status"] = "Erreur" config.history[-1]["message"] = "API NOT FOUND" save_history(config.history) @@ -956,6 +959,9 @@ def handle_controls(event, sources, joystick, screen): return action # Retour à l'origine capturée si disponible sinon previous_menu_state target = getattr(config, 'history_origin', getattr(config, 'previous_menu_state', 'platform')) + # Éviter boucle si target reste 'history' + if target == 'history': + target = 'platform' config.menu_state = validate_menu_state(target) if hasattr(config, 'history_origin'): try: diff --git a/ports/RGSX/display.py b/ports/RGSX/display.py index 1dcd2c7..789f5fd 100644 --- a/ports/RGSX/display.py +++ b/ports/RGSX/display.py @@ -427,7 +427,19 @@ def draw_platform_grid(screen): platform_name = config.platform_names.get(platform, platform) # Affichage du titre avec animation subtile - title_text = f"{platform_name}" + # Afficher le nombre total de jeux disponibles (tous systèmes) pour cohérence avec l'écran jeux + # Nombre de jeux pour la plateforme sélectionnée (utilise le cache pre-calculé si disponible) + game_count = 0 + try: + if hasattr(config, 'games_count') and isinstance(config.games_count, dict): + game_count = config.games_count.get(platform_name, 0) + # Fallback dynamique si pas dans le cache (ex: plateformes modifiées à chaud) + if game_count == 0 and hasattr(config, 'platform_dict_by_name'): + from utils import load_games # import local pour éviter import circulaire global + game_count = len(load_games(platform_name)) + except Exception: + game_count = 0 + title_text = f"{platform_name} ({game_count})" if game_count > 0 else f"{platform_name}" 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(60, 30) @@ -770,12 +782,12 @@ def draw_history_list(screen): pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12) screen.blit(title_surface, title_rect) - # Define column widths as percentages of available space + # Define column widths as percentages of available space (give more space to status/error messages) column_width_percentages = { - "platform": 0.20, # platform column - "game_name": 0.50, # game name column - "size": 0.10, # size column - "status": 0.20 # status column + "platform": 0.15, # narrower platform column + "game_name": 0.45, # game name column + "size": 0.10, # size column remains compact + "status": 0.30 # wider status column for long error codes/messages } available_width = int(0.95 * config.screen_width - 60) # Total available width for columns col_platform_width = int(available_width * column_width_percentages["platform"]) @@ -886,36 +898,51 @@ def draw_history_list(screen): status = entry.get("status", "Inconnu") progress = entry.get("progress", 0) - + progress = max(0, min(100, progress)) # Clamp progress between 0 and 100 - # Personnaliser l'affichage du statut + # Compute status text (optimized version without redundant prefix for errors) if status in ["Téléchargement", "downloading"]: status_text = _("history_status_downloading").format(progress) - # logger.debug(f"Affichage progression: {progress:.1f}% pour {game_name}, status={status_text}") + provider_prefix = entry.get("provider_prefix") or (entry.get("provider") + ":" if entry.get("provider") else "") + if provider_prefix and not status_text.startswith(provider_prefix): + status_text = f"{provider_prefix} {status_text}" elif status == "Extracting": status_text = _("history_status_extracting").format(progress) - # logger.debug(f"Affichage extraction: {progress:.1f}% pour {game_name}, status={status_text}") + provider_prefix = entry.get("provider_prefix") or (entry.get("provider") + ":" if entry.get("provider") else "") + if provider_prefix and not status_text.startswith(provider_prefix): + status_text = f"{provider_prefix} {status_text}" elif status == "Download_OK": + # Completed: no provider prefix (per requirement) status_text = _("history_status_completed") - # S'assurer que le pourcentage est entre 0 et 100 - progress = max(0, min(100, progress)) - # Personnaliser l'affichage du statut - if status in ["Téléchargement", "downloading"]: - status_text = _("history_status_downloading").format(progress) - # logger.debug(f"Affichage progression: {progress:.1f}% pour {game_name}, status={status_text}") - elif status == "Extracting": - status_text = _("history_status_extracting").format(progress) - # logger.debug(f"Affichage extraction: {progress:.1f}% pour {game_name}, status={status_text}") - elif status == "Download_OK": - status_text = _("history_status_completed") - # logger.debug(f"Affichage terminé: {game_name}, status={status_text}") elif status == "Erreur": - status_text = _("history_status_error").format(entry.get('message', 'Échec')) + # Prefer friendly mapped message now stored in 'message' + status_text = entry.get('message') + if not status_text: + # Some legacy entries might have only raw in result[1] or auxiliary field + status_text = entry.get('raw_error_realdebrid') or entry.get('error') or 'Échec' + # Strip redundant prefixes if any + for prefix in ["Erreur :", "Erreur:", "Error:", "Error :"]: + if status_text.startswith(prefix): + status_text = status_text[len(prefix):].strip() + break + provider_prefix = entry.get("provider_prefix") or (entry.get("provider") + ":" if entry.get("provider") else "") + if provider_prefix and not status_text.startswith(provider_prefix): + status_text = f"{provider_prefix} {status_text}" elif status == "Canceled": status_text = _("history_status_canceled") else: status_text = status - #logger.debug(f"Affichage statut inconnu: {game_name}, status={status_text}") + + # Determine color dedicated to status (independent from selection for better readability) + if status == "Erreur": + status_color = THEME_COLORS.get("error_text", (255, 0, 0)) + elif status == "Canceled": + status_color = THEME_COLORS.get("warning_text", (255, 100, 0)) + elif status == "Download_OK": + # Use green OK color + status_color = THEME_COLORS.get("fond_lignes", (0, 255, 0)) + else: + status_color = THEME_COLORS.get("text", (255, 255, 255)) platform_text = truncate_text_end(platform, config.small_font, col_platform_width - 10) game_text = truncate_text_end(game_name, config.small_font, col_game_width - 10) @@ -926,7 +953,7 @@ def draw_history_list(screen): platform_surface = config.small_font.render(platform_text, True, color) game_surface = config.small_font.render(game_text, True, color) size_surface = config.small_font.render(size_text, True, color) # Correction ici - status_surface = config.small_font.render(status_text, True, color) + status_surface = config.small_font.render(status_text, True, status_color) platform_rect = platform_surface.get_rect(center=(header_x_positions[0], y_pos)) game_rect = game_surface.get_rect(center=(header_x_positions[1], y_pos)) @@ -1545,35 +1572,104 @@ def draw_pause_api_keys_status(screen): screen.blit(OVERLAY, (0,0)) from utils import load_api_keys keys = load_api_keys() - # Layout simple - lines = [ - _("api_keys_status_title") if _ else "API Keys Status", + title = _("api_keys_status_title") if _ else "API Keys Status" + # Préparer données avec masquage partiel des clés (afficher 4 premiers et 2 derniers caractères si longueur > 10) + def mask_key(value: str|None): + if not value: + return "" # rien si absent + v = value.strip() + if len(v) <= 10: + return v # courte, afficher entière + return f"{v[:4]}…{v[-2:]}" # masque au milieu + + providers = [ ("1fichier", keys.get('1fichier')), - ("AllDebrid", keys.get('alldebrid')) + ("AllDebrid", keys.get('alldebrid')), + ("RealDebrid", keys.get('realdebrid')) ] - menu_width = int(config.screen_width * 0.55) - menu_height = int(config.screen_height * 0.35) + # Dimensions dynamiques en fonction du contenu + row_height = config.small_font.get_height() + 14 + header_height = 60 + inner_rows = len(providers) + menu_width = int(config.screen_width * 0.60) + menu_height = header_height + inner_rows * row_height + 80 menu_x = (config.screen_width - menu_width)//2 menu_y = (config.screen_height - menu_height)//2 - pygame.draw.rect(screen, THEME_COLORS["button_idle"], (menu_x, menu_y, menu_width, menu_height), border_radius=16) - pygame.draw.rect(screen, THEME_COLORS["border"], (menu_x, menu_y, menu_width, menu_height), 2, border_radius=16) - title_surface = config.font.render(lines[0], True, THEME_COLORS["text"]) - title_rect = title_surface.get_rect(center=(config.screen_width//2, menu_y + 40)) + pygame.draw.rect(screen, THEME_COLORS["button_idle"], (menu_x, menu_y, menu_width, menu_height), border_radius=22) + pygame.draw.rect(screen, THEME_COLORS["border"], (menu_x, menu_y, menu_width, menu_height), 2, border_radius=22) + + # Titre + title_surface = config.font.render(title, True, THEME_COLORS["text"]) + title_rect = title_surface.get_rect(center=(config.screen_width//2, menu_y + 36)) screen.blit(title_surface, title_rect) - status_on = _("status_present") if _ else "Present" - status_off = _("status_missing") if _ else "Missing" - y = title_rect.bottom + 20 - for provider, present in lines[1:]: - status_txt = status_on if present else status_off - text = f"{provider}: {status_txt}" - surf = config.small_font.render(text, True, THEME_COLORS["text"]) - rect = surf.get_rect(center=(config.screen_width//2, y)) - screen.blit(surf, rect) - y += surf.get_height() + 12 - back_txt = _("menu_back") if _ else "Back" - back_surf = config.small_font.render(back_txt, True, THEME_COLORS["fond_lignes"]) # Indication - back_rect = back_surf.get_rect(center=(config.screen_width//2, menu_y + menu_height - 30)) - screen.blit(back_surf, back_rect) + + status_present_txt = _("status_present") if _ else "Present" + status_missing_txt = _("status_missing") if _ else "Missing" + # Plus de légende textuelle Présent / Missing (demandé) – seules les pastilles couleur serviront. + legend_rect = pygame.Rect(0,0,0,0) + + # Colonnes: Provider | Status badge | (key masked) + col_provider_x = menu_x + 40 + col_status_x = menu_x + int(menu_width * 0.40) + col_key_x = menu_x + int(menu_width * 0.58) + + # Démarrage des lignes sous le titre avec un padding + y = title_rect.bottom + 24 + badge_font = config.tiny_font if hasattr(config, 'tiny_font') else config.small_font + for provider, value in providers: + present = bool(value) + # Provider name + prov_surf = config.small_font.render(provider, True, THEME_COLORS["text"]) + screen.blit(prov_surf, (col_provider_x, y)) + + # Pastille circulaire simple (couleur = statut) + circle_color = (60, 170, 60) if present else (180, 55, 55) + circle_bg = (30, 70, 30) if present else (70, 25, 25) + radius = 14 + center_x = col_status_x + radius + center_y = y + badge_font.get_height()//2 + pygame.draw.circle(screen, circle_bg, (center_x, center_y), radius) + pygame.draw.circle(screen, circle_color, (center_x, center_y), radius, 2) + + # Masked key (dim color) or hint + if present: + masked = mask_key(value) + key_color = THEME_COLORS.get("text_dim", (180,180,180)) + key_label = masked + else: + key_color = THEME_COLORS.get("text_dim", (150,150,150)) + # Afficher nom de fichier + 'empty' + filename_display = { + '1fichier': '1FichierAPI.txt', + 'AllDebrid': 'AllDebridAPI.txt', + 'RealDebrid': 'RealDebridAPI.txt' + }.get(provider, 'key.txt') + empty_suffix = _("api_key_empty_suffix") if _ and _("api_key_empty_suffix") != "api_key_empty_suffix" else "empty" + key_label = f"{filename_display} {empty_suffix}" + key_surf = config.tiny_font.render(key_label, True, key_color) if hasattr(config, 'tiny_font') else config.small_font.render(key_label, True, key_color) + screen.blit(key_surf, (col_key_x, y)) + + # Ligne séparatrice (optionnelle) + sep_y = y + row_height - 8 + if provider != providers[-1][0]: + pygame.draw.line(screen, THEME_COLORS["border"], (menu_x + 25, sep_y), (menu_x + menu_width - 25, sep_y), 1) + y += row_height + + # Indication basique: utiliser config.SAVE_FOLDER (chemin dynamique) + save_folder_path = getattr(config, 'SAVE_FOLDER', '/saves/ports/rgsx') + # Utiliser placeholder {path} si traduction fournie + if _ and _("api_keys_hint_manage") != "api_keys_hint_manage": + try: + hint_txt = _("api_keys_hint_manage").format(path=save_folder_path) + except Exception: + hint_txt = f"Put your keys in {save_folder_path}" + else: + hint_txt = f"Put your keys in {save_folder_path}" + hint_font = config.tiny_font if hasattr(config, 'tiny_font') else config.small_font + hint_surf = hint_font.render(hint_txt, True, THEME_COLORS.get("text_dim", THEME_COLORS["text"])) + # Positionné un peu plus haut pour aérer + hint_rect = hint_surf.get_rect(center=(config.screen_width//2, menu_y + menu_height - 30)) + screen.blit(hint_surf, hint_rect) def draw_filter_platforms_menu(screen): """Affiche le menu de filtrage des plateformes (afficher/masquer).""" diff --git a/ports/RGSX/languages/de.json b/ports/RGSX/languages/de.json index 540485c..32c1e51 100644 --- a/ports/RGSX/languages/de.json +++ b/ports/RGSX/languages/de.json @@ -147,5 +147,7 @@ "status_missing": "Fehlt", "menu_api_keys_status": "API-Schlüssel", "api_keys_status_title": "Status der API-Schlüssel", - "menu_games": "Spiele" + "menu_games": "Spiele", + "api_keys_hint_manage": "Legen Sie Ihre Schlüssel in {path}", + "api_key_empty_suffix": "leer" } \ No newline at end of file diff --git a/ports/RGSX/languages/en.json b/ports/RGSX/languages/en.json index 1781952..fda30b3 100644 --- a/ports/RGSX/languages/en.json +++ b/ports/RGSX/languages/en.json @@ -147,5 +147,7 @@ "status_missing": "Missing", "menu_api_keys_status": "API Keys", "api_keys_status_title": "API Keys Status", - "menu_games": "Games" + "menu_games": "Games", + "api_keys_hint_manage": "Put your keys in {path}", + "api_key_empty_suffix": "empty" } \ No newline at end of file diff --git a/ports/RGSX/languages/es.json b/ports/RGSX/languages/es.json index 194aaad..c437d12 100644 --- a/ports/RGSX/languages/es.json +++ b/ports/RGSX/languages/es.json @@ -147,5 +147,7 @@ "status_missing": "Ausente", "menu_api_keys_status": "Claves API", "api_keys_status_title": "Estado de las claves API", - "menu_games": "Juegos" + "menu_games": "Juegos", + "api_keys_hint_manage": "Coloca tus claves en {path}", + "api_key_empty_suffix": "vacío" } \ No newline at end of file diff --git a/ports/RGSX/languages/fr.json b/ports/RGSX/languages/fr.json index f3b595e..1033d97 100644 --- a/ports/RGSX/languages/fr.json +++ b/ports/RGSX/languages/fr.json @@ -147,5 +147,7 @@ "status_missing": "Absente", "menu_api_keys_status": "Clés API", "api_keys_status_title": "Statut des clés API", - "menu_games": "Jeux" + "menu_games": "Jeux", + "api_keys_hint_manage": "Placez vos clés dans {path}", + "api_key_empty_suffix": "vide" } \ No newline at end of file diff --git a/ports/RGSX/languages/it.json b/ports/RGSX/languages/it.json index 8d19418..e6e73c8 100644 --- a/ports/RGSX/languages/it.json +++ b/ports/RGSX/languages/it.json @@ -147,5 +147,7 @@ "status_missing": "Assente", "menu_api_keys_status": "Chiavi API", "api_keys_status_title": "Stato delle chiavi API", - "menu_games": "Giochi" + "menu_games": "Giochi", + "api_keys_hint_manage": "Metti le tue chiavi in {path}", + "api_key_empty_suffix": "vuoto" } \ No newline at end of file diff --git a/ports/RGSX/languages/pt.json b/ports/RGSX/languages/pt.json index e96b7f6..5537094 100644 --- a/ports/RGSX/languages/pt.json +++ b/ports/RGSX/languages/pt.json @@ -147,5 +147,7 @@ "status_missing": "Ausente", "menu_api_keys_status": "Chaves API", "api_keys_status_title": "Status das chaves API", - "menu_games": "Jogos" + "menu_games": "Jogos", + "api_keys_hint_manage": "Coloque suas chaves em {path}", + "api_key_empty_suffix": "vazio" } \ No newline at end of file diff --git a/ports/RGSX/network.py b/ports/RGSX/network.py index 0d7ebc9..c324ca5 100644 --- a/ports/RGSX/network.py +++ b/ports/RGSX/network.py @@ -136,15 +136,40 @@ async def check_for_updates(): config.current_loading_system = _("network_checking_updates") config.loading_progress = 5.0 config.needs_redraw = True + response = requests.get(OTA_VERSION_ENDPOINT, timeout=5) response.raise_for_status() if response.headers.get("content-type") != "application/json": - raise ValueError(f"Le fichier version.json n'est pas un JSON valide (type de contenu : {response.headers.get('content-type')})") + raise ValueError( + f"Le fichier version.json n'est pas un JSON valide (type de contenu : {response.headers.get('content-type')})" + ) version_data = response.json() latest_version = version_data.get("version") logger.debug(f"Version distante : {latest_version}, version locale : {config.app_version}") + + # --- Protection anti-downgrade --- + def _parse_version(v: str): + try: + return [int(p) for p in str(v).strip().split('.') if p.isdigit()] + except Exception: + return [0] + + local_parts = _parse_version(getattr(config, 'app_version', '0')) + remote_parts = _parse_version(latest_version or '0') + # Normaliser longueur + max_len = max(len(local_parts), len(remote_parts)) + local_parts += [0] * (max_len - len(local_parts)) + remote_parts += [0] * (max_len - len(remote_parts)) + logger.debug(f"Comparaison versions normalisées local={local_parts} remote={remote_parts}") + if remote_parts <= local_parts: + # Pas de mise à jour si version distante identique ou inférieure (empêche downgrade accidentel) + logger.info("Version distante inférieure ou égale – skip mise à jour (anti-downgrade)") + return True, _("network_no_update_available") if _ else "No update (local >= remote)" + + # À ce stade latest_version est strictement > version locale UPDATE_ZIP = OTA_UPDATE_ZIP.replace("RGSX.zip", f"RGSX_v{latest_version}.zip") logger.debug(f"URL de mise à jour : {UPDATE_ZIP}") + if latest_version != config.app_version: config.current_loading_system = _("network_update_available").format(latest_version) config.loading_progress = 10.0 @@ -638,12 +663,19 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= keys_info = load_api_keys() config.API_KEY_1FICHIER = keys_info.get('1fichier', '') config.API_KEY_ALLDEBRID = keys_info.get('alldebrid', '') + config.API_KEY_REALDEBRID = keys_info.get('realdebrid', '') if not config.API_KEY_1FICHIER and config.API_KEY_ALLDEBRID: logger.debug("Clé 1fichier absente, utilisation fallback AllDebrid") - elif not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID: - logger.debug("Aucune clé API disponible (1fichier ni AllDebrid)") + if not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and config.API_KEY_REALDEBRID: + logger.debug("Clé 1fichier & AllDebrid absentes, utilisation fallback RealDebrid") + elif not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and not config.API_KEY_REALDEBRID: + logger.debug("Aucune clé API disponible (1fichier, AllDebrid, RealDebrid)") logger.debug(f"Début téléchargement 1fichier: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}, task_id={task_id}") - logger.debug(f"Clé API 1fichier: {'présente' if config.API_KEY_1FICHIER else 'absente'} / AllDebrid: {'présente' if config.API_KEY_ALLDEBRID else 'absente'} (reloaded={keys_info.get('reloaded')})") + logger.debug( + f"Clé API 1fichier: {'présente' if config.API_KEY_1FICHIER else 'absente'} / " + f"AllDebrid: {'présente' if config.API_KEY_ALLDEBRID else 'absente'} / " + f"RealDebrid: {'présente' if config.API_KEY_REALDEBRID else 'absente'} (reloaded={keys_info.get('reloaded')})" + ) result = [None, None] # Créer une queue spécifique pour cette tâche @@ -653,8 +685,30 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= if task_id not in cancel_events: cancel_events[task_id] = threading.Event() + provider_used = None # '1F', 'AD', 'RD' + + def _set_provider_in_history(pfx: str): + try: + if not pfx: + return + if isinstance(config.history, list): + for entry in config.history: + if entry.get("url") == url: + entry["provider"] = pfx + entry["provider_prefix"] = f"{pfx}:" + try: + save_history(config.history) + except Exception: + pass + config.needs_redraw = True + break + except Exception: + pass + def download_thread(): logger.debug(f"Thread téléchargement 1fichier démarré pour {url}, task_id={task_id}") + # Assurer l'accès à provider_used dans cette closure (lecture/écriture) + nonlocal provider_used try: cancel_ev = cancel_events.get(task_id) link = url.split('&af=')[0] @@ -700,12 +754,46 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= logger.debug(f"Préparation requête 1fichier file/info pour {link}") response = requests.post("https://api.1fichier.com/v1/file/info.cgi", headers=headers, json=payload, timeout=30) logger.debug(f"Réponse file/info reçue, code: {response.status_code}") - response.raise_for_status() - file_info = response.json() + file_info = None + raw_fileinfo_text = None + try: + raw_fileinfo_text = response.text + except Exception: + pass + try: + file_info = response.json() + except Exception: + file_info = None + if response.status_code != 200: + # 403 souvent = clé invalide ou accès interdit + friendly = None + raw_err = None + if isinstance(file_info, dict): + raw_err = file_info.get('message') or file_info.get('error') or file_info.get('status') + if raw_err == 'Bad token': + friendly = "1F: Clé API 1fichier invalide" + elif raw_err: + friendly = f"1F: {raw_err}" + if not friendly: + if response.status_code == 403: + friendly = "1F: Accès refusé (403)" + elif response.status_code == 401: + friendly = "1F: Non autorisé (401)" + else: + friendly = f"1F: Erreur HTTP {response.status_code}" + result[0] = False + result[1] = friendly + try: + result.append({"raw_error_1fichier_fileinfo": raw_err or raw_fileinfo_text}) + except Exception: + pass + return + # Status 200 requis à partir d'ici + file_info = file_info if isinstance(file_info, dict) else {} if "error" in file_info and file_info["error"] == "Resource not found": logger.error(f"Le fichier {game_name} n'existe pas sur 1fichier") result[0] = False - result[1] = _("network_file_not_found").format(game_name) + result[1] = f"1F: {_("network_file_not_found").format(game_name)}" if _ else f"1F: File not found {game_name}" return filename = file_info.get("filename", "").strip() if not filename: @@ -718,9 +806,57 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= logger.debug(f"Chemin destination: {dest_path}") logger.debug(f"Envoi requête 1fichier get_token pour {link}") response = requests.post("https://api.1fichier.com/v1/download/get_token.cgi", headers=headers, json=payload, timeout=30) - logger.debug(f"Réponse get_token reçue, code: {response.status_code}") + status_1f = response.status_code + raw_text_1f = None + try: + raw_text_1f = response.text + except Exception: + pass + logger.debug(f"Réponse get_token reçue, code: {status_1f} body_snippet={(raw_text_1f[:120] + '...') if raw_text_1f and len(raw_text_1f) > 120 else raw_text_1f}") + download_info = None + try: + download_info = response.json() + except Exception: + download_info = None + # Même en cas de code !=200 on tente de récupérer un message JSON exploitable + if status_1f != 200: + friendly_1f = None + raw_error_1f = None + if isinstance(download_info, dict): + # Exemples de réponses d'erreur 1fichier: {"status":"KO","message":"Bad token"} ou autres + raw_error_1f = download_info.get('message') or download_info.get('status') + # Mapping simple pour les messages fréquents / cas premium requis + ONEFICHIER_ERROR_MAP = { + "Bad token": "1F: Clé API invalide", + "Must be a customer (Premium, Access) #236": "1F: Compte Premium requis", + } + if raw_error_1f: + friendly_1f = ONEFICHIER_ERROR_MAP.get(raw_error_1f) + if not friendly_1f: + # Fallback générique sur code HTTP + if status_1f == 403: + friendly_1f = "1F: Accès refusé (403)" + elif status_1f == 401: + friendly_1f = "1F: Non autorisé (401)" + elif status_1f >= 500: + friendly_1f = f"1F: Erreur serveur ({status_1f})" + else: + friendly_1f = f"1F: Erreur ({status_1f})" + # Stocker et retourner tôt car pas de token valide + result[0] = False + result[1] = friendly_1f + try: + result.append({"raw_error_1fichier": raw_error_1f or raw_text_1f}) + except Exception: + pass + return + # Si status 200 on continue normalement response.raise_for_status() - download_info = response.json() + if not isinstance(download_info, dict): + logger.error("Réponse 1fichier inattendue (pas un JSON) pour get_token") + result[0] = False + result[1] = _("network_api_error").format("1fichier invalid JSON") if _ else "1fichier invalid JSON" + return final_url = download_info.get("url") if not final_url: logger.error("Impossible de récupérer l'URL de téléchargement") @@ -728,43 +864,147 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported= result[1] = _("network_cannot_get_download_url") return logger.debug(f"URL de téléchargement obtenue via 1fichier: {final_url}") + provider_used = '1F' + _set_provider_in_history(provider_used) else: - # AllDebrid: débrider l'URL 1fichier vers une URL directe - logger.debug("Mode téléchargement sélectionné: AllDebrid (fallback, débridage 1fichier)") - if not getattr(config, 'API_KEY_ALLDEBRID', ''): - logger.error("Aucune clé API (1fichier/AllDebrid) disponible") - result[0] = False - result[1] = _("network_api_error").format("Missing API key") if _ else "API key missing" - return - ad_key = config.API_KEY_ALLDEBRID - # AllDebrid API v4 example: GET https://api.alldebrid.com/v4/link/unlock?agent=&apikey=&link= - params = { - 'agent': 'RGSX', - 'apikey': ad_key, - 'link': link - } - logger.debug("Requête AllDebrid link/unlock en cours") - response = requests.get("https://api.alldebrid.com/v4/link/unlock", params=params, timeout=30) - logger.debug(f"Réponse AllDebrid reçue, code: {response.status_code}") - response.raise_for_status() - ad_json = response.json() - if ad_json.get('status') != 'success': - err = ad_json.get('error', {}).get('code') or ad_json - logger.error(f"AllDebrid échec débridage: {err}") - result[0] = False - result[1] = _("network_api_error").format(f"AllDebrid unlock failed: {err}") if _ else f"AllDebrid unlock failed: {err}" - return - data = ad_json.get('data', {}) - filename = data.get('filename') or game_name - final_url = data.get('link') or data.get('download') or data.get('streamingLink') + final_url = None + filename = None + # Tentative AllDebrid + if getattr(config, 'API_KEY_ALLDEBRID', ''): + logger.debug("Mode téléchargement sélectionné: AllDebrid (fallback 1)") + try: + ad_key = config.API_KEY_ALLDEBRID + params = {'agent': 'RGSX', 'apikey': ad_key, 'link': link} + logger.debug("Requête AllDebrid link/unlock en cours") + response = requests.get("https://api.alldebrid.com/v4/link/unlock", params=params, timeout=30) + logger.debug(f"Réponse AllDebrid reçue, code: {response.status_code}") + response.raise_for_status() + ad_json = response.json() + if ad_json.get('status') == 'success': + data = ad_json.get('data', {}) + filename = data.get('filename') or game_name + final_url = data.get('link') or data.get('download') or data.get('streamingLink') + if final_url: + logger.debug("Débridage réussi via AllDebrid") + provider_used = 'AD' + _set_provider_in_history(provider_used) + else: + logger.warning(f"AllDebrid status != success: {ad_json}") + except Exception as e: + logger.error(f"Erreur AllDebrid fallback: {e}") + # Tentative RealDebrid si pas de final_url + if not final_url and getattr(config, 'API_KEY_REALDEBRID', ''): + logger.debug("Tentative fallback RealDebrid (unlock)") + try: + rd_key = config.API_KEY_REALDEBRID + headers_rd = {"Authorization": f"Bearer {rd_key}"} + rd_resp = requests.post( + "https://api.real-debrid.com/rest/1.0/unrestrict/link", + data={"link": link}, + headers=headers_rd, + timeout=30 + ) + status = rd_resp.status_code + raw_text = None + rd_json = None + try: + raw_text = rd_resp.text + except Exception: + pass + # Tenter JSON même si statut != 200 + try: + rd_json = rd_resp.json() + except Exception: + rd_json = None + logger.debug(f"Réponse RealDebrid code={status} body_snippet={(raw_text[:120] + '...') if raw_text and len(raw_text) > 120 else raw_text}") + + # Mapping erreurs RD (liste partielle, extensible) + REALDEBRID_ERROR_MAP = { + # Values intentionally WITHOUT prefix; we'll add 'RD:' dynamically + 1: "Bad request", + 2: "Unsupported hoster", + 3: "Temporarily unavailable", + 4: "File not found", + 5: "Too many requests", + 6: "Access denied", + 8: "Not premium account", + 9: "No traffic left", + 11: "Internal error", + 20: "Premium account only", # normalisation wording + } + + error_code = None + error_message = None # Friendly / mapped message (to display in history) + error_message_raw = None # Raw provider message ('error') kept for debugging if needed + if rd_json and isinstance(rd_json, dict): + # Format attendu quand erreur: {'error_code': int, 'error': 'message'} + error_code = rd_json.get('error_code') or rd_json.get('error') if isinstance(rd_json.get('error'), int) else rd_json.get('error_code') + if isinstance(error_code, str) and error_code.isdigit(): + error_code = int(error_code) + api_error_text = rd_json.get('error') if isinstance(rd_json.get('error'), str) else None + if error_code is not None: + mapped = REALDEBRID_ERROR_MAP.get(error_code) + # Raw API error sometimes returns 'hoster_not_free' while code=20 + if api_error_text and api_error_text.strip().lower() == 'hoster_not_free': + api_error_text = 'Premium account only' + if mapped and not mapped.lower().startswith('rd:'): + mapped = f"RD: {mapped}" + if not mapped and api_error_text and not api_error_text.lower().startswith('rd:'): + api_error_text = f"RD: {api_error_text}" + error_message = mapped or api_error_text or f"RD: error {error_code}" + # Conserver la version brute séparément + error_message_raw = api_error_text if api_error_text and api_error_text != error_message else None + # Succès si 200 et presence 'download' + if status == 200 and rd_json and rd_json.get('download'): + final_url = rd_json.get('download') + filename = rd_json.get('filename') or filename or game_name + logger.debug("Débridage réussi via RealDebrid") + provider_used = 'RD' + _set_provider_in_history(provider_used) + else: + if error_message: + logger.warning(f"RealDebrid a renvoyé une erreur (code interne {error_code}): {error_message}") + else: + # Pas d'erreur structurée -> traiter statut HTTP + if status == 503: + error_message = "RD: service unavailable (503)" + elif status >= 500: + error_message = f"RD: server error ({status})" + elif status == 429: + error_message = "RD: rate limited (429)" + else: + error_message = f"RD: unexpected status ({status})" + logger.warning(f"RealDebrid fallback échec: {error_message}") + # Pas de détail JSON -> utiliser friendly comme raw aussi + error_message_raw = error_message + # Conserver message dans result si aucun autre provider ne réussit + if not final_url: + # Marquer le provider même en cas d'erreur pour affichage du préfixe dans l'historique + if provider_used is None: + provider_used = 'RD' + _set_provider_in_history(provider_used) + result[0] = False + # Pour l'interface: stocker le message friendly en priorité + result[1] = error_message or error_message_raw + # Stocker la version brute pour éventuel usage avancé + try: + if isinstance(result, list): + # Ajouter un dict auxiliaire pour meta erreurs + result.append({"raw_error_realdebrid": error_message_raw}) + except Exception: + pass + except Exception as e: + logger.error(f"Exception RealDebrid fallback: {e}") if not final_url: - logger.error("AllDebrid n'a pas renvoyé de lien direct") + logger.error("Aucune URL directe obtenue (AllDebrid & RealDebrid échoués ou absents)") result[0] = False - result[1] = _("network_cannot_get_download_url") + if result[1] is None: + result[1] = _("network_api_error").format("No provider available") if _ else "No provider available" return + if not filename: + filename = game_name sanitized_filename = sanitize_filename(filename) dest_path = os.path.join(dest_dir, sanitized_filename) - logger.debug(f"URL directe obtenue via AllDebrid: {final_url}") lock = threading.Lock() retries = 10 retry_delay = 10 diff --git a/ports/RGSX/utils.py b/ports/RGSX/utils.py index bebafb9..e3a4449 100644 --- a/ports/RGSX/utils.py +++ b/ports/RGSX/utils.py @@ -342,35 +342,6 @@ def _get_dest_folder_name(platform_key: str) -> str: # Fonction pour charger sources.json def load_sources(): try: - # 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: @@ -1251,23 +1222,24 @@ def set_music_popup(music_name): config.needs_redraw = True # Forcer le redraw pour afficher le nom de la musique def load_api_keys(force: bool = False): - """Charge les clés API (1fichier, AllDebrid) en une seule passe. + """Charge les clés API (1fichier, AllDebrid, RealDebrid) en une seule passe. - Crée les fichiers vides s'ils n'existent pas - - Met à jour config.API_KEY_1FICHIER et config.API_KEY_ALLDEBRID + - Met à jour config.API_KEY_1FICHIER, config.API_KEY_ALLDEBRID, config.API_KEY_REALDEBRID - Utilise un cache basé sur le mtime pour éviter des relectures - force=True ignore le cache et relit systématiquement - Retourne: { '1fichier': str, 'alldebrid': str, 'reloaded': bool } + Retourne: { '1fichier': str, 'alldebrid': str, 'realdebrid': str, 'reloaded': bool } """ try: paths = { '1fichier': getattr(config, 'API_KEY_1FICHIER_PATH', ''), 'alldebrid': getattr(config, 'API_KEY_ALLDEBRID_PATH', ''), + 'realdebrid': getattr(config, 'API_KEY_REALDEBRID_PATH', ''), } cache_attr = '_api_keys_cache' if not hasattr(config, cache_attr): - setattr(config, cache_attr, {'1fichier_mtime': None, 'alldebrid_mtime': None}) + setattr(config, cache_attr, {'1fichier_mtime': None, 'alldebrid_mtime': None, 'realdebrid_mtime': None}) cache_data = getattr(config, cache_attr) reloaded = False @@ -1299,13 +1271,16 @@ def load_api_keys(force: bool = False): # Assignation dans config if key_name == '1fichier': config.API_KEY_1FICHIER = value - else: + elif key_name == 'alldebrid': config.API_KEY_ALLDEBRID = value + elif key_name == 'realdebrid': + config.API_KEY_REALDEBRID = value cache_data[cache_key] = mtime reloaded = True return { '1fichier': getattr(config, 'API_KEY_1FICHIER', ''), 'alldebrid': getattr(config, 'API_KEY_ALLDEBRID', ''), + 'realdebrid': getattr(config, 'API_KEY_REALDEBRID', ''), 'reloaded': reloaded } except Exception as e: @@ -1313,6 +1288,7 @@ def load_api_keys(force: bool = False): return { '1fichier': getattr(config, 'API_KEY_1FICHIER', ''), 'alldebrid': getattr(config, 'API_KEY_ALLDEBRID', ''), + 'realdebrid': getattr(config, 'API_KEY_REALDEBRID', ''), 'reloaded': False } @@ -1323,10 +1299,41 @@ def load_api_key_1fichier(force: bool = False): # pragma: no cover def load_api_key_alldebrid(force: bool = False): # pragma: no cover return load_api_keys(force).get('alldebrid', '') +def load_api_key_realdebrid(force: bool = False): # pragma: no cover + return load_api_keys(force).get('realdebrid', '') + # Ancien nom conservé comme alias def ensure_api_keys_loaded(force: bool = False): # pragma: no cover return load_api_keys(force) +# ------------------------------ +# Helpers centralisés pour gestion des fournisseurs de téléchargement +# ------------------------------ +def build_provider_paths_string(): + """Retourne une chaîne listant les chemins des fichiers de clés pour affichage/erreurs.""" + return f"{getattr(config, 'API_KEY_1FICHIER_PATH', '')} or {getattr(config, 'API_KEY_ALLDEBRID_PATH', '')} or {getattr(config, 'API_KEY_REALDEBRID_PATH', '')}" + +def ensure_download_provider_keys(force: bool = False): # pragma: no cover + """S'assure que les clés 1fichier/AllDebrid/RealDebrid sont chargées et retourne le dict. + + Utilise load_api_keys (cache mtime). force=True invalide le cache. + """ + return load_api_keys(force) + +def missing_all_provider_keys(): # pragma: no cover + """True si aucune des trois clés n'est définie.""" + keys = load_api_keys(False) + return not keys.get('1fichier') and not keys.get('alldebrid') and not keys.get('realdebrid') + +def provider_keys_status(): # pragma: no cover + """Retourne un dict de présence pour debug/log.""" + keys = load_api_keys(False) + return { + '1fichier': bool(keys.get('1fichier')), + 'alldebrid': bool(keys.get('alldebrid')), + 'realdebrid': bool(keys.get('realdebrid')), + } + def load_music_config(): """Charge la configuration musique depuis rgsx_settings.json.""" try: