v1.9.7.0 - Ajout support multilangues (beta) , correction de bugs de logique, amélioration des erreurs

This commit is contained in:
skymike03
2025-07-23 23:54:11 +02:00
commit 7c15c04808
25 changed files with 8328 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
logs/
images/
games/
__pycache__/
sources.json
gamelist.xml
*.log
*.rar
*.zip

175
README.md Normal file
View File

@@ -0,0 +1,175 @@
# 🎮 Retro Game Sets Xtra (RGSX)
RGSX est une application Python basée sur Pygame.
---
## ✨ Fonctionnalités
- **Téléchargement de jeux** : Prise en charge des fichiers ZIP et gestion des extensions non supportées grâce au fichier `info.txt` dans chaque dossier.
- Les téléchargements ne nécessitent aucune authentification ni compte pour la plupart.
- Les systèmes notés `(1fichier)` dans le nom ne seront accessibles que si vous renseignez votre clé API 1fichier (voir plus bas).
- **Historique des téléchargements** : Consultez et retéléchargez les anciens fichiers.
- **Personnalisation des contrôles** : Remappez les touches du clavier ou de la manette à votre convenance.
- **Mode recherche** : Filtrez les jeux par nom pour une navigation rapide.
- **Gestion des erreurs**
- **Interface réactive** : L'interface s'adapte à toutes résolutions de 800x600 à 4K (non testé au-delà de 1920x1080).
- **Mise à jour automatique** (bug d'affichage à améliorer lors d'une mise à jour) : l'application doit être relancée après sa fermeture automatique.
---
## 🖥️ Prérequis
### Système d'exploitation
- Batocera ou Knulli
### Matériel
- Manette (optionnelle, mais recommandée pour une expérience optimale) ou Clavier.
### Espace disque
- Espace suffisant dans `/userdata/roms/ports/RGSX` pour stocker les ROMs, images et fichiers de configuration.
---
## 🚀 Installation
### Méthode 1 : Ligne de commande
- Sur batocera PC acceder à F1>Applications>xTERM ou
- Depuis un autre pc sur le réseau avec application Putty, powershell SSH ou autre
Entrez la commande :
## `curl -L bit.ly/rgsx-install | sh`
Patientez et regardez le retour à l'écran ou sur la commande (à améliorer).
Mettez à jour la liste des jeux via : `Menu > Paramètres de jeux > Mettre à jour la liste des jeux `.
Vous trouverez RGSX dans le système "PORTS" ou "Jeux Amateurs et portages" et dans `/userdata/roms/ports/RGSX`
---
### Méthode 2 : Copie manuelle
- Téléchargez le contenu du dépôt en zip : https://github.com/RetroGameSets/RGSX/archive/refs/heads/main.zip
- Extrayez le tout dans `/userdata/roms/ports/RGSX` (le dossier RGSX devra être créé manuellement). Attention de bien respecter la structure indiquée plus bas.
- Mettez à jour la liste des jeux via le menu :
`Paramètres de jeux > Mettre à jour la liste`.
## 🏁 1er démarrage
---
> ## IMPORTANT
> Si vous avez une clé API 1Fichier, vous devez la renseigner dans
> `/userdata/saves/ports/RGSX/1FichierAPI.txt`
> si vous souhaitez télécharger depuis des liens 1Fichier.
---
- Lancez RGSX depuis ports.
- Configurez les contrôles. Ils pourront être reconfigurés via le menu pause par la suite si erreur.
- Supprimez le fichier `/userdata/saves/ports/rgsx/controls.json` en cas de problème puis relancez l'application.
- L'application téléchargera toutes les données nécessaires automatiquement ensuite.
---
## 🕹️ Utilisation
### Navigation dans les menus
- Utilisez les touches directionnelles (D-Pad, flèches du clavier) pour naviguer entre les plateformes, jeux et options.
- Appuyez sur la touche configurée comme start (par défaut, **P** ou bouton Start sur la manette) pour ouvrir le menu pause.
- Depuis le menu pause, accédez à l'historique, à l'aide des contrôles (l'affichage des contrôles change suivant le menu où vous êtes) ou à la reconfiguration des touches.
- Vous pouvez aussi, depuis le menu, régénérer la liste des systèmes/jeux/images pour être sûr d'avoir les dernières mises à jour.
---
### Téléchargement
- Sélectionnez une plateforme, puis un jeu.
- Appuyez sur la touche configurée confirm (par défaut, **Entrée** ou bouton **A**) pour lancer le téléchargement.
- Suivez la progression dans le menu `download_progress`.
---
### Personnalisation des contrôles
- Dans le menu pause, sélectionnez **Remap controls**.
- Suivez les instructions à l'écran pour mapper chaque action en maintenant la touche ou le bouton pendant 3 secondes.
- Appuyez sur **Échap** pour ignorer une action sans la mapper.
---
### Historique
- Accédez à l'historique des téléchargements via le menu pause ou en appuyant sur la touche history (par défaut, **H**).
- Sélectionnez un jeu pour le retélécharger si nécessaire.
---
### Logs
Les logs sont enregistrés dans `/userdata/roms/ports/RGSX/logs/RGSX.log` pour diagnostiquer les problèmes.
---
## 📁 Structure du projet
```
/userdata/roms/ports/
RGSX-INSTALL.log # LOG d'installation uniquement
RGSX/
├── main.py # Point d'entrée principal de l'application.
├── controls.py # Gestion des événements clavier/manette/souris et navigation dans les menus.
├── controls_mapper.py # Configuration des contrôles.
├── display.py # Rendu des interfaces graphiques avec Pygame.
├── config.py # Configuration globale (chemins, paramètres, etc.).
├── network.py # Gestion des téléchargements de jeux.
├── history.py # Gestion de l'historique des téléchargements.
├── utils.py # Fonctions utilitaires (wrap du texte, troncage etc.).
└── logs/
└── RGSX.log # Fichier de logs.
/userdata/saves/ports/
RGSX/
├── controls.json # Fichier de mappage des contrôles (généré après le 1er demarrage)
├── history.json # Base de données de l'historique de téléchargements (généré après le 1er téléchargement)
└── 1FichierAPI.txt # Clé API 1fichier (compte premium et + uniquement) (vide par defaut)
```
---
## 🤝 Contribution
### Signaler un bug
1. Consultez les logs dans `/userdata/roms/ports/RGSX/logs/RGSX.log`.
2. Ouvrez une issue sur GitHub avec une description détaillée et les logs pertinents.
### Proposer une fonctionnalité
- Soumettez une issue avec une description claire de la fonctionnalité proposée.
- Expliquez comment elle s'intègre dans l'application.
### Contribuer au code
1. Forkez le dépôt et créez une branche pour votre fonctionnalité ou correction :
git checkout -b feature/nom-de-votre-fonctionnalité
2. Testez vos modifications sur Batocera.
3. Soumettez une pull request avec une description détaillée.
---
## ⚠️ Problèmes connus / À implémenter
- Gestion des téléchargements multiples
---
## 📝 Licence
Ce projet est libre. Vous êtes libre de l'utiliser, le modifier et le distribuer selon les termes de cette licence.
Développé avec ❤️ pour les amateurs de jeux rétro.

4
RGSX.sh Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Supprimer SDL_VIDEODRIVER=fbcon pour laisser SDL choisir le pilote
# export SDL_VIDEODRIVER=fbcon
/usr/bin/python3 /userdata/roms/ports/RGSX

779
__main__.py Normal file
View File

@@ -0,0 +1,779 @@
import os
os.environ["SDL_FBDEV"] = "/dev/fb0"
import pygame # type: ignore
import asyncio
import platform
import logging
import requests
import queue
import datetime
from display import init_display, draw_loading_screen, draw_error_screen, draw_platform_grid, draw_progress_screen, draw_controls, draw_virtual_keyboard, draw_popup_result_download, draw_extension_warning, draw_pause_menu, draw_controls_help, draw_game_list, draw_history_list, draw_clear_history_dialog, draw_confirm_dialog, draw_redownload_game_cache_dialog, draw_popup, draw_gradient, draw_language_menu, THEME_COLORS
from language import update_valid_states, handle_language_menu_events, _
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates
from controls import handle_controls, validate_menu_state, process_key_repeats
from controls_mapper import load_controls_config, map_controls, draw_controls_mapping, ACTIONS
from utils import detect_non_pc, load_sources, check_extension_before_download, extract_zip_data, play_random_music
from history import load_history, save_history
import config
from config import OTA_data_ZIP
# Configuration du logging
log_dir = os.path.join(config.APP_FOLDER, "logs")
log_file = os.path.join(log_dir, "RGSX.log")
try:
os.makedirs(log_dir, exist_ok=True)
logging.basicConfig(
filename=log_file,
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
except Exception as e:
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logging.error(f"Échec de la configuration du logging dans {log_file}: {str(e)}")
logger = logging.getLogger(__name__)
# Initialisation de Pygame et des polices
pygame.init()
config.init_font()
pygame.joystick.init()
pygame.mouse.set_visible(True)
# Initialisation du sélecteur de langue
update_valid_states()
# Chargement et initialisation de la langue
from language import initialize_language
initialize_language()
logger.debug(f"Langue initialisée: {config.current_language}")
# Détection du système non-PC
config.is_non_pc = detect_non_pc()
# Initialisation de lécran
screen = init_display()
pygame.display.set_caption("RGSX")
clock = pygame.time.Clock()
# Initialisation des polices
try:
font_path = os.path.join(config.APP_FOLDER, "assets", "Pixel-UniCode.ttf")
config.font = pygame.font.Font(font_path, 36) # Police principale
config.title_font = pygame.font.Font(font_path, 48) # Police pour les titres
config.search_font = pygame.font.Font(font_path, 48) # Police pour la recherche
config.progress_font = pygame.font.Font(font_path, 36) # Police pour l'affichage de la progression
config.small_font = pygame.font.Font(font_path, 28) # Police pour les petits textes
logger.debug("Police Pixel-UniCode chargée")
except:
config.font = pygame.font.SysFont("arial", 48) # Police fallback
config.title_font = pygame.font.SysFont("arial", 60) # Police fallback pour les titres
config.search_font = pygame.font.SysFont("arial", 60) # Police fallback pour la recherche
config.progress_font = pygame.font.SysFont("arial", 36) # Police fallback pour l'affichage de la progression
config.small_font = pygame.font.SysFont("arial", 28) # Police fallback pour les petits textes
logger.debug("Police Arial chargée")
# Mise à jour de la résolution dans config
config.screen_width, config.screen_height = pygame.display.get_surface().get_size()
logger.debug(f"Résolution réelle : {config.screen_width}x{config.screen_height}")
# Initialisation des variables de grille
config.current_page = 0
config.selected_platform = 0
config.selected_key = (0, 0)
config.transition_state = "none"
# Initialisation des variables de répétition
config.repeat_action = None
config.repeat_key = None
config.repeat_start_time = 0
config.repeat_last_action = 0
# Initialisation des variables pour la popup de musique
current_music_name = None
music_popup_start_time = 0
# Dossier musique Batocera
music_folder = os.path.join(config.APP_FOLDER, "assets", "music")
music_files = [f for f in os.listdir(music_folder) if f.lower().endswith(('.ogg', '.mp3'))]
current_music = None # Variable pour suivre la musique en cours
if music_files:
current_music = play_random_music(music_files, music_folder, current_music)
else:
logger.debug("Aucune musique trouvée dans config.APP_FOLDER/assets/music")
# Chargement de l'historique
config.history = load_history()
logger.debug(f"Historique chargé: {len(config.history)} entrées")
# Vérifier si le fichier de configuration des contrôles existe
controls_file_exists = os.path.exists(config.CONTROLS_CONFIG_PATH)
logger.debug(f"Fichier controls.json existe: {controls_file_exists} à {config.CONTROLS_CONFIG_PATH}")
# Vérification et chargement de la configuration des contrôles
config.controls_config = load_controls_config()
# Déterminer l'état initial de l'application
if not controls_file_exists:
# Si pas de fichier de contrôles, on commence par les configurer
config.menu_state = "controls_mapping"
config.needs_redraw = True # Forcer le redraw immédiatement
logger.info(f"Pas de fichier de contrôles à {config.CONTROLS_CONFIG_PATH}, configuration des contrôles")
logger.debug("Menu initial: mappage des contrôles")
else:
# Sinon, chargement normal
config.menu_state = "loading"
logger.debug("Menu chargement normal")
# Initialisation du gamepad
joystick = None
if pygame.joystick.get_count() > 0:
joystick = pygame.joystick.Joystick(0)
joystick.init()
logger.debug("Gamepad initialisé")
# Initialisation du mixer Pygame
pygame.mixer.pre_init(44100, -16, 2, 4096)
pygame.mixer.init()
# Boucle principale
async def main():
# amazonq-ignore-next-line
global current_music, music_files, music_folder
logger.debug("Début main")
running = True
loading_step = "none"
sources = []
config.last_state_change_time = 0
config.debounce_delay = 50
config.update_triggered = False
last_redraw_time = pygame.time.get_ticks()
config.last_frame_time = pygame.time.get_ticks() # Initialisation pour éviter erreur
screen = init_display()
clock = pygame.time.Clock()
while running:
clock.tick(30) # Limite à 60 FPS
if config.update_triggered:
logger.debug("Mise à jour déclenchée, arrêt de la boucle principale")
break
current_time = pygame.time.get_ticks()
# Forcer redraw toutes les 100 ms dans download_progress
if config.menu_state == "download_progress" and current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
# Forcer redraw toutes les 100 ms dans history avec téléchargement actif
if config.menu_state == "history" and any(entry["status"] == "Téléchargement" for entry in config.history):
if current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
# logger.debug("Forcing redraw in history state due to active download")
# Gestion de la fin du popup
if config.menu_state == "restart_popup" and config.popup_timer > 0:
config.popup_timer -= (current_time - config.last_frame_time)
config.needs_redraw = True
if config.popup_timer <= 0:
config.menu_state = validate_menu_state(config.previous_menu_state)
config.popup_message = ""
config.popup_timer = 0
config.needs_redraw = True
logger.debug(f"Fermeture automatique du popup, retour à {config.menu_state}")
# Gestion de la fin du popup update_result
if config.menu_state == "update_result" and current_time - config.update_result_start_time > 5000:
config.menu_state = "platform" # Retour à l'écran des plateformes
config.update_result_message = ""
config.update_result_error = False
config.needs_redraw = True
logger.debug("Fin popup update_result, retour à platform")
# Gestion de la répétition automatique des actions
process_key_repeats(sources, joystick, screen)
# Gestion des événements
events = pygame.event.get()
for event in events:
# Gestion directe des événements pour le menu de langue
if config.menu_state == "language_select" and event.type == pygame.KEYDOWN:
handle_language_menu_events(event, screen)
continue
if event.type == pygame.USEREVENT + 1: # Événement de fin de musique
logger.debug("Fin de la musique détectée, lecture d'une nouvelle musique aléatoire")
current_music = play_random_music(music_files, music_folder, current_music)
continue
if event.type == pygame.QUIT:
config.menu_state = "confirm_exit"
config.confirm_selection = 0
config.needs_redraw = True
logger.debug("Événement QUIT détecté, passage à confirm_exit")
continue
start_config = config.controls_config.get("start", {})
if start_config and (
(event.type == pygame.KEYDOWN and start_config.get("type") == "key" and event.key == start_config.get("value")) or
(event.type == pygame.JOYBUTTONDOWN and start_config.get("type") == "button" and event.button == start_config.get("value")) or
(event.type == pygame.JOYAXISMOTION and start_config.get("type") == "axis" and event.axis == start_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == start_config.get("value")[1]) or
(event.type == pygame.JOYHATMOTION and start_config.get("type") == "hat" and event.value == tuple(start_config.get("value"))) or
(event.type == pygame.MOUSEBUTTONDOWN and start_config.get("type") == "mouse" and event.button == start_config.get("value"))
):
if config.menu_state not in ["pause_menu", "controls_help", "controls_mapping", "history", "confirm_clear_history"]:
config.previous_menu_state = config.menu_state
config.menu_state = "pause_menu"
config.selected_option = 0
config.needs_redraw = True
logger.debug(f"Ouverture menu pause depuis {config.previous_menu_state}")
continue
if config.menu_state == "pause_menu":
action = handle_controls(event, sources, joystick, screen)
config.needs_redraw = True
logger.debug(f"Événement transmis à handle_controls dans pause_menu: {event.type}")
continue
if config.menu_state == "controls_help":
cancel_config = config.controls_config.get("cancel", {})
if (
(event.type == pygame.KEYDOWN and cancel_config and event.key == cancel_config.get("value")) or
(event.type == pygame.JOYBUTTONDOWN and cancel_config and cancel_config.get("type") == "button" and event.button == cancel_config.get("value")) or
(event.type == pygame.JOYAXISMOTION and cancel_config and cancel_config.get("type") == "axis" and event.axis == cancel_config.get("value")[0] and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == cancel_config.get("value")[1]) or
(event.type == pygame.JOYHATMOTION and cancel_config and cancel_config.get("type") == "hat" and event.value == tuple(cancel_config.get("value")))
):
config.previous_menu_state = validate_menu_state(config.previous_menu_state)
config.menu_state = "pause_menu"
config.needs_redraw = True
logger.debug("Controls_help: Annulation, retour à pause_menu")
continue
if config.menu_state == "confirm_clear_history":
action = handle_controls(event, sources, joystick, screen)
config.needs_redraw = True
logger.debug(f"Événement transmis à handle_controls dans confirm_clear_history: {event.type}")
continue
if config.menu_state == "redownload_game_cache":
action = handle_controls(event, sources, joystick, screen)
config.needs_redraw = True
logger.debug(f"Événement transmis à handle_controls dans redownload_game_cache: {event.type}")
continue
if config.menu_state == "extension_warning":
action = handle_controls(event, sources, joystick, screen)
config.needs_redraw = True
if action == "confirm":
if config.pending_download and config.extension_confirm_selection == 0: # Oui
url, platform, game_name, is_zip_non_supported = config.pending_download
logger.debug(f"Téléchargement confirmé après avertissement: {game_name} pour {platform} depuis {url}")
task_id = str(pygame.time.get_ticks())
config.history.append({
"platform": platform,
"game_name": game_name,
"status": "downloading",
"progress": 0,
"url": url,
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
config.current_history_item = len(config.history) - 1
save_history(config.history)
config.download_tasks[task_id] = (
asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id)),
url, game_name, platform
)
config.menu_state = "history"
config.pending_download = None
config.needs_redraw = True
logger.debug(f"Téléchargement démarré pour {game_name}, task_id={task_id}")
elif config.extension_confirm_selection == 1: # Non
config.menu_state = config.previous_menu_state
config.pending_download = None
config.needs_redraw = True
logger.debug("Téléchargement annulé, retour à l'état précédent")
continue
if config.menu_state in ["platform", "game", "error", "confirm_exit", "download_progress", "download_result", "history"]:
action = handle_controls(event, sources, joystick, screen)
config.needs_redraw = True
if action == "quit":
running = False
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 url:
logger.debug(f"Vérification pour {game_name}, URL: {url}")
# Ajouter une entrée temporaire à l'historique
config.history.append({
"platform": platform,
"game_name": game_name,
"status": "downloading",
"progress": 0,
"url": url,
"timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
})
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:
config.previous_menu_state = config.menu_state
config.menu_state = "error"
config.error_message = (
"Attention il faut renseigner sa clé API (premium only) dans le fichier /userdata/saves/ports/rgsx/1fichierAPI.txt"
)
# Mettre à jour l'entrée temporaire avec l'erreur
config.history[-1]["status"] = "Erreur"
config.history[-1]["progress"] = 0
config.history[-1]["message"] = "Erreur API : Clé API 1fichier absente"
save_history(config.history)
config.needs_redraw = True
logger.error("Clé API 1fichier absente")
config.pending_download = None
continue
is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name)
if not is_supported:
config.pending_download = (url, platform, game_name, is_zip_non_supported)
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non reconnue pour lien 1fichier, passage à extension_warning pour {game_name}")
# Supprimer l'entrée temporaire si erreur
config.history.pop()
else:
config.previous_menu_state = config.menu_state
logger.debug(f"Previous menu state défini: {config.previous_menu_state}")
# Lancer le téléchargement dans une tâche asynchrone
task_id = str(pygame.time.get_ticks())
config.download_tasks[task_id] = (
asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported)),
url, game_name, platform
)
config.menu_state = "history" # Passer à l'historique
config.needs_redraw = True
logger.debug(f"Téléchargement 1fichier démarré pour {game_name}, passage à l'historique")
else:
is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name)
if not is_supported:
config.pending_download = (url, platform, game_name, is_zip_non_supported)
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non reconnue, passage à extension_warning pour {game_name}")
# Supprimer l'entrée temporaire si erreur
config.history.pop()
else:
config.previous_menu_state = config.menu_state
logger.debug(f"Previous menu state défini: {config.previous_menu_state}")
# Lancer le téléchargement dans une tâche asynchrone
task_id = str(pygame.time.get_ticks())
config.download_tasks[task_id] = (
asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported)),
url, game_name, platform
)
config.menu_state = "history" # Passer à l'historique
config.needs_redraw = True
logger.debug(f"Téléchargement démarré pour {game_name}, passage à l'historique")
elif action == "redownload" and config.menu_state == "history" and config.history:
entry = config.history[config.current_history_item]
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]
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:
config.previous_menu_state = config.menu_state
config.menu_state = "error"
config.error_message = (
f"Attention il faut renseigner sa clé API (premium only) dans le fichier {os.path.join(config.SAVE_FOLDER, '1fichierAPI.txt')}"
)
config.needs_redraw = True
logger.error("Clé API 1fichier absente")
config.pending_download = None
continue
is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name)
if not is_supported:
config.pending_download = (url, platform, game_name, is_zip_non_supported)
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non reconnue pour lien 1fichier, passage à extension_warning pour {game_name}")
else:
config.previous_menu_state = config.menu_state
logger.debug(f"Previous menu state défini: {config.previous_menu_state}")
success, message = download_from_1fichier(url, platform, game_name, is_zip_non_supported)
config.download_result_message = message
config.download_result_error = not success
config.download_result_start_time = pygame.time.get_ticks()
config.menu_state = "download_result"
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
logger.debug(f"Retéléchargement 1fichier terminé pour {game_name}, succès={success}, message={message}")
else:
is_supported, message, is_zip_non_supported = check_extension_before_download(url, platform, game_name)
if not is_supported:
config.pending_download = (url, platform, game_name, is_zip_non_supported)
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non reconnue pour retéléchargement, passage à extension_warning pour {game_name}")
else:
config.previous_menu_state = config.menu_state
logger.debug(f"Previous menu state défini: {config.previous_menu_state}")
success, message = download_rom(url, platform, game_name, is_zip_non_supported)
config.download_result_message = message
config.download_result_error = not success
config.download_result_start_time = pygame.time.get_ticks()
config.menu_state = "download_result"
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
logger.debug(f"Retéléchargement terminé pour {game_name}, succès={success}, message={message}")
break
# Gestion des téléchargements
if config.download_tasks:
for task_id, (task, url, game_name, platform) in list(config.download_tasks.items()):
if task.done():
try:
success, message = await task
if "http" in message:
message = message.split("https://")[0].strip()
for entry in config.history:
if entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
save_history(config.history)
config.needs_redraw = True
logger.debug(f"Téléchargement terminé: {game_name}, succès={success}, message={message}, task_id={task_id}")
break
config.download_result_message = message
config.download_result_error = not success
config.download_result_start_time = pygame.time.get_ticks()
config.menu_state = "download_result"
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
del config.download_tasks[task_id]
except Exception as e:
message = f"Erreur lors du téléchargement: {str(e)}"
if "http" in message:
message = message.split("https://")[0].strip()
for entry in config.history:
if entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["status"] = "Erreur"
entry["progress"] = 0
entry["message"] = message
save_history(config.history)
config.needs_redraw = True
logger.debug(f"Erreur téléchargement: {game_name}, message={message}, task_id={task_id}")
break
config.download_result_message = message
config.download_result_error = True
config.download_result_start_time = pygame.time.get_ticks()
config.menu_state = "download_result"
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
del config.download_tasks[task_id]
else:
# Traiter les mises à jour de progression
progress_queue = queue.Queue()
while not progress_queue.empty():
data = progress_queue.get()
# logger.debug(f"Progress queue data received: {data}, task_id={task_id}")
if len(data) != 3 or data[0] != task_id: # Ignorer les données d'une autre tâche
logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}")
continue
if isinstance(data[1], bool): # Fin du téléchargement
success, message = data[1], data[2]
for entry in config.history:
if entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
save_history(config.history)
config.needs_redraw = True
logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}")
break
else:
downloaded, total_size = data[1], data[2]
progress = (downloaded / total_size * 100) if total_size > 0 else 0
for entry in config.history:
if entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["progress"] = progress
entry["status"] = "Téléchargement"
config.needs_redraw = True
# logger.debug(f"Progress updated in history: {progress:.1f}% for {game_name}, task_id={task_id}")
break
config.download_result_message = message
config.download_result_error = True
config.download_result_start_time = pygame.time.get_ticks()
config.menu_state = "download_result"
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
del config.download_tasks[task_id]
# Gestion de la fin du popup download_result
if config.menu_state == "download_result" and current_time - config.download_result_start_time > 3000:
config.menu_state = "history" # Rester dans l'historique après le popup
config.download_progress.clear()
config.pending_download = None
config.needs_redraw = True
logger.debug(f"Fin popup download_result, retour à history")
# Affichage
if config.needs_redraw:
draw_gradient(screen, THEME_COLORS["background_top"], THEME_COLORS["background_bottom"])
if config.menu_state == "controls_mapping":
# Ne rien faire ici, la gestion est faite dans la section spécifique
pass
elif config.menu_state == "loading":
draw_loading_screen(screen)
elif config.menu_state == "error":
draw_error_screen(screen)
elif config.menu_state == "update_result":
draw_popup_result_download(screen, config.update_result_message, config.update_result_error)
elif config.menu_state == "platform":
draw_platform_grid(screen)
elif config.menu_state == "game":
if not config.search_mode:
draw_game_list(screen)
if config.search_mode:
draw_game_list(screen)
if config.is_non_pc:
draw_virtual_keyboard(screen)
elif config.menu_state == "download_progress":
draw_progress_screen(screen)
elif config.menu_state == "download_result":
draw_popup_result_download(screen, config.download_result_message, config.download_result_error)
elif config.menu_state == "confirm_exit":
draw_confirm_dialog(screen)
elif config.menu_state == "extension_warning":
draw_extension_warning(screen)
elif config.menu_state == "pause_menu":
draw_pause_menu(screen, config.selected_option)
logger.debug("Rendu de draw_pause_menu")
elif config.menu_state == "controls_help":
draw_controls_help(screen, config.previous_menu_state)
elif config.menu_state == "history":
draw_history_list(screen)
# logger.debug("Screen updated with draw_history_list")
elif config.menu_state == "confirm_clear_history":
draw_clear_history_dialog(screen)
elif config.menu_state == "redownload_game_cache":
draw_redownload_game_cache_dialog(screen)
elif config.menu_state == "restart_popup":
draw_popup(screen)
elif config.menu_state == "language_select":
draw_language_menu(screen)
# Ajout de log pour déboguer
logger.debug(f"Affichage du sélecteur de langue, index={config.selected_language_index}")
else:
config.menu_state = "platform"
draw_platform_grid(screen)
config.needs_redraw = True
logger.error(f"État de menu non valide détecté: {config.menu_state}, retour à platform")
draw_controls(screen, config.menu_state)
pygame.display.flip()
config.needs_redraw = False
# logger.debug("Screen flipped with pygame.display.flip()")
# Gestion de l'état controls_mapping
if config.menu_state == "controls_mapping":
logger.debug("Avant appel de map_controls")
try:
# Vérifier si le fichier de contrôles existe déjà
controls_file_exists = os.path.exists(config.CONTROLS_CONFIG_PATH)
logger.debug(f"Vérification du fichier controls.json: {controls_file_exists} à {config.CONTROLS_CONFIG_PATH}")
if controls_file_exists:
# Si le fichier existe déjà, passer directement à l'état loading
config.menu_state = "loading"
logger.debug("Fichier controls.json existe déjà, passage direct à l'état loading")
config.needs_redraw = True
else:
# Forcer l'affichage de l'interface de mappage des contrôles
action = ACTIONS[0]
draw_controls_mapping(screen, action, None, True, 0.0)
pygame.display.flip()
logger.debug("Interface de mappage des contrôles affichée")
# Appeler map_controls pour gérer la configuration
success = map_controls(screen)
logger.debug(f"map_controls terminé, succès={success}")
if success:
config.controls_config = load_controls_config()
# Toujours passer à l'état loading après la configuration des contrôles
config.menu_state = "loading"
logger.debug("Passage à l'état loading après mappage")
config.needs_redraw = True
else:
config.menu_state = "error"
config.error_message = "Échec du mappage des contrôles"
config.needs_redraw = True
logger.debug("Échec du mappage, passage à l'état error")
except Exception as e:
logger.error(f"Erreur lors de l'appel de map_controls : {str(e)}")
config.menu_state = "error"
config.error_message = f"Erreur dans map_controls: {str(e)}"
config.needs_redraw = True
# Gestion de l'état loading
elif config.menu_state == "loading":
if loading_step == "none":
loading_step = "test_internet"
config.current_loading_system = "Test de connexion..."
config.loading_progress = 0.0
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
elif loading_step == "test_internet":
logger.debug("Exécution de test_internet()")
if test_internet():
loading_step = "check_ota"
config.current_loading_system = "Verification Mise à jour en cours... Patientez..."
config.loading_progress = 20.0
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
else:
config.menu_state = "error"
config.error_message = "Pas de connexion Internet. Vérifiez votre réseau."
config.needs_redraw = True
logger.debug(f"Erreur : {config.error_message}")
elif loading_step == "check_ota":
logger.debug("Exécution de check_for_updates()")
success, message = await check_for_updates()
logger.debug(f"Résultat de check_for_updates : success={success}, message={message}")
if not success:
config.menu_state = "error"
config.error_message = message
config.needs_redraw = True
logger.debug(f"Erreur OTA : {message}")
else:
loading_step = "check_data"
config.current_loading_system = "Téléchargement des jeux et images ..."
config.loading_progress = 50.0
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
elif loading_step == "check_data":
games_data_dir = os.path.join(config.APP_FOLDER, "games")
is_data_empty = not os.path.exists(games_data_dir) or not any(os.scandir(games_data_dir))
if is_data_empty:
config.current_loading_system = "Téléchargement du Dossier Data initial..."
config.loading_progress = 30.0
config.needs_redraw = True
logger.debug("Dossier Data vide, début du téléchargement du ZIP")
try:
zip_path = os.path.join(config.APP_FOLDER, "data_download.zip")
headers = {'User-Agent': 'Mozilla/5.0'}
with requests.get(OTA_data_ZIP, stream=True, headers=headers, timeout=30) as response:
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale du ZIP : {total_size} octets")
downloaded = 0
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
config.download_progress[OTA_data_ZIP] = {
"downloaded_size": downloaded,
"total_size": total_size,
"status": "Téléchargement",
"progress_percent": (downloaded / total_size * 100) if total_size > 0 else 0
}
config.loading_progress = 15.0 + (35.0 * downloaded / total_size) if total_size > 0 else 15.0
config.needs_redraw = True
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {zip_path}")
config.current_loading_system = "Extraction du Dossier Data initial..."
config.loading_progress = 60.0
config.needs_redraw = True
dest_dir = config.APP_FOLDER
success, message = extract_zip_data(zip_path, dest_dir, OTA_data_ZIP)
if success:
logger.debug(f"Extraction réussie : {message}")
config.loading_progress = 70.0
config.needs_redraw = True
else:
raise Exception(f"Échec de l'extraction : {message}")
except Exception as e:
logger.error(f"Erreur lors du téléchargement/extraction du Dossier Data : {str(e)}")
config.menu_state = "error"
config.error_message = f"Échec du téléchargement/extraction du Dossier Data : {str(e)}"
config.needs_redraw = True
loading_step = "load_sources"
if os.path.exists(zip_path):
os.remove(zip_path)
continue
if os.path.exists(zip_path):
os.remove(zip_path)
logger.debug(f"Fichier ZIP {zip_path} supprimé")
loading_step = "load_sources"
config.current_loading_system = "Chargement des systèmes..."
config.loading_progress = 80.0
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
else:
loading_step = "load_sources"
config.current_loading_system = "Chargement des systèmes..."
config.loading_progress = 80.0
config.needs_redraw = True
logger.debug(f"Dossier Data non vide, passage à {loading_step}")
elif loading_step == "load_sources":
sources = load_sources()
if not sources:
config.menu_state = "error"
config.error_message = "Échec du chargement de sources.json"
config.needs_redraw = True
logger.debug("Erreur : Échec du chargement de sources.json")
else:
config.menu_state = "platform"
config.loading_progress = 100.0
config.current_loading_system = ""
config.needs_redraw = True
logger.debug(f"Fin chargement, passage à platform, progress={config.loading_progress}")
# Gestion de l'état de transition
if config.transition_state == "to_game":
config.transition_progress += 1
if config.transition_progress >= config.transition_duration:
config.menu_state = "game"
config.transition_state = "idle"
config.transition_progress = 0.0
config.needs_redraw = True
logger.debug("Transition terminée, passage à game")
config.last_frame_time = current_time
clock.tick(60)
await asyncio.sleep(0.01)
pygame.mixer.music.stop()
pygame.quit()
logger.debug("Application terminée")
if platform.system() == "Emscripten":
asyncio.ensure_future(main())
else:
if __name__ == "__main__":
asyncio.run(main())

