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
View File
@@ -0,0 +1,9 @@
logs/
images/
games/
__pycache__/
sources.json
gamelist.xml
*.log
*.rar
*.zip
+175
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
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
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())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+148
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
View File
File diff suppressed because it is too large Load Diff
+516
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
View File
File diff suppressed because it is too large Load Diff
+85
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
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
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
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
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
View File
File diff suppressed because it is too large Load Diff
+87
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
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 ""