BIN
assets/Pixel-UniCode.ttf Normal file

Binary file not shown.

BIN
assets/music/8bit.mp3 Normal file

Binary file not shown.

BIN
assets/music/90s.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/music/fantasia.mp3 Normal file

Binary file not shown.

BIN
assets/music/game_mode.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/music/stranger.mp3 Normal file

Binary file not shown.

148
config.py Normal file
View File

@@ -0,0 +1,148 @@
import pygame # type: ignore
import os
import logging
logger = logging.getLogger(__name__)
# Version actuelle de l'application
app_version = "1.9.7.0"
# Langue par défaut
current_language = "fr"
# URL
OTA_SERVER_URL = "https://retrogamesets.fr/softs"
OTA_VERSION_ENDPOINT = f"{OTA_SERVER_URL}/version.json"
OTA_UPDATE_ZIP = f"{OTA_SERVER_URL}/RGSX.zip"
OTA_data_ZIP = f"{OTA_SERVER_URL}/rgsx-data.zip"
# Chemins de base
APP_FOLDER = "/userdata/roms/ports/RGSX"
SAVE_FOLDER = "/userdata/saves/ports/rgsx"
UPDATE_FOLDER = f"{APP_FOLDER}/update"
CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json")
HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json")
LANGUAGE_CONFIG_PATH = os.path.join(SAVE_FOLDER, "language.json")
JSON_EXTENSIONS = os.path.join(APP_FOLDER, "rom_extensions.json")
# Constantes pour la répétition automatique dans pause_menu
REPEAT_DELAY = 350 # Délai initial avant répétition (ms) - augmenté pour éviter les doubles actions
REPEAT_INTERVAL = 120 # Intervalle entre répétitions (ms) - ajusté pour une navigation plus contrôlée
REPEAT_ACTION_DEBOUNCE = 150 # Délai anti-rebond pour répétitions (ms) - augmenté pour éviter les doubles actions
# Variables d'état
platforms = []
current_platform = 0
platform_names = {} # {platform_id: platform_name}
games = []
current_game = 0
menu_state = "popup"
confirm_choice = False
scroll_offset = 0
visible_games = 15
popup_start_time = 0
last_progress_update = 0
needs_redraw = True
transition_state = "idle"
transition_progress = 0.0
transition_duration = 18
games_count = {}
# Variables pour la sélection de langue
selected_language_index = 0
loading_progress = 0.0
current_loading_system = ""
error_message = ""
repeat_action = None
repeat_start_time = 0
repeat_last_action = 0
repeat_key = None
filtered_games = []
search_mode = False
search_query = ""
filter_active = False
extension_confirm_selection = 0
pending_download = None
controls_config = {}
selected_option = 0
previous_menu_state = None
history = [] # Liste des entrées d'historique avec platform, game_name, status, url, progress, message, timestamp
download_progress = {}
download_tasks = {} # Dictionnaire pour les tâches de téléchargement
download_result_message = ""
download_result_error = False
download_result_start_time = 0
pending_download = None
needs_redraw = False
current_history_item = 0
history_scroll_offset = 0 # Offset pour le défilement de l'historique
visible_history_items = 15 # Nombre d'éléments d'historique visibles (ajusté dynamiquement)
confirm_clear_selection = 0 # confirmation clear historique
last_state_change_time = 0 # Temps du dernier changement d'état pour debounce
debounce_delay = 200 # Délai de debounce en millisecondes
platform_dicts = [] # Liste des dictionnaires de plateformes
selected_key = (0, 0) # Position du curseur dans le clavier virtuel
is_non_pc = True # Indicateur pour plateforme non-PC (par exemple, console)
redownload_confirm_selection = 0 # Sélection pour la confirmation de redownload
popup_message = "" # Message à afficher dans les popups
popup_timer = 0 # Temps restant pour le popup en millisecondes (0 = inactif)
last_frame_time = pygame.time.get_ticks()
GRID_COLS = 3 # Number of columns in the platform grid
GRID_ROWS = 4 # Number of rows in the platform grid
# Résolution de l'écran fallback
# Utilisée si la résolution définie dépasse les capacités de l'écran
SCREEN_WIDTH = 800
"""Largeur de l'écran en pixels."""
SCREEN_HEIGHT = 600
"""Hauteur de l'écran en pixels."""
# Polices
FONT = None
"""Police par défaut pour l'affichage, initialisée via init_font()."""
progress_font = None
"""Police pour l'affichage de la progression."""
title_font = None
"""Police pour les titres."""
search_font = None
"""Police pour la recherche."""
small_font = None
"""Police pour les petits textes."""
def init_font():
"""Initialise les polices après pygame.init()."""
global FONT, progress_font, title_font, search_font, small_font
try:
FONT = pygame.font.Font(None, 36)
progress_font = pygame.font.Font(None, 28)
title_font = pygame.font.Font(None, 48)
search_font = pygame.font.Font(None, 36)
small_font = pygame.font.Font(None, 24)
logger.debug("Polices initialisées avec succès")
# amazonq-ignore-next-line
except pygame.error as e:
logger.error(f"Erreur lors de l'initialisation des polices : {e}")
FONT = None
progress_font = None
title_font = None
search_font = None
small_font = None
def validate_resolution():
"""Valide la résolution de l'écran par rapport aux capacités de l'écran."""
display_info = pygame.display.Info()
if SCREEN_WIDTH > display_info.current_w or SCREEN_HEIGHT > display_info.current_h:
logger.warning(f"Résolution {SCREEN_WIDTH}x{SCREEN_HEIGHT} dépasse les limites de l'écran")
return display_info.current_w, display_info.current_h
return SCREEN_WIDTH, SCREEN_HEIGHT
API_KEY_1FICHIER = "" # Initialisation de la variable globale pour la clé API

1079
controls.py Normal file

File diff suppressed because it is too large Load Diff

516
controls_mapper.py Normal file
View File

@@ -0,0 +1,516 @@
import pygame # type: ignore
import json
import os
import logging
import config
from config import CONTROLS_CONFIG_PATH
from display import draw_gradient
logger = logging.getLogger(__name__)
# Chemin du fichier de configuration des contrôles
CONTROLS_CONFIG_PATH = "/userdata/saves/ports/rgsx/controls.json"
# Actions internes de RGSX à mapper
ACTIONS = [
{"name": "confirm", "display": "Confirmer", "description": "Valider (Recommandé: Entrée, A/Croix)"},
{"name": "cancel", "display": "Annuler", "description": "Annuler/Retour (Recommandé: Retour Arrière, B/Rond)"},
{"name": "up", "display": "Haut", "description": "Naviguer vers le haut"},
{"name": "down", "display": "Bas", "description": "Naviguer vers le bas"},
{"name": "left", "display": "Gauche", "description": "Naviguer à gauche"},
{"name": "right", "display": "Droite", "description": "Naviguer à droite"},
{"name": "page_up", "display": "Page Précédente", "description": "Page précédente/Défilement Rapide Haut (Recommandé: PageUp, LB/L1)"},
{"name": "page_down", "display": "Page Suivante", "description": "Page suivante/Défilement Rapide Bas (Recommandé: PageDown, RB/R1)"},
{"name": "history", "display": "Historique", "description": "Ouvrir l'historique (Recommandé: H, Y/Triangle)"},
{"name": "progress", "display": "Progression", "description": "Historique : Effacer la liste (Recommandé: X/Carré)"},
{"name": "filter", "display": "Filtrer", "description": "Ouvrir filtre (Recommandé: F, Select)"},
{"name": "delete", "display": "Supprimer", "description": "Mode Fitre : Supprimer caractère en mode recherche (Recommandé: DEL, LT/L2)"},
{"name": "space", "display": "Espace", "description": "Mode Filtre : Ajouter espace (Recommandé: Espace, RT/R2)"},
{"name": "start", "display": "Start", "description": "Menu pause / Paramètres (Recommandé: Start, AltGr)"},
]
# Mappage des valeurs SDL vers les constantes Pygame
SDL_TO_PYGAME_KEY = {
1073741906: pygame.K_UP, # Flèche Haut
1073741905: pygame.K_DOWN, # Flèche Bas
1073741904: pygame.K_LEFT, # Flèche Gauche
1073741903: pygame.K_RIGHT, # Flèche Droite
1073742050: pygame.K_LALT, # Alt gauche
1073742051: pygame.K_RSHIFT, # Alt droit
1073742049: pygame.K_LCTRL, # Ctrl gauche
1073742053: pygame.K_RCTRL, # Ctrl droit
1073742048: pygame.K_LSHIFT, # Shift gauche
1073742054: pygame.K_RALT, # Shift droit
}
# Noms lisibles pour les touches clavier
KEY_NAMES = {
pygame.K_RETURN: "Entrée",
pygame.K_ESCAPE: "Échap",
pygame.K_SPACE: "Espace",
pygame.K_UP: "Flèche Haut",
pygame.K_DOWN: "Flèche Bas",
pygame.K_LEFT: "Flèche Gauche",
pygame.K_RIGHT: "Flèche Droite",
pygame.K_BACKSPACE: "Retour Arrière",
pygame.K_TAB: "Tab",
pygame.K_LALT: "Alt",
pygame.K_RALT: "AltGR",
pygame.K_LCTRL: "LCtrl",
pygame.K_RCTRL: "RCtrl",
pygame.K_LSHIFT: "LShift",
pygame.K_RSHIFT: "RShift",
pygame.K_LMETA: "LMeta",
pygame.K_RMETA: "RMeta",
pygame.K_CAPSLOCK: "Verr Maj",
pygame.K_NUMLOCK: "Verr Num",
pygame.K_SCROLLOCK: "Verr Déf",
pygame.K_a: "A",
pygame.K_b: "B",
pygame.K_c: "C",
pygame.K_d: "D",
pygame.K_e: "E",
pygame.K_f: "F",
pygame.K_g: "G",
pygame.K_h: "H",
pygame.K_i: "I",
pygame.K_j: "J",
pygame.K_k: "K",
pygame.K_l: "L",
pygame.K_m: "M",
pygame.K_n: "N",
pygame.K_o: "O",
pygame.K_p: "P",
pygame.K_q: "Q",
pygame.K_r: "R",
pygame.K_s: "S",
pygame.K_t: "T",
pygame.K_u: "U",
pygame.K_v: "V",
pygame.K_w: "W",
pygame.K_x: "X",
pygame.K_y: "Y",
pygame.K_z: "Z",
pygame.K_0: "0",
pygame.K_1: "1",
pygame.K_2: "2",
pygame.K_3: "3",
pygame.K_4: "4",
pygame.K_5: "5",
pygame.K_6: "6",
pygame.K_7: "7",
pygame.K_8: "8",
pygame.K_9: "9",
pygame.K_KP0: "Pavé 0",
pygame.K_KP1: "Pavé 1",
pygame.K_KP2: "Pavé 2",
pygame.K_KP3: "Pavé 3",
pygame.K_KP4: "Pavé 4",
pygame.K_KP5: "Pavé 5",
pygame.K_KP6: "Pavé 6",
pygame.K_KP7: "Pavé 7",
pygame.K_KP8: "Pavé 8",
pygame.K_KP9: "Pavé 9",
pygame.K_KP_PERIOD: "Pavé .",
pygame.K_KP_DIVIDE: "Pavé /",
pygame.K_KP_MULTIPLY: "Pavé *",
pygame.K_KP_MINUS: "Pavé -",
pygame.K_KP_PLUS: "Pavé +",
pygame.K_KP_ENTER: "Pavé Entrée",
pygame.K_KP_EQUALS: "Pavé =",
pygame.K_F1: "F1",
pygame.K_F2: "F2",
pygame.K_F3: "F3",
pygame.K_F4: "F4",
pygame.K_F5: "F5",
pygame.K_F6: "F6",
pygame.K_F7: "F7",
pygame.K_F8: "F8",
pygame.K_F9: "F9",
pygame.K_F10: "F10",
pygame.K_F11: "F11",
pygame.K_F12: "F12",
pygame.K_F13: "F13",
pygame.K_F14: "F14",
pygame.K_F15: "F15",
pygame.K_INSERT: "Inser",
pygame.K_DELETE: "Suppr",
pygame.K_HOME: "Début",
pygame.K_END: "Fin",
pygame.K_PAGEUP: "Page Haut",
pygame.K_PAGEDOWN: "Page Bas",
pygame.K_PRINT: "Impr Écran",
pygame.K_SYSREQ: "SysReq",
pygame.K_BREAK: "Pause",
pygame.K_PAUSE: "Pause",
pygame.K_BACKQUOTE: "`",
pygame.K_MINUS: "-",
pygame.K_EQUALS: "=",
pygame.K_LEFTBRACKET: "[",
pygame.K_RIGHTBRACKET: "]",
pygame.K_BACKSLASH: "\\",
pygame.K_SEMICOLON: ";",
pygame.K_QUOTE: "'",
pygame.K_COMMA: ",",
pygame.K_PERIOD: ".",
pygame.K_SLASH: "/",
}
# Noms lisibles pour les boutons de manette
BUTTON_NAMES = {
0: "A",
1: "B",
2: "X",
3: "Y",
4: "LB",
5: "RB",
6: "LT",
7: "RT",
8: "Select",
9: "Start",
}
# Noms pour les axes de joystick
AXIS_NAMES = {
(0, 1): "Joy G Haut",
(0, -1): "Joy G Bas",
(1, 1): "Joy G Gauche",
(1, -1): "Joy G Droite",
(2, 1): "Joy D Haut",
(2, -1): "Joy D Bas",
(3, 1): "Joy D Gauche",
(3, -1): "Joy D Droite",
}
# Noms pour la croix directionnelle
HAT_NAMES = {
(0, 1): "D-Pad Haut",
(0, -1): "D-Pad Bas",
(-1, 0): "D-Pad Gauche",
(1, 0): "D-Pad Droite",
}
# Noms pour les boutons de souris
MOUSE_BUTTON_NAMES = {
1: "Clic Gauche",
2: "Clic Milieu",
3: "Clic Droit",
}
# Durée de maintien pour valider une entrée (en millisecondes)
HOLD_DURATION = 1000
JOYHAT_DEBOUNCE = 200 # Délai anti-rebond pour JOYHATMOTION (ms)
def load_controls_config():
#Charge la configuration des contrôles depuis controls.json
try:
if os.path.exists(CONTROLS_CONFIG_PATH):
with open(CONTROLS_CONFIG_PATH, "r") as f:
config = json.load(f)
logger.debug(f"Configuration des contrôles chargée : {config}")
return config
else:
logger.debug("Aucun fichier controls.json trouvé, configuration par défaut.")
return {}
except Exception as e:
logger.error(f"Erreur lors du chargement de controls.json : {e}")
return {}
def save_controls_config(controls_config):
#Enregistre la configuration des contrôles dans controls.json
try:
os.makedirs(os.path.dirname(CONTROLS_CONFIG_PATH), exist_ok=True)
with open(CONTROLS_CONFIG_PATH, "w") as f:
json.dump(controls_config, f, indent=4)
logger.debug(f"Configuration des contrôles enregistrée : {controls_config}")
except Exception as e:
logger.error(f"Erreur lors de l'enregistrement de controls.json : {e}")
def get_readable_input_name(event):
#Retourne un nom lisible pour une entrée (touche, bouton, axe, hat, ou souris)
if event.type == pygame.KEYDOWN:
key_value = SDL_TO_PYGAME_KEY.get(event.key, event.key)
return KEY_NAMES.get(key_value, pygame.key.name(key_value) or f"Touche {key_value}")
elif event.type == pygame.JOYBUTTONDOWN:
return BUTTON_NAMES.get(event.button, f"Bouton {event.button}")
elif event.type == pygame.JOYAXISMOTION:
if abs(event.value) > 0.5: # Seuil pour détecter un mouvement significatif
return AXIS_NAMES.get((event.axis, 1 if event.value > 0 else -1), f"Axe {event.axis}")
elif event.type == pygame.JOYHATMOTION:
return HAT_NAMES.get(event.value, f"D-Pad {event.value}")
elif event.type == pygame.MOUSEBUTTONDOWN:
return MOUSE_BUTTON_NAMES.get(event.button, f"Souris Bouton {event.button}")
return "Inconnu"
def map_controls(screen):
mapping = True
current_action = 0
clock = pygame.time.Clock()
while mapping:
clock.tick(100) # 100 FPS
for event in pygame.event.get():
# Initialisation des variables de contrôle
controls_config = load_controls_config()
current_action_index = 0
current_input = None
input_held_time = 0
last_input_name = None
last_frame_time = pygame.time.get_ticks()
config.needs_redraw = True
last_joyhat_time = 0 # Pour le débouncing des événements JOYHATMOTION
# Initialiser l'état des boutons et axes pour suivre les relâchements
held_keys = set()
held_buttons = set()
held_axes = {} # {axis: direction}
held_hats = {} # {hat: value}
held_mouse_buttons = set()
while current_action_index < len(ACTIONS):
if config.needs_redraw:
progress = min(input_held_time / HOLD_DURATION, 1.0) if current_input else 0.0
draw_controls_mapping(screen, ACTIONS[current_action_index], last_input_name, current_input is not None, progress)
pygame.display.flip()
config.needs_redraw = False
current_time = pygame.time.get_ticks()
delta_time = current_time - last_frame_time
last_frame_time = current_time
events = pygame.event.get()
for event in events:
if event.type == pygame.QUIT:
return False
# Détecter les relâchements pour réinitialiser
if event.type == pygame.KEYUP:
if event.key in held_keys:
held_keys.remove(event.key)
if current_input and current_input["type"] == "key" and current_input["value"] == event.key:
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
logger.debug(f"Touche relâchée: {event.key}")
elif event.type == pygame.JOYBUTTONUP:
if event.button in held_buttons:
held_buttons.remove(event.button)
if current_input and current_input["type"] == "button" and current_input["value"] == event.button:
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
logger.debug(f"Bouton relâché: {event.button}")
elif event.type == pygame.MOUSEBUTTONUP:
if event.button in held_mouse_buttons:
held_mouse_buttons.remove(event.button)
if current_input and current_input["type"] == "mouse" and current_input["value"] == event.button:
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
logger.debug(f"Bouton souris relâché: {event.button}")
elif event.type == pygame.JOYAXISMOTION:
if abs(event.value) < 0.5: # Axe revenu à la position neutre
if event.axis in held_axes:
del held_axes[event.axis]
if current_input and current_input["type"] == "axis" and current_input["value"][0] == event.axis:
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
logger.debug(f"Axe relâché: {event.axis}")
elif event.type == pygame.JOYHATMOTION:
logger.debug(f"JOYHATMOTION détecté: hat={event.hat}, value={event.value}")
if event.value == (0, 0): # D-Pad revenu à la position neutre
if event.hat in held_hats:
del held_hats[event.hat]
if current_input and current_input["type"] == "hat" and current_input["value"] == event.value:
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
logger.debug(f"D-Pad relâché: {event.hat}")
continue # Ignorer les événements (0, 0) pour la détection des nouvelles entrées
# Détecter les nouvelles entrées
if event.type in (pygame.KEYDOWN, pygame.JOYBUTTONDOWN, pygame.JOYAXISMOTION, pygame.JOYHATMOTION, pygame.MOUSEBUTTONDOWN):
# Appliquer le débouncing pour JOYHATMOTION
if event.type == pygame.JOYHATMOTION and (current_time - last_joyhat_time) < JOYHAT_DEBOUNCE:
logger.debug(f"Événement JOYHATMOTION ignoré (debounce): hat={event.hat}, value={event.value}")
continue
if event.type == pygame.JOYHATMOTION:
last_joyhat_time = current_time
input_name = get_readable_input_name(event)
if input_name != "Inconnu":
input_type = {
pygame.KEYDOWN: "key",
pygame.JOYBUTTONDOWN: "button",
pygame.JOYAXISMOTION: "axis",
pygame.JOYHATMOTION: "hat",
pygame.MOUSEBUTTONDOWN: "mouse",
}[event.type]
input_value = (
SDL_TO_PYGAME_KEY.get(event.key, event.key) if event.type == pygame.KEYDOWN else
event.button if event.type == pygame.JOYBUTTONDOWN else
(event.axis, 1 if event.value > 0 else -1) if event.type == pygame.JOYAXISMOTION and abs(event.value) > 0.5 else
event.value if event.type == pygame.JOYHATMOTION else
event.button
)
# Vérifier si l'entrée est nouvelle ou différente
if (current_input is None or
(input_type == "key" and current_input["value"] != input_value) or
(input_type == "button" and current_input["value"] != input_value) or
(input_type == "axis" and current_input["value"] != input_value) or
(input_type == "hat" and current_input["value"] != input_value) or
(input_type == "mouse" and current_input["value"] != input_value)):
current_input = {"type": input_type, "value": input_value}
input_held_time = 0
last_input_name = input_name
config.needs_redraw = True
logger.debug(f"Nouvelle entrée détectée: {input_type}:{input_value} ({input_name})")
# Mettre à jour les entrées maintenues
if input_type == "key":
held_keys.add(input_value)
elif input_type == "button":
held_buttons.add(input_value)
elif input_type == "axis":
held_axes[input_value[0]] = input_value[1]
elif input_type == "hat":
held_hats[event.hat] = input_value
elif input_type == "mouse":
held_mouse_buttons.add(input_value)
# Désactivation du passage avec Échap
# Aucun code ici pour empêcher de sauter les actions avec Échap
# Mettre à jour le temps de maintien
if current_input:
input_held_time += delta_time
if input_held_time >= HOLD_DURATION:
action_name = ACTIONS[current_action_index]["name"]
logger.debug(f"Entrée validée pour {action_name}: {current_input['type']}:{current_input['value']} ({last_input_name})")
controls_config[action_name] = {
"type": current_input["type"],
"value": current_input["value"],
"display": last_input_name
}
current_action_index += 1
current_input = None
input_held_time = 0
last_input_name = None
config.needs_redraw = True
# Réinitialiser les entrées maintenues pour éviter les interférences
held_keys.clear()
held_buttons.clear()
held_axes.clear()
held_hats.clear()
held_mouse_buttons.clear()
config.needs_redraw = True
pygame.time.wait(10)
save_controls_config(controls_config)
config.controls_config = controls_config
return True
pass
def save_controls_config(config):
#Enregistre la configuration des contrôles dans un fichier JSON
try:
with open(CONTROLS_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=4)
logger.debug("Configuration des contrôles enregistrée")
except Exception as e:
logger.error(f"Erreur lors de l'enregistrement de controls.json : {e}")
return False
return True
def draw_controls_mapping(screen, action, last_input, waiting_for_input, hold_progress):
#Affiche l'interface de mappage des contrôles avec une barre de progression pour le maintien
draw_gradient(screen, (28, 37, 38), (47, 59, 61))
# Paramètres de l'interface
padding_horizontal = 40
padding_vertical = 30
padding_between = 15
border_radius = 24
border_width = 4
shadow_offset = 8
# Titre principal
title_text = "Configuration des contrôles"
title_surface = config.title_font.render(title_text, True, (255, 255, 255))
title_rect = title_surface.get_rect(center=(config.screen_width // 2, 80))
screen.blit(title_surface, title_rect)
# Instructions
instruction_text = "Maintenez pendant 3s pour configurer :"
description_text = action['description']
instruction_surface = config.small_font.render(instruction_text, True, (255, 255, 255))
description_surface = config.font.render(description_text, True, (200, 200, 200))
instruction_width, instruction_height = instruction_surface.get_size()
description_width, description_height = description_surface.get_size()
# Input détecté
input_text = last_input or (f"En attente d'une touche ou bouton..." if waiting_for_input else "Appuyez sur une touche ou un bouton")
input_surface = config.small_font.render(input_text, True, (0, 255, 0) if last_input else (255, 255, 255))
input_width, input_height = input_surface.get_size()
# Dimensions de la popup
text_width = max(instruction_width, description_width, input_width)
text_height = instruction_height + description_height + input_height + 2 * padding_between
popup_width = text_width + 2 * padding_horizontal
popup_height = text_height + 40 + 2 * padding_vertical # +40 pour la barre de progression
popup_x = (config.screen_width - popup_width) // 2
popup_y = (config.screen_height - popup_height) // 2
# Ombre portée
shadow_rect = pygame.Rect(popup_x + shadow_offset, popup_y + shadow_offset, popup_width, popup_height)
shadow_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA)
pygame.draw.rect(shadow_surface, (0, 0, 0, 100), shadow_surface.get_rect(), border_radius=border_radius)
screen.blit(shadow_surface, shadow_rect.topleft)
# Fond semi-transparent
popup_rect = pygame.Rect(popup_x, popup_y, popup_width, popup_height)
popup_surface = pygame.Surface((popup_width, popup_height), pygame.SRCALPHA)
pygame.draw.rect(popup_surface, (30, 30, 30, 220), popup_surface.get_rect(), border_radius=border_radius)
screen.blit(popup_surface, popup_rect.topleft)
# Bordure blanche
pygame.draw.rect(screen, (255, 255, 255), popup_rect, border_width, border_radius=border_radius)
# Afficher les textes
start_y = popup_y + padding_vertical
instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, start_y + instruction_height // 2))
screen.blit(instruction_surface, instruction_rect)
start_y += instruction_height + padding_between
description_rect = description_surface.get_rect(center=(config.screen_width // 2, start_y + description_height // 2))
screen.blit(description_surface, description_rect)
start_y += description_height + padding_between
input_rect = input_surface.get_rect(center=(config.screen_width // 2, start_y + input_height // 2))
screen.blit(input_surface, input_rect)
start_y += input_height + padding_between
# Barre de progression pour le maintien
bar_width = 300
bar_height = 25
bar_x = (config.screen_width - bar_width) // 2
bar_y = start_y + 20
pygame.draw.rect(screen, (50, 50, 50), (bar_x, bar_y, bar_width, bar_height))
progress_width = bar_width * hold_progress
pygame.draw.rect(screen, (0, 255, 0), (bar_x, bar_y, progress_width, bar_height))
pygame.draw.rect(screen, (255, 255, 255), (bar_x, bar_y, bar_width, bar_height), 2)
# Afficher le pourcentage de progression
if hold_progress > 0:
progress_text = f"{int(hold_progress * 100)}%"
progress_surface = config.small_font.render(progress_text, True, (255, 255, 255))
progress_rect = progress_surface.get_rect(center=(config.screen_width // 2, bar_y + bar_height + 30))
screen.blit(progress_surface, progress_rect)

1244
display.py Normal file

File diff suppressed because it is too large Load Diff

85
history.py Normal file
View File

@@ -0,0 +1,85 @@
import json
import os
import logging
import config
from datetime import datetime
logger = logging.getLogger(__name__)
# Chemin par défaut pour history.json
DEFAULT_HISTORY_PATH = os.path.join(config.SAVE_FOLDER, "history.json")
def init_history():
"""Initialise le fichier history.json s'il n'existe pas."""
history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH)
# Vérifie si le fichier history.json existe, sinon le crée
if not os.path.exists(history_path):
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
with open(history_path, "w", encoding='utf-8') as f:
json.dump([], f) # Initialise avec une liste vide
logger.info(f"Fichier d'historique créé : {history_path}")
except OSError as e:
logger.error(f"Erreur lors de la création du fichier d'historique : {e}")
else:
logger.info(f"Fichier d'historique trouvé : {history_path}")
return history_path
def load_history():
"""Charge l'historique depuis history.json."""
history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH)
try:
if not os.path.exists(history_path):
logger.debug(f"Aucun fichier d'historique trouvé à {history_path}")
return []
with open(history_path, "r", encoding='utf-8') as f:
history = json.load(f)
# Valider la structure : liste de dictionnaires avec 'platform', 'game_name', 'status'
for entry in history:
if not all(key in entry for key in ['platform', 'game_name', 'status']):
logger.warning(f"Entrée d'historique invalide : {entry}")
return []
logger.debug(f"Historique chargé depuis {history_path}, {len(history)} entrées")
return history
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Erreur lors de la lecture de {history_path} : {e}")
return []
def save_history(history):
"""Sauvegarde l'historique dans history.json."""
history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH)
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
with open(history_path, "w", encoding='utf-8') as f:
json.dump(history, f, indent=2, ensure_ascii=False)
logger.debug(f"Historique sauvegardé dans {history_path}")
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {history_path} : {e}")
def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None):
"""Ajoute une entrée à l'historique."""
history = load_history()
entry = {
"platform": platform,
"game_name": game_name,
"status": status,
"url": url,
"progress": progress,
"timestamp": timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
if message:
entry["message"] = message
history.append(entry)
save_history(history)
logger.info(f"Ajout à l'historique : platform={platform}, game_name={game_name}, status={status}, progress={progress}")
return entry
def clear_history():
"""Vide l'historique."""
history_path = getattr(config, 'HISTORY_PATH', DEFAULT_HISTORY_PATH)
try:
with open(history_path, "w", encoding='utf-8') as f:
json.dump([], f)
logger.info(f"Historique vidé : {history_path}")
except Exception as e:
logger.error(f"Erreur lors du vidage de {history_path} : {e}")

349
language.py Normal file
View File

@@ -0,0 +1,349 @@
import os
import json
import pygame #type: ignore
import logging
import config
logger = logging.getLogger(__name__)
# Langue par défaut et variables globales
DEFAULT_LANGUAGE = "fr"
current_language = DEFAULT_LANGUAGE
translations = {}
show_language_selector_on_startup = False
def load_language(lang_code=None):
"""Charge les traductions pour la langue spécifiée ou la langue par défaut."""
global current_language, translations
if lang_code is None:
lang_code = DEFAULT_LANGUAGE
lang_file = os.path.join(config.APP_FOLDER, "languages", f"{lang_code}.json")
try:
if not os.path.exists(lang_file):
if lang_code != DEFAULT_LANGUAGE:
logger.warning(f"Fichier de langue {lang_code} non trouvé, utilisation de la langue par défaut")
return load_language(DEFAULT_LANGUAGE)
else:
logger.error(f"Fichier de langue par défaut {lang_file} non trouvé")
return False
with open(lang_file, 'r', encoding='utf-8') as f:
translations = json.load(f)
current_language = lang_code
logger.debug(f"Langue {lang_code} chargée avec succès ({len(translations)} traductions)")
return True
except Exception as e:
logger.error(f"Erreur lors du chargement de la langue {lang_code}: {str(e)}")
if lang_code != DEFAULT_LANGUAGE:
logger.warning(f"Tentative de chargement de la langue par défaut")
return load_language(DEFAULT_LANGUAGE)
return False
def get_text(key, default=None):
"""Récupère la traduction correspondant à la clé."""
if not translations:
load_language()
if key in translations:
return translations[key]
# Si la clé n'existe pas, retourner la valeur par défaut ou la clé elle-même
if default is not None:
return default
logger.warning(f"Clé de traduction '{key}' non trouvée dans la langue {current_language}")
return key
def get_available_languages():
"""Récupère la liste des langues disponibles."""
languages_dir = os.path.join(config.APP_FOLDER, "languages")
if not os.path.exists(languages_dir):
logger.warning(f"Dossier des langues {languages_dir} non trouvé")
return []
languages = []
for file in os.listdir(languages_dir):
if file.endswith(".json"):
lang_code = os.path.splitext(file)[0]
languages.append(lang_code)
return languages
def set_language(lang_code):
"""Change la langue courante et sauvegarde la préférence."""
if load_language(lang_code):
config.current_language = lang_code
save_language_preference(lang_code)
return True
return False
def save_language_preference(lang_code):
"""Sauvegarde la préférence de langue dans un fichier."""
try:
# S'assurer que le dossier existe
os.makedirs(os.path.dirname(config.LANGUAGE_CONFIG_PATH), exist_ok=True)
# Sauvegarder la préférence
with open(config.LANGUAGE_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump({"language": lang_code}, f)
logger.debug(f"Préférence de langue sauvegardée: {lang_code}")
return True
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde de la préférence de langue: {str(e)}")
return False
def load_language_preference():
"""Charge la préférence de langue depuis le fichier."""
global show_language_selector_on_startup
try:
if not os.path.exists(config.LANGUAGE_CONFIG_PATH):
logger.info("Aucune préférence de langue trouvée, utilisation du français par défaut")
# Créer le fichier avec le français par défaut
save_language_preference(DEFAULT_LANGUAGE)
return DEFAULT_LANGUAGE
with open(config.LANGUAGE_CONFIG_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
lang_code = data.get("language", DEFAULT_LANGUAGE)
logger.debug(f"Préférence de langue chargée: {lang_code}")
return lang_code
except json.JSONDecodeError:
logger.warning("Fichier de préférence de langue corrompu, utilisation du français par défaut")
# Recréer le fichier avec le français par défaut
save_language_preference(DEFAULT_LANGUAGE)
return DEFAULT_LANGUAGE
except Exception as e:
logger.error(f"Erreur lors du chargement de la préférence de langue: {str(e)}")
# Recréer le fichier avec le français par défaut
save_language_preference(DEFAULT_LANGUAGE)
return DEFAULT_LANGUAGE
def get_language_name(lang_code):
"""Retourne le nom de la langue à partir du code."""
language_names = {
"fr": "Français",
"en": "English",
"es": "Español",
"de": "Deutsch",
"it": "Italiano",
"pt": "Português",
"ja": "日本語",
"zh": "中文",
"ru": "Русский"
}
return language_names.get(lang_code, lang_code)
def draw_language_selector(screen, selected_language_index):
"""Affiche le sélecteur de langue."""
from display import THEME_COLORS, OVERLAY
# Obtenir les langues disponibles
available_languages = get_available_languages()
if not available_languages:
logger.error("Aucune langue disponible")
return
# Afficher l'overlay
screen.blit(OVERLAY, (0, 0))
# Titre
title_text = _("language_select_title")
title_surface = config.font.render(title_text, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, config.screen_height // 4))
# Fond du titre
title_bg_rect = title_rect.inflate(40, 20)
pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_bg_rect, border_radius=10)
pygame.draw.rect(screen, THEME_COLORS["border"], title_bg_rect, 2, border_radius=10)
screen.blit(title_surface, title_rect)
# Options de langue
button_height = 60
button_width = 300
button_spacing = 20
total_height = len(available_languages) * (button_height + button_spacing) - button_spacing
start_y = (config.screen_height - total_height) // 2
for i, lang_code in enumerate(available_languages):
# Obtenir le nom de la langue
lang_name = get_language_name(lang_code)
# Position du bouton
button_x = (config.screen_width - button_width) // 2
button_y = start_y + i * (button_height + button_spacing)
# Dessiner le bouton
button_color = THEME_COLORS["button_hover"] if i == selected_language_index else THEME_COLORS["button_idle"]
pygame.draw.rect(screen, button_color, (button_x, button_y, button_width, button_height), border_radius=10)
pygame.draw.rect(screen, THEME_COLORS["border"], (button_x, button_y, button_width, button_height), 2, border_radius=10)
# Texte du bouton
text_surface = config.font.render(lang_name, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(button_x + button_width // 2, button_y + button_height // 2))
screen.blit(text_surface, text_rect)
# Instructions
instruction_text = _("language_select_instruction")
instruction_surface = config.small_font.render(instruction_text, True, THEME_COLORS["text"])
instruction_rect = instruction_surface.get_rect(center=(config.screen_width // 2, config.screen_height - 50))
screen.blit(instruction_surface, instruction_rect)
def handle_language_menu_events(event, screen):
"""Gère les événements du menu de sélection de langue avec support clavier et manette."""
available_languages = get_available_languages()
if not available_languages:
logger.error("Aucune langue disponible")
config.menu_state = "platform" # Toujours revenir à platform en cas d'erreur
config.needs_redraw = True
return
# Navigation avec les touches du clavier
if event.type == pygame.KEYDOWN:
# Navigation vers le haut
if event.key == pygame.K_UP:
config.selected_language_index = (config.selected_language_index - 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le haut dans le sélecteur de langue: {config.selected_language_index}")
# Navigation vers le bas
elif event.key == pygame.K_DOWN:
config.selected_language_index = (config.selected_language_index + 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le bas dans le sélecteur de langue: {config.selected_language_index}")
# Sélection de la langue
elif event.key == pygame.K_RETURN:
lang_code = available_languages[config.selected_language_index]
if set_language(lang_code):
logger.info(f"Langue changée pour {lang_code}")
config.current_language = lang_code
# Déterminer l'état suivant en fonction du contexte
if config.previous_menu_state is None:
# Premier démarrage - passer à l'état loading pour charger les plateformes
config.menu_state = "loading"
logger.debug("Premier démarrage: passage à l'état loading après sélection de la langue")
elif config.previous_menu_state == "pause_menu":
# Si on vient du menu pause, retourner au menu pause avec un message
config.menu_state = "restart_popup"
config.popup_message = _("language_changed").format(lang_code)
config.popup_timer = 2000 # 2 secondes
config.previous_menu_state = "platform" # Pour revenir à l'écran principal après le popup
logger.debug("Message de confirmation de changement de langue affiché, retour au menu pause")
else:
# Autre cas, retourner à l'état précédent avec un message
config.menu_state = "platform" # Toujours revenir à platform pour éviter les problèmes
logger.debug(f"Retour à l'écran principal après sélection de la langue")
else:
# Retour au menu pause en cas d'erreur
config.menu_state = "platform" # Toujours revenir à platform en cas d'erreur
config.needs_redraw = True
logger.debug(f"Sélection de la langue: {lang_code}")
# Annulation (seulement si on n'est pas au démarrage)
elif event.key == pygame.K_ESCAPE and config.previous_menu_state is not None:
config.menu_state = "pause_menu"
config.needs_redraw = True
logger.debug("Annulation de la sélection de langue, retour au menu pause")
# Support de la manette
elif event.type == pygame.JOYBUTTONDOWN:
# Sélection avec le bouton A (généralement 0)
if event.button == 0: # Bouton A
lang_code = available_languages[config.selected_language_index]
if set_language(lang_code):
logger.info(f"Langue changée pour {lang_code} (manette)")
config.current_language = lang_code
# Déterminer l'état suivant en fonction du contexte
if config.previous_menu_state is None:
# Premier démarrage - passer à l'état loading pour charger les plateformes
config.menu_state = "loading"
logger.debug("Premier démarrage: passage à l'état loading après sélection de la langue (manette)")
else:
config.menu_state = "platform"
else:
config.menu_state = "platform"
config.needs_redraw = True
# Annulation avec le bouton B (généralement 1)
elif event.button == 1 and config.previous_menu_state is not None: # Bouton B
config.menu_state = "pause_menu"
config.needs_redraw = True
logger.debug("Annulation de la sélection de langue (manette), retour au menu pause")
# Navigation avec le D-pad
elif event.type == pygame.JOYHATMOTION:
if event.value == (0, 1): # Haut
config.selected_language_index = (config.selected_language_index - 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le haut dans le sélecteur de langue (D-pad): {config.selected_language_index}")
elif event.value == (0, -1): # Bas
config.selected_language_index = (config.selected_language_index + 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le bas dans le sélecteur de langue (D-pad): {config.selected_language_index}")
# Navigation avec les joysticks analogiques
elif event.type == pygame.JOYAXISMOTION:
# Joystick gauche vertical (généralement axe 1)
if event.axis == 1 and abs(event.value) > 0.5:
if event.value < -0.5: # Haut
config.selected_language_index = (config.selected_language_index - 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le haut dans le sélecteur de langue (joystick): {config.selected_language_index}")
elif event.value > 0.5: # Bas
config.selected_language_index = (config.selected_language_index + 1) % len(available_languages)
config.needs_redraw = True
logger.debug(f"Navigation vers le bas dans le sélecteur de langue (joystick): {config.selected_language_index}")
def update_valid_states():
"""Ajoute l'état language_select à la liste des états valides."""
from controls import VALID_STATES
if "language_select" not in VALID_STATES:
VALID_STATES.append("language_select")
logger.debug("État language_select ajouté aux états valides")
def initialize_language():
"""Initialise la langue au démarrage de l'application."""
global show_language_selector_on_startup
# Vérifier si le fichier de préférence de langue existe
language_file_exists = os.path.exists(config.LANGUAGE_CONFIG_PATH)
# Si le fichier n'existe pas, créer un fichier avec le français par défaut
if not language_file_exists:
logger.info("Aucun fichier de préférence de langue trouvé, création avec le français par défaut")
save_language_preference(DEFAULT_LANGUAGE)
show_language_selector_on_startup = False # Ne pas afficher le sélecteur au démarrage
else:
# Le fichier existe, charger normalement
show_language_selector_on_startup = False # Ne jamais afficher le sélecteur au démarrage
# Charger la préférence de langue
lang_code = load_language_preference()
# Charger la langue par défaut ou préférée
if load_language(lang_code):
logger.info(f"Langue chargée au démarrage: {lang_code}")
else:
logger.warning(f"Impossible de charger la langue {lang_code}, utilisation de la langue par défaut")
load_language(DEFAULT_LANGUAGE)
return True
# Alias pour faciliter l'utilisation
_ = get_text

159
languages/en.json Normal file
View File

@@ -0,0 +1,159 @@
{
"welcome_message": "Welcome to RGSX",
"disclaimer_line1": "It's dangerous to go alone, take all you need!",
"disclaimer_line2": "But only download games",
"disclaimer_line3": "that you already own!",
"disclaimer_line4": "RGSX is not responsible for downloaded content,",
"disclaimer_line5": "and does not host ROMs.",
"loading_test_connection": "Testing connection...",
"loading_update_check": "Checking for updates... Please wait...",
"loading_download_data": "Downloading games and images...",
"loading_download_initial": "Downloading initial Data Folder...",
"loading_extract_initial": "Extracting initial Data Folder...",
"loading_systems": "Loading systems...",
"loading_progress": "Progress: {0}%",
"error_no_internet": "No internet connection. Please check your network.",
"error_load_sources": "Failed to load sources.json",
"error_controls_mapping": "Failed to map controls",
"error_download_data": "Failed to download/extract Data Folder: {0}",
"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_extension": "Unsupported extension or download error",
"error_no_download": "No pending download.",
"platform_no_platform": "No platform",
"platform_page": "Page {0}/{1}",
"game_no_games": "No games available",
"game_count": "{0} ({1} games)",
"game_filter": "Active filter: {0}",
"game_search": "Filter: {0}",
"history_title": "Downloads ({0})",
"history_empty": "No downloads in history",
"history_column_system": "System",
"history_column_game": "Game name",
"history_column_status": "Status",
"history_status_downloading": "Downloading: {0}%",
"history_status_extracting": "Extracting: {0}%",
"history_status_completed": "Completed",
"history_status_error": "Error: {0}",
"download_status": "{0}: {1}",
"download_progress": "{0}% {1} MB / {2} MB",
"download_canceled": "Download canceled by user.",
"extension_warning_zip": "The file '{0}' is an archive and Batocera does not support archives for this system. Automatic extraction will occur after download, continue?",
"extension_warning_unsupported": "The file extension for '{0}' is not supported by Batocera according to the info.txt file. Do you want to continue?",
"confirm_exit": "Exit application?",
"confirm_clear_history": "Clear history?",
"confirm_redownload_cache": "Redownload games cache?",
"popup_redownload_success": "Games redownloaded successfully.\nPlease restart the application to see the changes.",
"popup_no_cache": "No cache found.\nPlease restart the application to load games.",
"popup_countdown": "This message will close in {0} second{1}",
"language_select_title": "Language Selection",
"language_select_instruction": "Use arrow keys to navigate and Enter to select",
"language_changed": "Language changed to {0}",
"menu_controls": "Controls",
"menu_remap_controls": "Remap controls",
"menu_history": "History",
"menu_language": "Language",
"menu_redownload_cache": "Redownload Games cache",
"menu_quit": "Quit",
"button_yes": "Yes",
"button_no": "No",
"button_validate": "Validate",
"controls_hold_message": "Hold for 3s for: '{0}'",
"controls_skip_message": "Press Esc to skip (PC only)",
"controls_waiting": "Waiting...",
"controls_hold": "Hold 3s",
"controls_action_confirm": "Confirm",
"controls_action_cancel": "Cancel",
"controls_action_up": "Up",
"controls_action_down": "Down",
"controls_action_left": "Left",
"controls_action_right": "Right",
"controls_action_page_up": "Previous Page",
"controls_action_page_down": "Next Page",
"controls_action_progress": "Progress",
"controls_action_history": "History",
"controls_action_filter": "Filter",
"controls_action_delete": "Delete",
"controls_action_space": "Space",
"controls_action_start": "Menu",
"controls_desc_confirm": "Validate (e.g. A, Enter)",
"controls_desc_cancel": "Cancel/Back (e.g. B, Backspace)",
"controls_desc_up": "Navigate up",
"controls_desc_down": "Navigate down",
"controls_desc_left": "Navigate left",
"controls_desc_right": "Navigate right",
"controls_desc_page_up": "Previous page/Fast scroll up (e.g. PageUp, LB)",
"controls_desc_page_down": "Next page/Fast scroll down (e.g. PageDown, RB)",
"controls_desc_progress": "View progress (e.g. X)",
"controls_desc_history": "Open history (e.g. H, Y)",
"controls_desc_filter": "Open filter (e.g. F, Select)",
"controls_desc_delete": "Delete character (e.g. LT, Delete)",
"controls_desc_space": "Add space (e.g. RT, Space)",
"controls_desc_start": "Open pause menu (e.g. Start, AltGr)",
"footer_version": "RGSX v{0} - {1}: Options - {2}: History - {3}: Filter",
"action_retry": "Retry",
"action_quit": "Quit",
"action_select": "Select",
"action_history": "History",
"action_progress": "Progress",
"action_download": "Download",
"action_filter": "Filter",
"action_cancel": "Cancel",
"action_back": "Back",
"action_navigate": "Navigate",
"action_page": "Page",
"action_cancel_download": "Cancel download",
"action_background": "Background",
"action_confirm": "Confirm",
"action_redownload": "Redownload",
"action_clear_history": "Clear history",
"network_checking_updates": "Checking for updates...",
"network_update_available": "Update available: {0}",
"network_extracting_update": "Extracting update...",
"network_update_completed": "Update completed",
"network_update_success": "Update to {0} completed successfully. Please restart the application.",
"network_update_success_message": "Update completed successfully",
"network_no_update_available": "No update available",
"network_update_error": "Error during update: {0}",
"network_check_update_error": "Error checking for updates: {0}",
"network_extraction_failed": "Failed to extract update: {0}",
"network_extraction_partial": "Extraction successful, but some files were skipped due to errors: {0}",
"network_extraction_success": "Extraction successful",
"network_download_extract_ok": "Download and extraction successful of {0}",
"network_zip_extraction_error": "Error extracting ZIP {0}: {1}",
"network_permission_error": "No write permission in {0}",
"network_file_not_found": "File {0} does not exist",
"network_cannot_get_filename": "Cannot retrieve filename",
"network_cannot_get_download_url": "Cannot retrieve download URL",
"network_download_failed": "Download failed after {0} attempts",
"network_api_error": "API request error, the key may be incorrect: {0}",
"network_download_error": "Download error {0}: {1}",
"network_download_ok": "Download OK: {0}",
"utils_extracted": "Extracted: {0}",
"utils_corrupt_zip": "Corrupted ZIP archive: {0}",
"utils_permission_denied": "Permission denied during extraction: {0}",
"utils_extraction_failed": "Extraction failed: {0}",
"utils_unrar_unavailable": "Unrar command not available",
"utils_rar_list_failed": "Failed to list RAR files: {0}"
}

159
languages/fr.json Normal file
View File

@@ -0,0 +1,159 @@
{
"welcome_message": "Bienvenue dans RGSX",
"disclaimer_line1": "It's dangerous to go alone, take all you need!",
"disclaimer_line2": "Mais ne téléchargez que des jeux",
"disclaimer_line3": "dont vous possédez les originaux !",
"disclaimer_line4": "RGSX n'est pas responsable des contenus téléchargés,",
"disclaimer_line5": "et n'heberge pas de ROMs.",
"loading_test_connection": "Test de connexion...",
"loading_update_check": "Verification Mise à jour en cours... Patientez...",
"loading_download_data": "Téléchargement des jeux et images ...",
"loading_download_initial": "Téléchargement du Dossier Data initial...",
"loading_extract_initial": "Extraction du Dossier Data initial...",
"loading_systems": "Chargement des systèmes...",
"loading_progress": "Progression : {0}%",
"error_no_internet": "Pas de connexion Internet. Vérifiez votre réseau.",
"error_load_sources": "Échec du chargement de sources.json",
"error_controls_mapping": "Échec du mappage des contrôles",
"error_download_data": "Échec du téléchargement/extraction du Dossier Data : {0}",
"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_extension": "Extension non supportée ou erreur de téléchargement",
"error_no_download": "Aucun téléchargement en attente.",
"platform_no_platform": "Aucune plateforme",
"platform_page": "Page {0}/{1}",
"game_no_games": "Aucun jeu disponible",
"game_count": "{0} ({1} jeux)",
"game_filter": "Filtre actif : {0}",
"game_search": "Filtrer : {0}",
"history_title": "Téléchargements ({0})",
"history_empty": "Aucun téléchargement dans l'historique",
"history_column_system": "Système",
"history_column_game": "Nom du jeu",
"history_column_status": "État",
"history_status_downloading": "Téléchargement : {0}%",
"history_status_extracting": "Extraction : {0}%",
"history_status_completed": "Terminé",
"history_status_error": "Erreur : {0}",
"download_status": "{0} : {1}",
"download_progress": "{0}% {1} Mo / {2} Mo",
"download_canceled": "Téléchargement annulé par l'utilisateur.",
"extension_warning_zip": "Le fichier '{0}' est une archive et Batocera ne prend pas en charge les archives pour ce système. L'extraction automatique du fichier aura lieu après le téléchargement, continuer ?",
"extension_warning_unsupported": "L'extension du fichier '{0}' n'est pas supportée par Batocera d'après le fichier info.txt. Voulez-vous continuer ?",
"confirm_exit": "Quitter l'application ?",
"confirm_clear_history": "Vider l'historique ?",
"confirm_redownload_cache": "Retélécharger le cache des jeux ?",
"popup_redownload_success": "Redownload des jeux effectué.\nVeuillez redémarrer l'application pour voir les changements.",
"popup_no_cache": "Aucun cache trouvé.\nVeuillez redémarrer l'application pour charger les jeux.",
"popup_countdown": "Ce message se fermera dans {0} seconde{1}",
"language_select_title": "Sélection de la langue",
"language_select_instruction": "Utilisez les flèches pour naviguer et Entrée pour sélectionner",
"language_changed": "Langue changée pour {0}",
"menu_controls": "Contrôles",
"menu_remap_controls": "Remapper les contrôles",
"menu_history": "Historique",
"menu_language": "Langue",
"menu_redownload_cache": "Retélécharger le cache des jeux",
"menu_quit": "Quitter",
"button_yes": "Oui",
"button_no": "Non",
"button_validate": "Valider",
"controls_hold_message": "Maintenez pendant 3s pour : '{0}'",
"controls_skip_message": "Appuyez sur Échap pour passer(Pc only)",
"controls_waiting": "Attente...",
"controls_hold": "Maintenez 3s",
"controls_action_confirm": "Confirmer",
"controls_action_cancel": "Annuler",
"controls_action_up": "Haut",
"controls_action_down": "Bas",
"controls_action_left": "Gauche",
"controls_action_right": "Droite",
"controls_action_page_up": "Page Précédente",
"controls_action_page_down": "Page Suivante",
"controls_action_progress": "Progression",
"controls_action_history": "Historique",
"controls_action_filter": "Filtrer",
"controls_action_delete": "Supprimer",
"controls_action_space": "Espace",
"controls_action_start": "Menu",
"controls_desc_confirm": "Valider (ex: A, Entrée)",
"controls_desc_cancel": "Annuler/Retour (ex: B, RetourArrière)",
"controls_desc_up": "Naviguer vers le haut",
"controls_desc_down": "Naviguer vers le bas",
"controls_desc_left": "Naviguer à gauche",
"controls_desc_right": "Naviguer à droite",
"controls_desc_page_up": "Page précédente/Défilement Rapide Haut (ex: PageUp, LB)",
"controls_desc_page_down": "Page suivante/Défilement Rapide Bas (ex: PageDown, RB)",
"controls_desc_progress": "Voir progression (ex: X)",
"controls_desc_history": "Ouvrir l'historique (ex: H, Y)",
"controls_desc_filter": "Ouvrir filtre (ex: F, Select)",
"controls_desc_delete": "Supprimer caractère (ex: LT, Suppr)",
"controls_desc_space": "Ajouter espace (ex: RT, Espace)",
"controls_desc_start": "Ouvrir le menu pause (ex: Start, AltGr)",
"footer_version": "RGSX v{0} - {1} : Options - {2}: Historique - {3} : Filtrer",
"action_retry": "Retenter",
"action_quit": "Quitter",
"action_select": "Sélectionner",
"action_history": "Historique",
"action_progress": "Progression",
"action_download": "Télécharger",
"action_filter": "Filtrer",
"action_cancel": "Annuler",
"action_back": "Retour",
"action_navigate": "Naviguer",
"action_page": "Page",
"action_cancel_download": "Annuler le téléchargement",
"action_background": "Arrière plan",
"action_confirm": "Confirmer",
"action_redownload": "Retélécharger",
"action_clear_history": "Vider l'historique",
"network_checking_updates": "Vérification des mises à jour...",
"network_update_available": "Mise à jour disponible : {0}",
"network_extracting_update": "Extraction de la mise à jour...",
"network_update_completed": "Mise à jour terminée",
"network_update_success": "Mise à jour vers {0} terminée avec succès. Veuillez redémarrer l'application.",
"network_update_success_message": "Mise à jour terminée avec succès",
"network_no_update_available": "Aucune mise à jour disponible",
"network_update_error": "Erreur lors de la mise à jour : {0}",
"network_download_extract_ok": "Téléchargement et extraction réussi de {0}",
"network_check_update_error": "Erreur lors de la vérification des mises à jour : {0}",
"network_extraction_failed": "Échec de l'extraction de la mise à jour : {0}",
"network_extraction_partial": "Extraction réussie, mais certains fichiers ont été ignorés en raison d'erreurs : {0}",
"network_extraction_success": "Extraction réussie",
"network_zip_extraction_error": "Erreur lors de l'extraction du ZIP {0}: {1}",
"network_permission_error": "Pas de permission d'écriture dans {0}",
"network_file_not_found": "Le fichier {0} n'existe pas",
"network_cannot_get_filename": "Impossible de récupérer le nom du fichier",
"network_cannot_get_download_url": "Impossible de récupérer l'URL de téléchargement",
"network_download_failed": "Échec du téléchargement après {0} tentatives",
"network_api_error": "Erreur lors de la requête API, la clé est peut-être incorrecte: {0}",
"network_download_error": "Erreur téléchargement {0}: {1}",
"network_download_ok": "Téléchargement ok : {0}",
"utils_extracted": "Extracted: {0}",
"utils_corrupt_zip": "Archive ZIP corrompue: {0}",
"utils_permission_denied": "Permission refusée lors de l'extraction: {0}",
"utils_extraction_failed": "Échec de l'extraction: {0}",
"utils_unrar_unavailable": "Commande unrar non disponible",
"utils_rar_list_failed": "Échec de la liste des fichiers RAR: {0}"
}

595
network.py Normal file
View File

@@ -0,0 +1,595 @@
import requests
import subprocess
import os
import threading
import pygame # type: ignore
import zipfile
import asyncio
import config
from config import OTA_VERSION_ENDPOINT,APP_FOLDER, UPDATE_FOLDER, OTA_UPDATE_ZIP
from utils import sanitize_filename, extract_zip, extract_rar, load_api_key_1fichier
from history import save_history
import logging
import queue
import time
import os
from language import _ # Import de la fonction de traduction
logger = logging.getLogger(__name__)
cache = {}
CACHE_TTL = 3600 # 1 heure
def test_internet():
logger.debug("Test de connexion Internet")
try:
result = subprocess.run(['ping', '-c', '4', '8.8.8.8'], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
logger.debug("Connexion Internet OK")
return True
else:
logger.debug("Échec ping 8.8.8.8")
return False
except Exception as e:
logger.debug(f"Erreur test Internet: {str(e)}")
return False
async def check_for_updates():
try:
logger.debug("Vérification de la version disponible sur le serveur")
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')})")
version_data = response.json()
latest_version = version_data.get("version")
logger.debug(f"Version distante : {latest_version}, version locale : {config.app_version}")
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
config.needs_redraw = True
logger.debug(f"Téléchargement du ZIP de mise à jour : {UPDATE_ZIP}")
# Créer le dossier UPDATE_FOLDER s'il n'existe pas
os.makedirs(UPDATE_FOLDER, exist_ok=True)
update_zip_path = os.path.join(UPDATE_FOLDER, "RGSX.zip")
logger.debug(f"Téléchargement de {UPDATE_ZIP} vers {update_zip_path}")
# Télécharger le ZIP
with requests.get(UPDATE_ZIP, stream=True, timeout=10) as r:
r.raise_for_status()
total_size = int(r.headers.get('content-length', 0))
downloaded = 0
with open(update_zip_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
config.loading_progress = 10.0 + (40.0 * downloaded / total_size) if total_size > 0 else 10.0
config.needs_redraw = True
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {update_zip_path}")
# Extraire le contenu du ZIP dans APP_FOLDER
config.current_loading_system = _("network_extracting_update")
config.loading_progress = 60.0
config.needs_redraw = True
success, message = extract_update(update_zip_path, APP_FOLDER, UPDATE_ZIP)
if not success:
logger.error(f"Échec de l'extraction : {message}")
return False, _("network_extraction_failed").format(message)
# Supprimer le fichier ZIP après extraction
if os.path.exists(update_zip_path):
os.remove(update_zip_path)
logger.debug(f"Fichier ZIP {update_zip_path} supprimé")
config.current_loading_system = _("network_update_completed")
config.loading_progress = 100.0
config.needs_redraw = True
logger.debug("Mise à jour terminée avec succès")
# Configurer la popup pour afficher le message de succès
config.menu_state = "update_result"
config.update_result_message = _("network_update_success").format(latest_version)
config.update_result_error = False
config.update_result_start_time = pygame.time.get_ticks()
config.needs_redraw = True
logger.debug(f"Affichage de la popup de mise à jour réussie")
return True, _("network_update_success_message")
else:
logger.debug("Aucune mise à jour disponible")
return True, _("network_no_update_available")
except Exception as e:
logger.error(f"Erreur OTA : {str(e)}")
config.menu_state = "update_result"
config.update_result_message = _("network_update_error").format(str(e))
config.update_result_error = True
config.update_result_start_time = pygame.time.get_ticks()
config.needs_redraw = True
return False, _("network_check_update_error").format(str(e))
def extract_update(zip_path, dest_dir, source_url):
try:
# Vérifier et ajuster les permissions du répertoire de destination
os.makedirs(dest_dir, exist_ok=True)
try:
subprocess.run(["chmod", "-R", "u+rw", dest_dir], check=True)
logger.debug(f"Permissions ajustées pour {dest_dir}")
except subprocess.CalledProcessError as e:
logger.warning(f"Impossible d'ajuster les permissions pour {dest_dir}: {str(e)}")
# Extraire le ZIP
skipped_files = []
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
for file_info in zip_ref.infolist():
try:
zip_ref.extract(file_info, dest_dir)
except PermissionError as e:
logger.warning(f"Impossible d'extraire {file_info.filename}: {str(e)}")
skipped_files.append(file_info.filename)
except Exception as e:
logger.warning(f"Erreur lors de l'extraction de {file_info.filename}: {str(e)}")
skipped_files.append(file_info.filename)
if skipped_files:
message = _("network_extraction_partial").format(', '.join(skipped_files))
logger.warning(message)
return True, message # Considérer comme succès si certains fichiers sont extraits
return True, _("network_extraction_success")
except Exception as e:
logger.error(f"Erreur critique lors de l'extraction du ZIP {source_url}: {str(e)}")
return False, _("network_zip_extraction_error").format(source_url, str(e))
# File d'attente pour la progression
import queue
progress_queue = queue.Queue()
async def download_rom(url, platform, game_name, is_zip_non_supported=False, task_id=None):
logger.debug(f"Début téléchargement: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}, task_id={task_id}")
result = [None, None]
# Vider la file d'attente avant de commencer
while not progress_queue.empty():
try:
progress_queue.get_nowait()
logger.debug(f"File progress_queue vidée pour {game_name}")
except queue.Empty:
break
def download_thread():
logger.debug(f"Thread téléchargement démarré pour {url}, task_id={task_id}")
try:
dest_dir = None
for platform_dict in config.platform_dicts:
if platform_dict["platform"] == platform:
dest_dir = platform_dict.get("folder")
break
if not dest_dir:
dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform.lower().replace(" ", ""))
os.makedirs(dest_dir, exist_ok=True)
if not os.access(dest_dir, os.W_OK):
raise PermissionError(f"Pas de permission d'écriture dans {dest_dir}")
sanitized_name = sanitize_filename(game_name)
dest_path = os.path.join(dest_dir, f"{sanitized_name}")
logger.debug(f"Chemin destination: {dest_path}")
headers = {'User-Agent': 'Mozilla/5.0'}
response = requests.get(url, stream=True, headers=headers, timeout=30)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale: {total_size} octets")
# Initialiser la progression avec task_id
progress_queue.put((task_id, 0, total_size))
logger.debug(f"Progression initiale envoyée: 0% pour {game_name}, task_id={task_id}")
downloaded = 0
chunk_size = 4096
last_update_time = time.time()
update_interval = 0.5 # Mettre à jour toutes les 0,5 secondes
with open(dest_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
size_received = len(chunk)
f.write(chunk)
downloaded += size_received
current_time = time.time()
if current_time - last_update_time >= update_interval:
# Calculer le pourcentage correctement et le limiter entre 0 et 100
progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0
progress_percent = max(0, min(100, progress_percent))
progress_queue.put((task_id, downloaded, total_size))
last_update_time = current_time
else:
logger.debug("Chunk vide reçu")
os.chmod(dest_path, 0o644)
logger.debug(f"Téléchargement terminé: {dest_path}")
# Vérifier si l'extraction est nécessaire pour les archives non supportées
if is_zip_non_supported:
logger.debug(f"Extraction automatique nécessaire pour {dest_path}")
extension = os.path.splitext(dest_path)[1].lower()
if extension == ".zip":
try:
# Mettre à jour le statut avant l'extraction
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["status"] = "Extracting"
entry["progress"] = 0
entry["message"] = "Préparation de l'extraction..."
save_history(config.history)
config.needs_redraw = True
break
success, msg = extract_zip(dest_path, dest_dir, url)
if success:
logger.debug(f"Extraction ZIP réussie: {msg}")
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction ZIP: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction: {str(e)}")
result[0] = False
result[1] = f"Erreur téléchargement {game_name}: {str(e)}"
elif extension == ".rar":
try:
success, msg = extract_rar(dest_path, dest_dir, url)
if success:
logger.debug(f"Extraction RAR réussie: {msg}")
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction RAR: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction RAR: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction RAR {game_name}: {str(e)}"
else:
logger.warning(f"Type d'archive non supporté: {extension}")
result[0] = True
result[1] = _("network_download_ok").format(game_name)
else:
result[0] = True
result[1] = _("network_download_ok").format(game_name)
except Exception as e:
logger.error(f"Erreur téléchargement {url}: {str(e)}")
result[0] = False
result[1] = _("network_download_error").format(game_name, str(e))
finally:
logger.debug(f"Thread téléchargement terminé pour {url}, task_id={task_id}")
progress_queue.put((task_id, result[0], result[1]))
logger.debug(f"Final result sent to queue: success={result[0]}, message={result[1]}, task_id={task_id}")
thread = threading.Thread(target=download_thread)
thread.start()
# Boucle principale pour mettre à jour la progression
while thread.is_alive():
try:
while not progress_queue.empty():
data = progress_queue.get()
logger.debug(f"Progress queue data received: {data}")
if len(data) != 3 or data[0] != task_id: # Ignorer les données d'une autre tâche
logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}")
continue
if isinstance(data[1], bool): # Fin du téléchargement
success, message = data[1], data[2]
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "Téléchargement", "Extracting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
# Utiliser une variable intermédiaire pour stocker le message
message_text = message
entry["message"] = message_text
save_history(config.history)
config.needs_redraw = True
logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}")
break
else:
downloaded, total_size = data[1], data[2]
# Calculer le pourcentage correctement et le limiter entre 0 et 100
progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0
progress_percent = max(0, min(100, progress_percent))
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["progress"] = progress_percent
entry["status"] = "Téléchargement"
entry["downloaded_size"] = downloaded
entry["total_size"] = total_size
config.needs_redraw = True
break
await asyncio.sleep(0.2)
except Exception as e:
logger.error(f"Erreur mise à jour progression: {str(e)}")
thread.join()
logger.debug(f"Thread joined for {url}, task_id={task_id}")
return result[0], result[1]
async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=False, task_id=None):
load_api_key_1fichier()
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}")
result = [None, None]
# Vider la file d'attente avant de commencer
while not progress_queue.empty():
try:
progress_queue.get_nowait()
logger.debug(f"File progress_queue vidée pour {game_name}")
except queue.Empty:
break
def download_thread():
logger.debug(f"Thread téléchargement 1fichier démarré pour {url}, task_id={task_id}")
try:
link = url.split('&af=')[0]
dest_dir = None
for platform_dict in config.platform_dicts:
if platform_dict["platform"] == platform:
dest_dir = platform_dict.get("folder")
break
if not dest_dir:
logger.warning(f"Aucun dossier 'folder' trouvé pour la plateforme {platform}")
dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform)
logger.debug(f"Vérification répertoire destination: {dest_dir}")
os.makedirs(dest_dir, exist_ok=True)
if not os.access(dest_dir, os.W_OK):
raise PermissionError(f"Pas de permission d'écriture dans {dest_dir}")
headers = {
"Authorization": f"Bearer {config.API_KEY_1FICHIER}",
"Content-Type": "application/json"
}
payload = {
"url": link,
"pretty": 1
}
logger.debug(f"Envoi requête POST à https://api.1fichier.com/v1/file/info.cgi pour {url}")
response = requests.post("https://api.1fichier.com/v1/file/info.cgi", headers=headers, json=payload, timeout=30)
logger.debug(f"Réponse reçue, status: {response.status_code}")
response.raise_for_status()
file_info = response.json()
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)
return
filename = file_info.get("filename", "").strip()
if not filename:
logger.error("Impossible de récupérer le nom du fichier")
result[0] = False
result[1] = _("network_cannot_get_filename")
return
sanitized_filename = sanitize_filename(filename)
dest_path = os.path.join(dest_dir, sanitized_filename)
logger.debug(f"Chemin destination: {dest_path}")
logger.debug(f"Envoi requête POST à https://api.1fichier.com/v1/download/get_token.cgi pour {url}")
response = requests.post("https://api.1fichier.com/v1/download/get_token.cgi", headers=headers, json=payload, timeout=30)
logger.debug(f"Réponse reçue, status: {response.status_code}")
response.raise_for_status()
download_info = response.json()
final_url = download_info.get("url")
if not final_url:
logger.error("Impossible de récupérer l'URL de téléchargement")
result[0] = False
result[1] = _("network_cannot_get_download_url")
return
lock = threading.Lock()
retries = 10
retry_delay = 10
# Initialiser la progression avec task_id
progress_queue.put((task_id, 0, 0)) # Taille initiale inconnue
logger.debug(f"Progression initiale envoyée: 0% pour {game_name}, task_id={task_id}")
for attempt in range(retries):
try:
logger.debug(f"Tentative {attempt + 1} : Envoi requête GET à {final_url}")
with requests.get(final_url, stream=True, headers={'User-Agent': 'Mozilla/5.0'}, timeout=30) as response:
logger.debug(f"Réponse reçue, status: {response.status_code}")
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale: {total_size} octets")
with lock:
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] == "downloading":
entry["total_size"] = total_size
config.needs_redraw = True
break
progress_queue.put((task_id, 0, total_size)) # Mettre à jour la taille totale
downloaded = 0
chunk_size = 8192
last_update_time = time.time()
update_interval = 0.5 # Mettre à jour toutes les 0,5 secondes
with open(dest_path, 'wb') as f:
logger.debug(f"Ouverture fichier: {dest_path}")
for chunk in response.iter_content(chunk_size=chunk_size):
if chunk:
f.write(chunk)
downloaded += len(chunk)
current_time = time.time()
if current_time - last_update_time >= update_interval:
with lock:
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] == "downloading":
# Calculer le pourcentage correctement et le limiter entre 0 et 100
progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0
progress_percent = max(0, min(100, progress_percent))
entry["progress"] = progress_percent
entry["status"] = "Téléchargement"
entry["downloaded_size"] = downloaded
entry["total_size"] = total_size
config.needs_redraw = True
logger.debug(f"Progression mise à jour: {entry['progress']:.1f}% pour {game_name}")
break
progress_queue.put((task_id, downloaded, total_size))
last_update_time = current_time
if is_zip_non_supported:
with lock:
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] == "Téléchargement":
entry["progress"] = 0
entry["status"] = "Extracting"
config.needs_redraw = True
break
extension = os.path.splitext(dest_path)[1].lower()
if extension == ".zip":
try:
success, msg = extract_zip(dest_path, dest_dir, url)
if success:
logger.debug(f"Extraction ZIP réussie: {msg}")
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction ZIP: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction: {str(e)}")
result[0] = False
result[1] = f"Erreur téléchargement {game_name}: {str(e)}"
elif extension == ".rar":
try:
success, msg = extract_rar(dest_path, dest_dir, url)
if success:
logger.debug(f"Extraction RAR réussie: {msg}")
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction RAR: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction RAR: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction RAR {game_name}: {str(e)}"
else:
logger.warning(f"Type d'archive non supporté: {extension}")
result[0] = True
result[1] = _("network_download_ok").format(game_name)
else:
os.chmod(dest_path, 0o644)
logger.debug(f"Téléchargement terminé: {dest_path}")
result[0] = True
result[1] = _("network_download_ok").format(game_name)
return
except requests.exceptions.RequestException as e:
logger.error(f"Tentative {attempt + 1} échouée : {e}")
if attempt < retries - 1:
time.sleep(retry_delay)
else:
logger.error("Nombre maximum de tentatives atteint")
result[0] = False
result[1] = _("network_download_failed").format(retries)
return
except requests.exceptions.RequestException as e:
logger.error(f"Erreur API 1fichier : {e}")
result[0] = False
result[1] = _("network_api_error").format(str(e))
finally:
logger.debug(f"Thread téléchargement 1fichier terminé pour {url}, task_id={task_id}")
progress_queue.put((task_id, result[0], result[1]))
logger.debug(f"Final result sent to queue: success={result[0]}, message={result[1]}, task_id={task_id}")
thread = threading.Thread(target=download_thread)
logger.debug(f"Démarrage thread pour {url}, task_id={task_id}")
thread.start()
# Boucle principale pour mettre à jour la progression
while thread.is_alive():
try:
while not progress_queue.empty():
data = progress_queue.get()
logger.debug(f"Progress queue data received: {data}")
if len(data) != 3 or data[0] != task_id: # Ignorer les données d'une autre tâche
logger.debug(f"Ignoring queue data for task_id={data[0]}, expected={task_id}")
continue
if isinstance(data[1], bool): # Fin du téléchargement
success, message = data[1], data[2]
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "Téléchargement", "Extracting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
# Utiliser une variable intermédiaire pour stocker le message
message_text = message
entry["message"] = message_text
save_history(config.history)
config.needs_redraw = True
logger.debug(f"Final update in history: status={entry['status']}, progress={entry['progress']}%, message={message}, task_id={task_id}")
break
else:
downloaded, total_size = data[1], data[2]
# Calculer le pourcentage correctement et le limiter entre 0 et 100
progress_percent = int(downloaded / total_size * 100) if total_size > 0 else 0
progress_percent = max(0, min(100, progress_percent))
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
if "url" in entry and entry["url"] == url and entry["status"] in ["downloading", "Téléchargement"]:
entry["progress"] = progress_percent
entry["status"] = "Téléchargement"
entry["downloaded_size"] = downloaded
entry["total_size"] = total_size
config.needs_redraw = True
break
await asyncio.sleep(0.2)
except Exception as e:
logger.error(f"Erreur mise à jour progression: {str(e)}")
thread.join()
logger.debug(f"Thread joined for {url}, task_id={task_id}")
return result[0], result[1]
def is_1fichier_url(url):
"""Détecte si l'URL est un lien 1fichier."""
return "1fichier.com" in url

2317
rom_extensions.json Normal file

File diff suppressed because it is too large Load Diff

87
update_gamelist.py Normal file
View File

@@ -0,0 +1,87 @@
import os
import xml.dom.minidom
import xml.etree.ElementTree as ET
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
GAMELIST_FILE = "/userdata/roms/ports/gamelist.xml"
RGSX_ENTRY = {
"path": "./RGSX/RGSX.sh",
"name": "RGSX",
"desc": "Retro Games Sets X - Games Downloader",
"image": "./images/RGSX.png",
"marquee": "./images/RGSX.png",
"thumbnail": "./images/RGSX.png",
"fanart": "./images/RGSX.png",
"rating": "1",
"releasedate": "20250620T165718",
"developer": "RetroGameSets.fr",
"genre": "Compilation, Various / Utilities",
"playcount": "251",
"lastplayed": "20250621T234656",
"gametime": "30830",
"lang": "fr"
}
def update_gamelist():
try:
# Si le fichier n'existe pas, est vide ou non valide, créer une nouvelle structure
if not os.path.exists(GAMELIST_FILE) or os.path.getsize(GAMELIST_FILE) == 0:
logger.info(f"Création de {GAMELIST_FILE}")
root = ET.Element("gameList")
else:
try:
logger.info(f"Lecture de {GAMELIST_FILE}")
tree = ET.parse(GAMELIST_FILE)
root = tree.getroot()
if root.tag != "gameList":
logger.info(f"{GAMELIST_FILE} n'a pas de balise <gameList>, création d'une nouvelle structure")
root = ET.Element("gameList")
except ET.ParseError:
logger.info(f"{GAMELIST_FILE} est invalide, création d'une nouvelle structure")
root = ET.Element("gameList")
# Supprimer l'ancienne entrée RGSX
for game in root.findall("game"):
path = game.find("path")
if path is not None and path.text == "./RGSX/RGSX.sh":
root.remove(game)
logger.info("Ancienne entrée RGSX supprimée")
# Ajouter la nouvelle entrée
game_elem = ET.SubElement(root, "game")
for key, value in RGSX_ENTRY.items():
elem = ET.SubElement(game_elem, key)
elem.text = value
logger.info("Nouvelle entrée RGSX ajoutée")
# Générer le XML avec minidom pour une indentation correcte
rough_string = '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding='unicode')
parsed = xml.dom.minidom.parseString(rough_string)
pretty_xml = parsed.toprettyxml(indent="\t", encoding='utf-8').decode('utf-8')
# Supprimer les lignes vides inutiles générées par minidom
pretty_xml = '\n'.join(line for line in pretty_xml.split('\n') if line.strip())
with open(GAMELIST_FILE, 'w', encoding='utf-8') as f:
f.write(pretty_xml)
logger.info(f"{GAMELIST_FILE} mis à jour avec succès")
# Définir les permissions
os.chmod(GAMELIST_FILE, 0o644)
except Exception as e:
logger.error(f"Erreur lors de la mise à jour de {GAMELIST_FILE}: {e}")
raise
def load_gamelist(path):
"""Charge le fichier gamelist.xml."""
try:
tree = ET.parse(path)
return tree.getroot()
except (FileNotFoundError, ET.ParseError) as e:
logging.error(f"Erreur lors de la lecture de {path} : {e}")
return None
if __name__ == "__main__":
update_gamelist()

623
utils.py Normal file
View File

@@ -0,0 +1,623 @@
import shutil
import pygame # type: ignore
import re
import json
import os
import logging
import platform
import subprocess
import config
import threading
import zipfile
import time
import random
from config import JSON_EXTENSIONS, SAVE_FOLDER
from history import save_history
from language import _ # Import de la fonction de traduction
from datetime import datetime
logger = logging.getLogger(__name__)
# Désactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
# Liste globale pour stocker les systèmes avec une erreur 404
unavailable_systems = []
# Détection système non-PC
def detect_non_pc():
arch = platform.machine()
try:
result = subprocess.run(["batocera-es-swissknife", "--arch"], capture_output=True, text=True, timeout=2)
if result.returncode == 0:
arch = result.stdout.strip()
logger.debug(f"Architecture via batocera-es-swissknife: {arch}")
except (subprocess.SubprocessError, FileNotFoundError):
logger.debug(f"batocera-es-swissknife non disponible, utilisation de platform.machine(): {arch}")
is_non_pc = arch not in ["x86_64", "amd64"]
logger.debug(f"Système détecté: {platform.system()}, architecture: {arch}, is_non_pc={is_non_pc}")
return is_non_pc
# Fonction pour charger le fichier JSON des extensions supportées
def load_extensions_json():
"""Charge le fichier JSON contenant les extensions supportées."""
try:
with open(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}")
return []
def check_extension_before_download(url, platform, game_name):
"""Vérifie l'extension avant de lancer le téléchargement et retourne un tuple de 4 éléments."""
try:
sanitized_name = sanitize_filename(game_name)
extensions_data = load_extensions_json()
if not extensions_data:
logger.error(f"Fichier {JSON_EXTENSIONS} vide ou introuvable")
return None
is_supported = is_extension_supported(sanitized_name, platform, extensions_data)
extension = os.path.splitext(sanitized_name)[1].lower()
is_archive = extension in (".zip", ".rar")
if is_supported:
logger.debug(f"L'extension de {sanitized_name} est supportée pour {platform}")
return (url, platform, game_name, False)
elif is_archive:
logger.debug(f"Archive {extension.upper()} détectée pour {sanitized_name}, extraction automatique prévue")
return (url, platform, game_name, True)
else:
logger.debug(f"Extension non supportée ({extension}) pour {sanitized_name}, avertissement affiché")
return (url, platform, game_name, False)
except Exception as e:
logger.error(f"Erreur vérification extension {url}: {str(e)}")
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."""
extension = os.path.splitext(filename)[1].lower()
dest_dir = None
for platform_dict in config.platform_dicts:
if platform_dict["platform"] == platform:
dest_dir = platform_dict.get("folder")
break
if not dest_dir:
logger.warning(f"Aucun dossier 'folder' trouvé pour la plateforme {platform}")
dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform)
for system in extensions_data:
if system["folder"] == dest_dir:
return extension in system["extensions"]
logger.warning(f"Aucun système trouvé pour le dossier {dest_dir}")
return False
# Fonction pour charger sources.json
def load_sources():
"""Charge les sources depuis sources.json et initialise les plateformes."""
sources_path = os.path.join(config.APP_FOLDER, "sources.json")
logger.debug(f"Chargement de {sources_path}")
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
return sources
except Exception as e:
logger.error(f"Erreur lors du chargement de sources.json : {str(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.APP_FOLDER, "games", f"{platform_id}.json")
logger.debug(f"Chargement des jeux pour {platform_id} depuis {games_path}")
try:
with open(games_path, 'r', encoding='utf-8') as f:
games = json.load(f)
# Tester la première URL si la liste n'est pas vide
# if games and len(games) > 0 and len(games[0]) > 1:
# first_url = games[0][1]
# try:
# response = requests.head(first_url, timeout=5, allow_redirects=True)
# if response.status_code not in (200, 303): # Ne logger que les codes autres que 200 et 303
# logger.debug(f"https://{first_url} \"HEAD {first_url} HTTP/1.1\" {response.status_code} 0")
# if response.status_code == 404:
# logger.error(f"URL non accessible pour {platform_id} : {first_url} (code 404)")
# unavailable_systems.append(platform_id) # Assurez-vous que unavailable_systems est défini
# except requests.RequestException as e:
# logger.error(f"Erreur lors du test de l'URL pour {platform_id} : {first_url} ({str(e)})")
# else:
# logger.debug(f"Aucune URL à tester pour {platform_id} (liste vide ou mal formée)")
logger.debug(f"Jeux chargés pour {platform_id}: {len(games)} jeux")
return games
except Exception as e:
logger.error(f"Erreur lors du chargement des jeux pour {platform_id} : {str(e)}")
return []
def write_unavailable_systems():
"""Écrit la liste des systèmes avec une erreur 404 dans un fichier texte."""
if not unavailable_systems:
logger.debug("Aucun système avec des liens HS, rien à écrire dans le fichier.")
return
# 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")
try:
# Créer le répertoire s'il n'existe pas
os.makedirs(log_dir, exist_ok=True)
# Écrire les systèmes dans le fichier
with open(log_file, 'w', encoding='utf-8') as f:
f.write("Systèmes avec une erreur 404 :\n")
for system in unavailable_systems:
f.write(f"{system}\n")
logger.debug(f"Fichier écrit : {log_file} avec {len(unavailable_systems)} systèmes")
except Exception as e:
logger.error(f"Erreur lors de l'écriture du fichier {log_file} : {str(e)}")
def truncate_text_middle(text, font, max_width, is_filename=True):
"""Tronque le texte en insérant '...' au milieu, en préservant le début et la fin.
Si is_filename=False, ne supprime pas l'extension."""
# Supprimer l'extension uniquement si is_filename est True
if is_filename:
text = text.rsplit('.', 1)[0] if '.' in text else text
text_width = font.size(text)[0]
if text_width <= max_width:
return text
ellipsis = "..."
ellipsis_width = font.size(ellipsis)[0]
max_text_width = max_width - ellipsis_width
if max_text_width <= 0:
return ellipsis
# Diviser la largeur disponible entre début et fin, en priorisant la fin
chars = list(text)
left = []
right = []
left_width = 0
right_width = 0
left_idx = 0
right_idx = len(chars) - 1
# Préserver plus de caractères à droite pour garder le '%'
while left_idx <= right_idx and (left_width + right_width) < max_text_width:
# Ajouter à droite en priorité
if left_idx <= right_idx:
right.insert(0, chars[right_idx])
right_width = font.size(''.join(right))[0]
if left_width + right_width > max_text_width:
right.pop(0)
break
right_idx -= 1
# Ajouter à gauche seulement si nécessaire
if left_idx < right_idx:
left.append(chars[left_idx])
left_width = font.size(''.join(left))[0]
if left_width + right_width > max_text_width:
left.pop()
break
left_idx += 1
# Reculer jusqu'à un espace pour éviter de couper un mot
while left and left[-1] != ' ' and left_width + right_width > max_text_width:
left.pop()
left_width = font.size(''.join(left))[0] if left else 0
while right and right[0] != ' ' and left_width + right_width > max_text_width:
right.pop(0)
right_width = font.size(''.join(right))[0] if right else 0
return ''.join(left).rstrip() + ellipsis + ''.join(right).lstrip()
def truncate_text_end(text, font, max_width):
"""Tronque le texte à la fin pour qu'il tienne dans max_width avec la police donnée."""
if not isinstance(text, str):
logger.error(f"Texte non valide: {text}")
return ""
if not isinstance(font, pygame.font.Font):
logger.error("Police non valide dans truncate_text_end")
return text # Retourne le texte brut si la police est invalide
try:
if font.size(text)[0] <= max_width:
return text
truncated = text
while len(truncated) > 0 and font.size(truncated + "...")[0] > max_width:
truncated = truncated[:-1]
return truncated + "..." if len(truncated) < len(text) else text
except Exception as e:
logger.error(f"Erreur lors du rendu du texte '{text}': {str(e)}")
return text # Retourne le texte brut en cas d'erreur
def sanitize_filename(name):
"""Sanitise les noms de fichiers en remplaçant les caractères interdits."""
return re.sub(r'[<>:"/\/\\|?*]', '_', name).strip()
def wrap_text(text, font, max_width):
"""Divise le texte en lignes pour respecter la largeur maximale, en coupant les mots longs si nécessaire."""
if not isinstance(text, str):
text = str(text) if text is not None else ""
words = text.split(' ')
lines = []
current_line = ''
for word in words:
# Si le mot seul dépasse max_width, le couper caractère par caractère
if font.render(word, True, (255, 255, 255)).get_width() > max_width:
temp_line = current_line
for char in word:
test_line = temp_line + (' ' if temp_line else '') + char
test_surface = font.render(test_line, True, (255, 255, 255))
if test_surface.get_width() <= max_width:
temp_line = test_line
else:
if temp_line:
lines.append(temp_line)
temp_line = char
current_line = temp_line
else:
# Comportement standard pour les mots normaux
test_line = current_line + (' ' if current_line else '') + word
test_surface = font.render(test_line, True, (255, 255, 255))
if test_surface.get_width() <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
return lines
def load_system_image(platform_dict):
"""Charge une image système depuis le chemin spécifié dans system_image."""
image_path = platform_dict.get("system_image")
platform_name = platform_dict.get("platform", "unknown")
#logger.debug(f"Chargement de l'image système pour {platform_name} depuis {image_path}")
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()
except Exception as e:
logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}")
return None
def extract_zip_data(zip_path, dest_dir, url):
"""Extrait le contenu du fichier ZIP dans le dossier config.APP_FOLDER sans progression a l'ecran"""
logger.debug(f"Extraction de {zip_path} dans {dest_dir}")
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.testzip() # Vérifier l'intégrité de l'archive
for info in zip_ref.infolist():
if info.is_dir():
continue
file_path = os.path.join(dest_dir, info.filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with zip_ref.open(info) as source, open(file_path, 'wb') as dest:
shutil.copyfileobj(source, dest)
logger.info(f"Extraction terminée de {zip_path}")
return True, "Extraction terminée avec succès"
except zipfile.BadZipFile as e:
logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}")
return False, _("utils_corrupt_zip").format(str(e))
def extract_zip(zip_path, dest_dir, url):
"""Extrait le contenu du fichier ZIP dans le dossier cible avec un suivi progressif de la progression."""
logger.debug(f"Extraction de {zip_path} dans {dest_dir}")
try:
lock = threading.Lock()
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.testzip() # Vérifier l'intégrité de l'archive
total_size = sum(info.file_size for info in zip_ref.infolist() if not info.is_dir())
logger.info(f"Taille totale à extraire: {total_size} octets")
if total_size == 0:
logger.warning("ZIP vide ou ne contenant que des dossiers")
return True, "ZIP vide extrait avec succès"
extracted_size = 0
os.makedirs(dest_dir, exist_ok=True)
chunk_size = 2048 # Réduire pour plus de mises à jour
last_save_time = time.time()
save_interval = 0.5 # Sauvegarder toutes les 0.5 secondes
for info in zip_ref.infolist():
if info.is_dir():
continue
file_path = os.path.join(dest_dir, info.filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with zip_ref.open(info) as source, open(file_path, 'wb') as dest:
file_size = info.file_size
file_extracted = 0
while True:
chunk = source.read(chunk_size)
if not chunk:
break
dest.write(chunk)
file_extracted += len(chunk)
extracted_size += len(chunk)
current_time = time.time()
with lock:
# Vérifier si config.history est une liste avant d'itérer
if isinstance(config.history, list):
for entry in config.history:
# Vérifier si l'entrée a les clés nécessaires et correspond à notre téléchargement
if "status" in entry and entry["status"] in ["Téléchargement", "Extracting", "downloading"]:
# Chercher par URL si disponible
if "url" in entry and entry["url"] == url:
# Calculer le pourcentage correctement et le limiter entre 0 et 100
progress_percent = int(extracted_size / total_size * 100) if total_size > 0 else 0
progress_percent = max(0, min(100, progress_percent))
entry["status"] = "Extracting"
entry["progress"] = progress_percent
entry["message"] = "Extraction en cours"
if current_time - last_save_time >= save_interval:
save_history(config.history)
last_save_time = current_time
logger.debug(f"Extraction en cours: {info.filename}, file_extracted={file_extracted}/{file_size}, total_extracted={extracted_size}/{total_size}, progression={progress_percent:.1f}%")
config.needs_redraw = True
break
os.chmod(file_path, 0o644)
for root, dirs, files in os.walk(dest_dir):
for dir_name in dirs:
os.chmod(os.path.join(root, dir_name), 0o755)
try:
os.remove(zip_path)
logger.info(f"Fichier ZIP {zip_path} extrait dans {dest_dir} et supprimé")
# Mettre à jour le statut final dans l'historique
if isinstance(config.history, list):
for entry in config.history:
if "status" in entry and entry["status"] == "Extracting":
entry["status"] = "Download_OK"
entry["progress"] = 100
# Utiliser une variable intermédiaire pour stocker le message
message_text = _("utils_extracted").format(os.path.basename(zip_path))
entry["message"] = message_text
save_history(config.history)
config.needs_redraw = True
break
return True, _("utils_extracted").format(os.path.basename(zip_path))
except Exception as e:
logger.error(f"Erreur lors de la finalisation de l'extraction: {str(e)}")
return True, _("utils_extracted").format(os.path.basename(zip_path))
except zipfile.BadZipFile as e:
logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}")
return False, _("utils_corrupt_zip").format(str(e))
except PermissionError as e:
logger.error(f"Erreur: Permission refusée lors de l'extraction: {str(e)}")
return False, _("utils_permission_denied").format(str(e))
except Exception as e:
logger.error(f"Erreur lors de l'extraction de {zip_path}: {str(e)}")
return False, _("utils_extraction_failed").format(str(e))
# Fonction pour extraire le contenu d'un fichier RAR
def extract_rar(rar_path, dest_dir, url):
"""Extrait le contenu du fichier RAR dans le dossier cible, préservant la structure des dossiers."""
try:
lock = threading.Lock()
os.makedirs(dest_dir, exist_ok=True)
result = subprocess.run(['unrar'], capture_output=True, text=True)
if result.returncode not in [0, 1]:
logger.error("Commande unrar non disponible")
return False, _("utils_unrar_unavailable")
result = subprocess.run(['unrar', 'l', '-v', rar_path], capture_output=True, text=True)
if result.returncode != 0:
error_msg = result.stderr.strip()
logger.error(f"Erreur lors de la liste des fichiers RAR: {error_msg}")
return False, _("utils_rar_list_failed").format(error_msg)
logger.debug(f"Sortie brute de 'unrar l -v {rar_path}':\n{result.stdout}")
total_size = 0
files_to_extract = []
root_dirs = set()
lines = result.stdout.splitlines()
in_file_list = False
for line in lines:
if line.startswith("----"):
in_file_list = not in_file_list
continue
if in_file_list:
match = re.match(r'^\s*(\S+)\s+(\d+)\s+\d*\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(.+)$', line)
if match:
attrs = match.group(1)
file_size = int(match.group(2))
file_date = match.group(3)
file_name = match.group(4).strip()
if 'D' not in attrs:
files_to_extract.append((file_name, file_size))
total_size += file_size
root_dir = file_name.split('/')[0] if '/' in file_name else ''
if root_dir:
root_dirs.add(root_dir)
logger.debug(f"Ligne parsée: {file_name}, taille: {file_size}, date: {file_date}")
else:
logger.debug(f"Dossier ignoré: {file_name}")
else:
logger.debug(f"Ligne ignorée (format inattendu): {line}")
logger.info(f"Taille totale à extraire (RAR): {total_size} octets")
logger.debug(f"Fichiers à extraire: {files_to_extract}")
logger.debug(f"Dossiers racines détectés: {root_dirs}")
if total_size == 0:
logger.warning("RAR vide, ne contenant que des dossiers, ou erreur de parsing")
return False, "RAR vide ou erreur lors de la liste des fichiers"
try:
with lock:
# Vérifier si l'URL existe dans config.download_progress
if url not in config.download_progress:
config.download_progress[url] = {}
config.download_progress[url]["downloaded_size"] = 0
config.download_progress[url]["total_size"] = total_size
config.download_progress[url]["status"] = "Extracting"
config.download_progress[url]["progress_percent"] = 0
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur lors de la mise à jour de la progression: {str(e)}")
# Continuer l'extraction même en cas d'erreur de mise à jour de la progression
escaped_rar_path = rar_path.replace(" ", "\\ ")
escaped_dest_dir = dest_dir.replace(" ", "\\ ")
process = subprocess.Popen(['unrar', 'x', '-y', escaped_rar_path, escaped_dest_dir],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
if process.returncode != 0:
logger.error(f"Erreur lors de l'extraction de {rar_path}: {stderr}")
return False, f"Erreur lors de l'extraction: {stderr}"
extracted_size = 0
extracted_files = []
total_files = len(files_to_extract)
for i, (expected_file, file_size) in enumerate(files_to_extract):
file_path = os.path.join(dest_dir, expected_file)
if os.path.exists(file_path):
extracted_size += file_size
extracted_files.append(expected_file)
os.chmod(file_path, 0o644)
logger.debug(f"Fichier extrait: {expected_file}, taille: {file_size}, chemin: {file_path}")
try:
with lock:
if url in config.download_progress:
config.download_progress[url]["downloaded_size"] = extracted_size
config.download_progress[url]["status"] = "Extracting"
config.download_progress[url]["progress_percent"] = ((i + 1) / total_files * 100) if total_files > 0 else 0
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur lors de la mise à jour de la progression d'extraction: {str(e)}")
# Continuer l'extraction même en cas d'erreur de mise à jour de la progression
else:
logger.warning(f"Fichier non trouvé après extraction: {expected_file}")
missing_files = [f for f, _ in files_to_extract if f not in extracted_files]
if missing_files:
logger.warning(f"Fichiers non extraits: {', '.join(missing_files)}")
return False, f"Fichiers non extraits: {', '.join(missing_files)}"
ps3_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), "ps3")
if dest_dir == ps3_dir and len(root_dirs) == 1:
root_dir = root_dirs.pop()
old_path = os.path.join(dest_dir, root_dir)
new_path = os.path.join(dest_dir, f"{root_dir}.ps3")
if os.path.isdir(old_path):
try:
os.rename(old_path, new_path)
logger.info(f"Dossier renommé: {old_path} -> {new_path}")
except Exception as e:
logger.error(f"Erreur lors du renommage de {old_path} en {new_path}: {str(e)}")
return False, f"Erreur lors du renommage du dossier: {str(e)}"
else:
logger.warning(f"Dossier racine {old_path} non trouvé après extraction")
elif dest_dir == ps3_dir and len(root_dirs) > 1:
logger.warning(f"Plusieurs dossiers racines détectés dans l'archive: {root_dirs}. Aucun renommage effectué.")
for root, dirs, files in os.walk(dest_dir):
for dir_name in dirs:
os.chmod(os.path.join(root, dir_name), 0o755)
os.remove(rar_path)
logger.info(f"Fichier RAR {rar_path} extrait dans {dest_dir} et supprimé")
return True, "RAR extrait avec succès"
except Exception as e:
logger.error(f"Erreur lors de l'extraction de {rar_path}: {str(e)}")
# Ne pas renvoyer l'URL comme message d'erreur
return False, f"Erreur lors de l'extraction: {str(e)}"
finally:
if os.path.exists(rar_path):
try:
os.remove(rar_path)
logger.info(f"Fichier RAR {rar_path} supprimé après échec de l'extraction")
except Exception as e:
logger.error(f"Erreur lors de la suppression de {rar_path}: {str(e)}")
def play_random_music(music_files, music_folder, current_music=None):
"""Joue une musique aléatoire et configure l'événement de fin."""
if music_files:
# Éviter de rejouer la même musique consécutivement
available_music = [f for f in music_files if f != current_music]
if not available_music: # Si une seule musique, on la reprend
available_music = music_files
music_file = random.choice(available_music)
music_path = os.path.join(music_folder, music_file)
logger.debug(f"Lecture de la musique : {music_path}")
pygame.mixer.music.load(music_path)
pygame.mixer.music.set_volume(0.5)
pygame.mixer.music.play(loops=0) # Jouer une seule fois
pygame.mixer.music.set_endevent(pygame.USEREVENT + 1) # Événement de fin
set_music_popup(music_file) # Afficher le nom de la musique dans la popup
return music_file # Retourner la nouvelle musique pour mise à jour
else:
logger.debug("Aucune musique trouvée dans /RGSX/assets/music")
return current_music
def set_music_popup(music_name):
"""Définit le nom de la musique à afficher dans la popup."""
global current_music_name, music_popup_start_time
current_music_name = f"{os.path.splitext(music_name)[0]}" # Utilise l'emoji ♬ directement
music_popup_start_time = pygame.time.get_ticks() / 1000 # Temps actuel en secondes
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")
try:
# Vérifie si le fichier existe déjà
if not os.path.exists(api_path):
# Crée le fichier vide si absent
with open(api_path, "w") as f:
f.write("")
logger.info(f"Fichier de clé API créé : {api_path}")
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:
api_key = f.read().strip()
logger.debug(f"Clé API 1fichier chargée : {api_key}")
if not api_key:
logger.warning("Clé API 1fichier vide, veuillez la renseigner dans le fichier pour pouvoir utiliser les fonctionnalités de téléchargement sur 1fichier.")
return api_key
except OSError as e:
logger.error(f"Erreur lors de la lecture de la clé API : {e}")
return ""