Compare commits

...

16 Commits

Author SHA1 Message Date
skymike03
2f437c1aa4 v2.6.0.2 (2025.03.17)
- Add support and donation information to release notes
- Add normalize game name for roms scanning (ie. Game (USA).ext will be shown owned for a rom named only "Game.ext"
2026-03-17 23:12:50 +01:00
skymike03
054b174c18 v2.6.0.1 (2025.03.16)
• add Debrid-Link API key support in desktop and web settings
• add Debrid-Link fallback for premium link generation in addition to AllDebrid and RealDebrid
2026-03-16 20:28:19 +01:00
skymike03
fbb1a2aa68 v2.5.0.7 (2026.03.16)
• improve filters/search performance with lazy cache without slowing unfiltered game list access
• fix fbneo logging traceback and avoid re-downloading/parsing fbneo gamelist when cache is already available
• add rom scan to rebuild downloaded games database from local files with extension-insensitive matching and backward compatibility
• fix clear history to remove stale converting entries and keep only real active transfers
• fix keyboard mode controls display to ignore joystick-only mappings and restore proper keyboard labels
• update displayed keyboard labels with ascii-safe names (Esc/Echap, Debut, Verr Def)
• move version/controller info to a top-right header badge and add page number badge on top-left
• simplify platform footer and loading footer layout
• add global search from platform menu across all available systems
• fix global search input handling and routing
• update global search confirm action to download directly and allow queue action from results
• add size and ext columns to global search results
• add ext column to game list and history
• add folder column to history to show download destination folder
• fix return from history to restore current game list instead of jumping back to platform list
• fix history crash caused by ext column text truncation
2026-03-16 19:09:47 +01:00
skymike03
1dbc741617 v2.5.0.6 (2026.03.15)
update download status handling for converting state and ps3 dec function
2026-03-15 23:47:32 +01:00
skymike03
c4913a5fc2 v2.5.0.5 (2026.03.05)
Merge pull request [#48](https://github.com/RetroGameSets/RGSX/issues/48) from elieserdejesus

- Fixing and upgrade Filters and search
- showing fbneo 'full name' instead rom name.
2026-03-05 18:45:01 +01:00
RGS
bf9d3d2de5 Merge pull request #48 from elieserdejesus/filters
Fixing Filters
2026-03-05 15:37:33 +01:00
Elieser de Jesus
9979949bdc Applying filters using 'display_name'
Adding a Game class with a display_name used do show games. The 'display_name'
is the game file name without suffix (.zip, .7z, .bin, etc) and without platform
prefix. Many platforms line NES, mega drive was showing the plaftorm name as prefix.

Now the filters are working with the 'display_name'.

I see the filters are no applyed until the "apply" button is clicked. Now the filters
are applyed everytime the game list is showed.
2026-03-05 11:06:07 -03:00
Elieser de Jesus
9ed264544f adding bootleg filter 2026-03-05 08:40:33 -03:00
Elieser de Jesus
779c060927 removing duplicated entries 2026-03-05 08:36:51 -03:00
RGS
88400e538f Merge pull request #47 from elieserdejesus/main
Showing and filtering FBneo games using 'full name' instead 'rom name'
2026-03-05 09:19:12 +01:00
Elieser de Jesus
cbab067dd6 filtering fbneo games by full name instead rom name 2026-03-04 18:45:18 -03:00
Elieser de Jesus
b4ed0b355d showing fbneo 'full name' instead rom name.
The full names are downloaded from github fbneo
repo only when user select fbneo in platforms screen
2026-03-04 18:42:21 -03:00
skymike03
51ad08ff33 v2.5.0.4 (2026.02.15)
- add some new cool musics :P
2026-02-15 20:34:28 +01:00
skymike03
d6a5c4b27e Add type ignore comment for requests import in network.py 2026-02-08 18:13:03 +01:00
skymike03
2c7c3414a5 v2.5.0.3 (2026.02.08)
- add 7z support for extract games
- add cookie test for archive.org downloads (new romhacks platforms added)
2026-02-08 16:58:11 +01:00
skymike03
059d3988ac v2.5.0.2 (2026.02.06)
- add "versionclean" script to clean batocera version after activating custom rgsx service at boot . Thanks to the BUA project for the script.
- add encoding UTF-8 for retrobat launcher to avoid controller with non ASCII characters to crash rgsx (issue #43) thanks to Crover81
- add new menu in Menu>Settings to test connection to all usefull urls and logging
- some log trim
2026-02-06 16:26:40 +01:00
50 changed files with 2347 additions and 297 deletions

View File

@@ -119,6 +119,16 @@ jobs:
### 📖 Documentation
[README.md](https://github.com/${{ github.repository }}/blob/main/README.md)
## SUPPORT US
Donate , Buy me a beer or a coffee :
if you want to support my project you can look here 🙂 https://bit.ly/donate-to-rgsx
Affiliate links :
hi all if you want to buy a premium account, you can use affiliated links here to support dev of RGSX without donate anything :
DEBRID-LINK.FR : https://debrid-link.fr/id/ow1DD
1FICHIER.COM : https://1fichier.com/?af=3186111
REAL-DEBRID.FR : http://real-debrid.com/?id=8441
RELEASE_EOF
echo "✓ Release notes generated"

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ pygame/
data/
docker-compose.test.yml
config/
pyrightconfig.json

View File

@@ -28,6 +28,7 @@ from display import (
init_display, draw_loading_screen, draw_error_screen, draw_platform_grid,
draw_progress_screen, draw_controls, draw_virtual_keyboard,
draw_extension_warning, draw_pause_menu, draw_controls_help, draw_game_list,
draw_global_search_list,
draw_display_menu, draw_filter_menu_choice, draw_filter_advanced, draw_filter_priority_config,
draw_history_list, draw_clear_history_dialog, draw_cancel_download_dialog,
draw_confirm_dialog, draw_reload_games_data_dialog, draw_popup, draw_gradient,
@@ -64,7 +65,7 @@ except Exception as e:
logger = logging.getLogger(__name__)
# Ensure API key files (1Fichier, AllDebrid, RealDebrid) exist at startup so user can fill them before any download
# Ensure API key files (1Fichier, AllDebrid, Debrid-Link, RealDebrid) exist at startup so user can fill them before any download
try: # pragma: no cover
load_api_keys(False)
except Exception as _e:
@@ -526,7 +527,7 @@ async def main():
# Appui long détecté, ouvrir le scraper
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
game_name = games[config.current_game][0]
game_name = games[config.current_game].name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
config.previous_menu_state = "game"
@@ -661,6 +662,7 @@ async def main():
# Basculer sur les contrôles clavier
config.joystick = False
config.keyboard = True
config.controller_device_name = ""
# Recharger la configuration des contrôles pour le clavier
config.controls_config = load_controls_config()
logger.info("Contrôles clavier chargés")
@@ -669,6 +671,7 @@ async def main():
# Basculer sur les contrôles clavier
config.joystick = False
config.keyboard = True
config.controller_device_name = ""
# Recharger la configuration des contrôles pour le clavier
config.controls_config = load_controls_config()
logger.info("Contrôles clavier chargés")
@@ -709,6 +712,7 @@ async def main():
"pause_games_menu",
"pause_settings_menu",
"pause_api_keys_status",
"pause_connection_status",
"filter_platforms",
"display_menu",
"language_select",
@@ -728,6 +732,7 @@ async def main():
"filter_menu_choice",
"filter_advanced",
"filter_priority_config",
"platform_search",
}
if config.menu_state in SIMPLE_HANDLE_STATES:
action = handle_controls(event, sources, joystick, screen)
@@ -834,12 +839,9 @@ async def main():
logger.debug("Action quit détectée, arrêt de l'application")
elif action == "download" and config.menu_state == "game" and config.filtered_games:
game = config.filtered_games[config.current_game]
if isinstance(game, (list, tuple)):
game_name = game[0]
url = game[1] if len(game) > 1 else None
else: # fallback str
game_name = str(game)
url = None
game_name = game.name
url = game.url
# Nouveau schéma: config.platforms contient déjà platform_name (string)
platform_name = config.platforms[config.current_platform]
if url:
@@ -851,7 +853,12 @@ async def main():
keys_info = ensure_download_provider_keys(False)
except Exception as e:
logger.error(f"Impossible de charger les clés via helpers: {e}")
keys_info = {'1fichier': getattr(config,'API_KEY_1FICHIER',''), 'alldebrid': getattr(config,'API_KEY_ALLDEBRID',''), 'realdebrid': getattr(config,'API_KEY_REALDEBRID','')}
keys_info = {
'1fichier': getattr(config,'API_KEY_1FICHIER',''),
'alldebrid': getattr(config,'API_KEY_ALLDEBRID',''),
'debridlink': getattr(config,'API_KEY_DEBRIDLINK',''),
'realdebrid': getattr(config,'API_KEY_REALDEBRID','')
}
# SUPPRIMÉ: Vérification clés API obligatoires
# Maintenant on a le mode gratuit en fallback automatique
@@ -1118,6 +1125,10 @@ async def main():
draw_game_list(screen)
if getattr(config, 'joystick', False):
draw_virtual_keyboard(screen)
elif config.menu_state == "platform_search":
draw_global_search_list(screen)
if getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False):
draw_virtual_keyboard(screen)
elif config.menu_state == "download_progress":
draw_progress_screen(screen)
# État download_result supprimé
@@ -1149,6 +1160,9 @@ async def main():
elif config.menu_state == "pause_api_keys_status":
from display import draw_pause_api_keys_status
draw_pause_api_keys_status(screen)
elif config.menu_state == "pause_connection_status":
from display import draw_pause_connection_status
draw_pause_connection_status(screen)
elif config.menu_state == "filter_platforms":
from display import draw_filter_platforms_menu
draw_filter_platforms_menu(screen)

View File

@@ -0,0 +1 @@
donation-identifier=39546f3b2d3f67a664818596d81a5bec; abtest-identifier=fee0e28eb6c8d0de147d19db4303ee84; logged-in-sig=1802098179%201770562179%20AKHN8aF4EsFeR%2FundhgQTu0j27ZdFZXmgyUiqnJvXq%2BwtDGVvapqhKUFhIlI9bXAMYLMHDRJoO76bsqXI662nrIsx58efihNrafdk285r8MAdotWx03usO30baYoNPoMMEaK8iuhtbfTEyfE7oTZwdO7wjxNUTm%2Bbjjm6kmUD3HSQRzPsc0oWrrnd8Wj2x3UiuZeRnBfC60OjJHcnKC2Xv7teS%2BBx3EdKAG1i739MxTzjtEfERWw83bnaV30827qaFhZ%2BDK3%2FwCGOUwtablPA%2B0EeLR9%2BoYeC6x5aaJMZHBMjBowSIEE4QAK9IG9haBsn7%2F1PCweYuLivMIZJeA7mA%3D%3D; logged-in-user=rgsx%40outlook.fr

View File

@@ -7,7 +7,7 @@
"cancel": {
"type": "key",
"key": 27,
"display": "\u00c9chap"
"display": "Esc/Echap"
},
"up": {
"type": "key",

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.

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.

View File

@@ -0,0 +1,69 @@
#!/bin/bash
# BATOCERA SERVICE
# name: Version Clean Service
# description: Clean batocera-version output (hide extra services)
# author: batocera-unofficial-addons
# depends:
# version: 1.0
SERVICE_NAME="versionclean"
TARGET="/usr/bin/batocera-version"
BACKUP="${TARGET}.bak"
case "$1" in
start)
# If we've already backed up, assume it's already "cleaned"
if [ -f "$BACKUP" ]; then
echo "${SERVICE_NAME}: already started (backup exists at ${BACKUP})."
exit 0
fi
echo "${SERVICE_NAME}: backing up original ${TARGET} to ${BACKUP}..."
cp "$TARGET" "$BACKUP"
echo "${SERVICE_NAME}: writing clean version script to ${TARGET}..."
cat << 'EOF' > "$TARGET"
#!/bin/bash
# Clean batocera-version
# - "batocera-version --extra" -> "none"
# - "batocera-version" -> contents of /usr/share/batocera/batocera.version
if test "$1" = "--extra"; then
echo "none"
exit 0
fi
cat /usr/share/batocera/batocera.version
EOF
chmod +x "$TARGET"
echo "${SERVICE_NAME}: clean version applied."
;;
stop)
if [ -f "$BACKUP" ]; then
echo "${SERVICE_NAME}: restoring original ${TARGET} from ${BACKUP}..."
cp "$BACKUP" "$TARGET"
rm "$BACKUP"
echo "${SERVICE_NAME}: restore complete."
else
echo "${SERVICE_NAME}: no backup found, nothing to restore."
fi
;;
status)
if [ -f "$BACKUP" ]; then
echo "${SERVICE_NAME}: CLEAN VERSION ACTIVE (backup present)."
else
echo "${SERVICE_NAME}: ORIGINAL VERSION ACTIVE."
fi
;;
*)
echo "Usage: $0 {start|stop|status}"
exit 1
;;
esac
exit 0

View File

@@ -2,6 +2,18 @@
import os
import logging
import platform
from typing import Optional
from dataclasses import dataclass
@dataclass(slots=True)
class Game:
name: str
url: str
size: str
display_name: str # name withou file extension or platform prefix
regions: Optional[list[str]] = None
is_non_release: Optional[bool] = None
base_name: Optional[str] = None
# Headless mode for CLI: set env RGSX_HEADLESS=1 to avoid pygame and noisy prints
HEADLESS = os.environ.get("RGSX_HEADLESS") == "1"
@@ -14,7 +26,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.5.0.1"
app_version = "2.6.0.1"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 7
@@ -141,6 +153,12 @@ pending_download_is_queue = False # Indique si pending_download doit être ajou
# Indique si un téléchargement est en cours
download_active = False
# Cache status de connexion (menu pause > settings)
connection_status = {}
connection_status_timestamp = 0.0
connection_status_in_progress = False
connection_status_progress = {"done": 0, "total": 0}
# Log directory
# Docker mode: /config/logs (persisted in config volume)
# Traditional mode: /app/RGSX/logs (current behavior)
@@ -179,7 +197,9 @@ DOWNLOADED_GAMES_PATH = os.path.join(SAVE_FOLDER, "downloaded_games.json")
RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json")
API_KEY_1FICHIER_PATH = os.path.join(SAVE_FOLDER, "1FichierAPI.txt")
API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt")
API_KEY_DEBRIDLINK_PATH = os.path.join(SAVE_FOLDER, "DebridLinkAPI.txt")
API_KEY_REALDEBRID_PATH = os.path.join(SAVE_FOLDER, "RealDebridAPI.txt")
ARCHIVE_ORG_COOKIE_PATH = os.path.join(APP_FOLDER, "assets", "ArchiveOrgCookie.txt")
@@ -347,6 +367,7 @@ platforms = [] # Liste des plateformes disponibles
current_platform = 0 # Index de la plateforme actuelle sélectionnée
platform_names = {} # {platform_id: platform_name}
games_count = {} # Dictionnaire comptant le nombre de jeux par plateforme
games_count_log_verbose = False # Log détaillé par fichier (sinon résumé compact)
platform_dicts = [] # Liste des dictionnaires de plateformes
# Filtre plateformes
@@ -356,7 +377,8 @@ filter_platforms_dirty = False # indique si modifications non sauvegardées
filter_platforms_selection = [] # copie de travail des plateformes visibles (bool masque?) structure: list of (name, hidden_bool)
# Affichage des jeux et sélection
games = [] # Liste des jeux pour la plateforme actuelle
games: list[Game] = [] # Liste des jeux pour la plateforme actuelle
fbneo_games = {}
current_game = 0 # Index du jeu actuellement sélectionné
menu_state = "loading" # État actuel de l'interface menu
scroll_offset = 0 # Offset de défilement pour la liste des jeux
@@ -381,10 +403,16 @@ selected_language_index = 0 # Index de la langue sélectionnée dans la liste
# Recherche et filtres
filtered_games = [] # Liste des jeux filtrés par recherche ou filtre
filtered_games: list[Game] = [] # Liste des jeux filtrés par recherche ou filtre
search_mode = False # Indicateur si le mode recherche est actif
search_query = "" # Chaîne de recherche saisie par l'utilisateur
filter_active = False # Indicateur si un filtre est appliqué
global_search_index = [] # Index de recherche global {platform, jeu} construit a l'ouverture
global_search_results = [] # Resultats de la recherche globale inter-plateformes
global_search_query = "" # Texte saisi pour la recherche globale
global_search_selected = 0 # Index du resultat global selectionne
global_search_scroll_offset = 0 # Offset de defilement des resultats globaux
global_search_editing = False # True si le clavier virtuel est actif pour la recherche globale
# Variables pour le filtrage avancé
selected_filter_choice = 0 # Index dans le menu de choix de filtrage (recherche / avancé)
@@ -430,9 +458,11 @@ scraper_game_page_url = "" # URL de la page du jeu sur TheGamesDB
# CLES API / PREMIUM HOSTS
API_KEY_1FICHIER = ""
API_KEY_ALLDEBRID = ""
API_KEY_DEBRIDLINK = ""
API_KEY_REALDEBRID = ""
PREMIUM_HOST_MARKERS = [
"1Fichier",
"Debrid-Link",
]
hide_premium_systems = False # Indicateur pour masquer les systèmes premium

View File

@@ -8,7 +8,7 @@ import datetime
import threading
import logging
import config
from config import REPEAT_DELAY, REPEAT_INTERVAL, REPEAT_ACTION_DEBOUNCE, CONTROLS_CONFIG_PATH
from config import REPEAT_DELAY, REPEAT_INTERVAL, REPEAT_ACTION_DEBOUNCE, CONTROLS_CONFIG_PATH, Game
from display import draw_validation_transition, show_toast
from network import download_rom, download_from_1fichier, is_1fichier_url, request_cancel
from utils import (
@@ -17,9 +17,10 @@ from utils import (
save_music_config, load_api_keys, _get_dest_folder_name,
extract_zip, extract_rar, find_file_with_or_without_extension, toggle_web_service_at_boot, check_web_service_status,
restart_application, generate_support_zip, load_sources,
ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string
ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string,
start_connection_status_check
)
from history import load_history, clear_history, add_to_history, save_history
from history import load_history, clear_history, add_to_history, save_history, scan_roms_for_downloaded_games
from language import _, get_available_languages, set_language
from rgsx_settings import (
get_allow_unknown_extensions, set_display_grid, get_font_family, set_font_family,
@@ -30,6 +31,8 @@ from rgsx_settings import (
from accessibility import save_accessibility_settings
from scraper import get_game_metadata, download_image_to_surface
from pathlib import Path
logger = logging.getLogger(__name__)
# Extensions d'archives pour lesquelles on ignore l'avertissement d'extension non supportée
@@ -54,6 +57,7 @@ VALID_STATES = [
"pause_games_menu", # sous-menu Games (source mode, update/redownload cache)
"pause_settings_menu", # sous-menu Settings (music on/off, symlink toggle, api keys status)
"pause_api_keys_status", # sous-menu API Keys (affichage statut des clés)
"pause_connection_status", # sous-menu Connection status (statut accès sites)
# Nouveaux menus historique
"history_game_options", # menu options pour un jeu de l'historique
"history_show_folder", # afficher le dossier de téléchargement
@@ -68,12 +72,15 @@ VALID_STATES = [
"filter_search", # recherche par nom (existant, mais renommé)
"filter_advanced", # filtrage avancé par région, etc.
"filter_priority_config", # configuration priorité régions pour one-rom-per-game
"platform_search", # recherche globale inter-plateformes
"platform_folder_config", # configuration du dossier personnalisé pour une plateforme
"folder_browser", # navigateur de dossiers intégré
"folder_browser_new_folder", # création d'un nouveau dossier
]
def validate_menu_state(state):
if not state:
return "platform"
if state not in VALID_STATES:
logger.debug(f"État invalide {state}, retour à platform")
return "platform"
@@ -103,6 +110,18 @@ def load_controls_config(path=CONTROLS_CONFIG_PATH):
"delete": {"type": "key", "key": pygame.K_BACKSPACE},
"space": {"type": "key", "key": pygame.K_SPACE}
}
def _is_keyboard_only_config(data):
if not isinstance(data, dict) or not data:
return False
for action_name, mapping in data.items():
if action_name == "device":
continue
if not isinstance(mapping, dict):
return False
if mapping.get("type") != "key":
return False
return True
try:
# 1) Fichier utilisateur
@@ -111,21 +130,25 @@ def load_controls_config(path=CONTROLS_CONFIG_PATH):
data = json.load(f)
if not isinstance(data, dict):
data = {}
keyboard_mode = (not getattr(config, 'joystick', False)) or getattr(config, 'keyboard', False)
if keyboard_mode and not _is_keyboard_only_config(data):
logging.getLogger(__name__).info("Configuration utilisateur manette ignorée en mode clavier")
else:
# Compléter les actions manquantes, et sauve seulement si le fichier utilisateur existe
changed = False
for k, v in default_config.items():
if k not in data:
data[k] = v
changed = True
if changed:
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logging.getLogger(__name__).debug(f"controls.json complété avec les actions manquantes: {path}")
except Exception as e:
logging.getLogger(__name__).warning(f"Impossible d'écrire les actions manquantes dans {path}: {e}")
return data
changed = False
for k, v in default_config.items():
if k not in data:
data[k] = v
changed = True
if changed:
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
logging.getLogger(__name__).debug(f"controls.json complété avec les actions manquantes: {path}")
except Exception as e:
logging.getLogger(__name__).warning(f"Impossible d'écrire les actions manquantes dans {path}: {e}")
return data
# 2) Préréglages sans copie si aucun fichier utilisateur
try:
@@ -254,6 +277,67 @@ def is_input_matched(event, action_name):
return False
def is_global_search_input_matched(event, action_name):
"""Fallback robuste pour la recherche globale, independant du preset courant."""
if is_input_matched(event, action_name):
return True
if event.type == pygame.KEYDOWN:
keyboard_fallback = {
"up": pygame.K_UP,
"down": pygame.K_DOWN,
"left": pygame.K_LEFT,
"right": pygame.K_RIGHT,
"confirm": pygame.K_RETURN,
"cancel": pygame.K_ESCAPE,
"filter": pygame.K_f,
"delete": pygame.K_BACKSPACE,
"space": pygame.K_SPACE,
"page_up": pygame.K_PAGEUP,
"page_down": pygame.K_PAGEDOWN,
}
if action_name in keyboard_fallback and event.key == keyboard_fallback[action_name]:
return True
if event.type == pygame.JOYBUTTONDOWN:
common_button_fallback = {
"confirm": {0},
"cancel": {1},
"filter": {6},
"start": {7},
"delete": {2},
"space": {5},
"page_up": {4},
"page_down": {5},
}
if action_name in common_button_fallback and event.button in common_button_fallback[action_name]:
return True
if event.type == pygame.JOYHATMOTION:
hat_fallback = {
"up": (0, 1),
"down": (0, -1),
"left": (-1, 0),
"right": (1, 0),
}
if action_name in hat_fallback and event.value == hat_fallback[action_name]:
return True
if event.type == pygame.JOYAXISMOTION:
axis_fallback = {
"left": (0, -1),
"right": (0, 1),
"up": (1, -1),
"down": (1, 1),
}
if action_name in axis_fallback:
axis_id, direction = axis_fallback[action_name]
if event.axis == axis_id and abs(event.value) > 0.5 and (1 if event.value > 0 else -1) == direction:
return True
return False
def _launch_next_queued_download():
"""Lance le prochain téléchargement de la queue si aucun n'est actif.
Gère la liaison entre le système Desktop et le système de download_rom/download_from_1fichier.
@@ -322,6 +406,231 @@ def _launch_next_queued_download():
if config.download_queue:
_launch_next_queued_download()
def filter_games_by_search_query() -> list[Game]:
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
filtered_games = []
for game in base_games:
game_name = game.display_name
if config.search_query.lower() in game_name.lower():
filtered_games.append(game)
return filtered_games
GLOBAL_SEARCH_KEYBOARD_LAYOUT = [
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
['A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
['Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M'],
['W', 'X', 'C', 'V', 'B', 'N']
]
def _get_platform_id(platform) -> str:
return platform.get("name") if isinstance(platform, dict) else str(platform)
def _get_platform_label(platform_id: str) -> str:
return config.platform_names.get(platform_id, platform_id)
def build_global_search_index() -> list[dict]:
indexed_games = []
for platform_index, platform in enumerate(config.platforms):
platform_id = _get_platform_id(platform)
platform_label = _get_platform_label(platform_id)
for game in load_games(platform_id):
indexed_games.append({
"platform_id": platform_id,
"platform_label": platform_label,
"platform_index": platform_index,
"game_name": game.name,
"display_name": game.display_name or Path(game.name).stem,
"url": game.url,
"size": game.size,
})
indexed_games.sort(key=lambda item: (item["platform_label"].lower(), item["display_name"].lower()))
return indexed_games
def refresh_global_search_results(reset_selection: bool = True) -> None:
query = (config.global_search_query or "").strip().lower()
if not query:
config.global_search_results = []
else:
config.global_search_results = [
item for item in config.global_search_index
if query in item["display_name"].lower()
]
if reset_selection:
config.global_search_selected = 0
config.global_search_scroll_offset = 0
else:
max_index = max(0, len(config.global_search_results) - 1)
config.global_search_selected = max(0, min(config.global_search_selected, max_index))
config.global_search_scroll_offset = max(0, min(config.global_search_scroll_offset, config.global_search_selected))
def enter_global_search() -> None:
config.global_search_index = build_global_search_index()
config.global_search_query = ""
config.global_search_results = []
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = bool(getattr(config, 'joystick', False))
config.selected_key = (0, 0)
config.previous_menu_state = "platform"
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug("Entree en recherche globale inter-plateformes")
def exit_global_search() -> None:
config.global_search_query = ""
config.global_search_results = []
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.selected_key = (0, 0)
config.menu_state = "platform"
config.needs_redraw = True
def open_global_search_result(screen) -> None:
if not config.global_search_results:
return
result = config.global_search_results[config.global_search_selected]
platform_index = result.get("platform_index", 0)
if platform_index < 0 or platform_index >= len(config.platforms):
return
config.current_platform = platform_index
config.selected_platform = platform_index
config.current_page = platform_index // max(1, config.GRID_COLS * config.GRID_ROWS)
platform_id = result["platform_id"]
config.games = load_games(platform_id)
config.filtered_games = config.games
config.search_mode = False
config.search_query = ""
config.filter_active = False
target_name = result["game_name"]
target_display_name = result["display_name"]
target_index = 0
for index, game in enumerate(config.games):
if game.name == target_name:
target_index = index
break
if game.display_name == target_display_name:
target_index = index
config.current_game = target_index
config.scroll_offset = 0
config.global_search_editing = False
from rgsx_settings import get_light_mode
if not get_light_mode():
draw_validation_transition(screen, config.current_platform)
config.menu_state = "game"
config.needs_redraw = True
logger.debug(f"Ouverture du resultat global {target_display_name} sur {platform_id}")
def trigger_global_search_download(queue_only: bool = False) -> None:
if not config.global_search_results:
return
result = config.global_search_results[config.global_search_selected]
url = result.get("url")
platform = result.get("platform_id")
game_name = result.get("game_name")
display_name = result.get("display_name") or game_name
if not url or not platform or not game_name:
logger.error(f"Resultat de recherche globale invalide: {result}")
return
pending_download = check_extension_before_download(url, platform, game_name)
if not pending_download:
logger.error(f"config.pending_download est None pour {game_name}")
config.needs_redraw = True
return
is_supported = is_extension_supported(
sanitize_filename(game_name),
platform,
load_extensions_json()
)
zip_ok = bool(pending_download[3])
allow_unknown = get_allow_unknown_extensions()
if (not is_supported and not zip_ok) and not allow_unknown:
config.pending_download = pending_download
config.pending_download_is_queue = queue_only
config.previous_menu_state = config.menu_state
config.menu_state = "extension_warning"
config.extension_confirm_selection = 0
config.needs_redraw = True
logger.debug(f"Extension non supportee, passage a extension_warning pour {game_name}")
return
if queue_only:
task_id = str(pygame.time.get_ticks())
queue_item = {
'url': url,
'platform': platform,
'game_name': game_name,
'is_zip_non_supported': pending_download[3],
'is_1fichier': is_1fichier_url(url),
'task_id': task_id,
'status': 'Queued'
}
config.download_queue.append(queue_item)
config.history.append({
'platform': platform,
'game_name': game_name,
'status': 'Queued',
'url': url,
'progress': 0,
'message': _("download_queued"),
'timestamp': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'downloaded_size': 0,
'total_size': 0,
'task_id': task_id
})
save_history(config.history)
show_toast(f"{display_name}\n{_('download_queued')}")
config.needs_redraw = True
logger.debug(f"{game_name} ajoute a la file d'attente depuis la recherche globale. Queue size: {len(config.download_queue)}")
if not config.download_active and config.download_queue:
_launch_next_queued_download()
return
if is_1fichier_url(url):
ensure_download_provider_keys(False)
if missing_all_provider_keys():
logger.warning("Aucune cle API - Mode gratuit 1fichier sera utilise (attente requise)")
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, pending_download[3], task_id))
else:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_rom(url, platform, game_name, pending_download[3], task_id))
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {display_name}")
config.needs_redraw = True
logger.debug(f"Telechargement demarre depuis la recherche globale: {game_name} pour {platform}, task_id={task_id}")
...
def handle_controls(event, sources, joystick, screen):
"""Gère un événement clavier/joystick/souris et la répétition automatique.
Retourne 'quit', 'download', 'redownload', ou None."""
@@ -484,6 +793,8 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "history"
config.needs_redraw = True
logger.debug("Ouverture history depuis platform")
elif is_input_matched(event, "filter"):
enter_global_search()
elif is_input_matched(event, "confirm"):
# Démarrer le chronomètre pour l'appui long - ne pas exécuter immédiatement
# L'action sera exécutée au relâchement si appui court, ou config dossier si appui long
@@ -503,9 +814,120 @@ def handle_controls(event, sources, joystick, screen):
config.confirm_selection = 0
config.needs_redraw = True
elif config.menu_state == "platform_search":
if getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False):
row, col = config.selected_key
max_row = len(GLOBAL_SEARCH_KEYBOARD_LAYOUT) - 1
max_col = len(GLOBAL_SEARCH_KEYBOARD_LAYOUT[row]) - 1
if is_global_search_input_matched(event, "up"):
if row == 0:
row = max_row + (1 if col <= 5 else 0)
if row > 0:
config.selected_key = (row - 1, min(col, len(GLOBAL_SEARCH_KEYBOARD_LAYOUT[row - 1]) - 1))
config.repeat_action = "up"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
config.repeat_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 else event.value
config.needs_redraw = True
elif is_global_search_input_matched(event, "down"):
if (col <= 5 and row == max_row) or (col > 5 and row == max_row - 1):
row = -1
if row < max_row:
config.selected_key = (row + 1, min(col, len(GLOBAL_SEARCH_KEYBOARD_LAYOUT[row + 1]) - 1))
config.repeat_action = "down"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
config.repeat_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 else event.value
config.needs_redraw = True
elif is_global_search_input_matched(event, "left"):
if col == 0:
col = max_col + 1
if col > 0:
config.selected_key = (row, col - 1)
config.repeat_action = "left"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
config.repeat_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 else event.value
config.needs_redraw = True
elif is_global_search_input_matched(event, "right"):
if col == max_col:
col = -1
if col < max_col:
config.selected_key = (row, col + 1)
config.repeat_action = "right"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
config.repeat_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 else event.value
config.needs_redraw = True
elif is_global_search_input_matched(event, "confirm"):
config.global_search_query += GLOBAL_SEARCH_KEYBOARD_LAYOUT[row][col]
refresh_global_search_results()
logger.debug(f"Recherche globale mise a jour: query={config.global_search_query}, resultats={len(config.global_search_results)}")
config.needs_redraw = True
elif is_global_search_input_matched(event, "delete"):
if config.global_search_query:
config.global_search_query = config.global_search_query[:-1]
refresh_global_search_results()
logger.debug(f"Recherche globale suppression: query={config.global_search_query}, resultats={len(config.global_search_results)}")
config.needs_redraw = True
elif is_global_search_input_matched(event, "space"):
config.global_search_query += " "
refresh_global_search_results()
logger.debug(f"Recherche globale espace: query={config.global_search_query}, resultats={len(config.global_search_results)}")
config.needs_redraw = True
elif is_global_search_input_matched(event, "filter"):
config.global_search_editing = False
config.needs_redraw = True
elif is_global_search_input_matched(event, "cancel"):
exit_global_search()
else:
results = config.global_search_results
if is_global_search_input_matched(event, "up"):
if config.global_search_selected > 0:
config.global_search_selected -= 1
update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else event.value)
config.needs_redraw = True
elif is_global_search_input_matched(event, "down"):
if config.global_search_selected < len(results) - 1:
config.global_search_selected += 1
update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else event.button if event.type == pygame.JOYBUTTONDOWN else (event.axis, event.value) if event.type == pygame.JOYAXISMOTION else event.value)
config.needs_redraw = True
elif is_global_search_input_matched(event, "page_up") or is_global_search_input_matched(event, "left"):
config.global_search_selected = max(0, config.global_search_selected - 10)
config.needs_redraw = True
elif is_global_search_input_matched(event, "page_down") or is_global_search_input_matched(event, "right"):
config.global_search_selected = min(max(0, len(results) - 1), config.global_search_selected + 10)
config.needs_redraw = True
elif is_global_search_input_matched(event, "confirm"):
trigger_global_search_download(queue_only=False)
elif is_global_search_input_matched(event, "clear_history"):
trigger_global_search_download(queue_only=True)
elif is_global_search_input_matched(event, "filter") and getattr(config, 'joystick', False):
config.global_search_editing = True
config.needs_redraw = True
elif is_global_search_input_matched(event, "cancel"):
exit_global_search()
elif not getattr(config, 'joystick', False) and event.type == pygame.KEYDOWN:
if event.unicode.isalnum() or event.unicode == ' ':
config.global_search_query += event.unicode
refresh_global_search_results()
logger.debug(f"Recherche globale clavier: query={config.global_search_query}, resultats={len(config.global_search_results)}")
config.needs_redraw = True
elif is_global_search_input_matched(event, "delete"):
if config.global_search_query:
config.global_search_query = config.global_search_query[:-1]
refresh_global_search_results()
logger.debug(f"Recherche globale clavier suppression: query={config.global_search_query}, resultats={len(config.global_search_results)}")
config.needs_redraw = True
if config.global_search_results:
config.global_search_selected = max(0, min(config.global_search_selected, len(config.global_search_results) - 1))
else:
config.global_search_selected = 0
# Jeux
elif config.menu_state == "game":
games = config.filtered_games if config.filter_active or config.search_mode else config.games
games: list[Game] = config.filtered_games if config.filter_active or config.search_mode else config.games
if config.search_mode and getattr(config, 'joystick', False):
keyboard_layout = [
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
@@ -559,10 +981,7 @@ def handle_controls(event, sources, joystick, screen):
elif is_input_matched(event, "confirm"):
config.search_query += keyboard_layout[row][col]
# Appliquer d'abord les filtres avancés si actifs, puis le filtre par nom
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = [game for game in base_games if config.search_query.lower() in game[0].lower()]
config.filtered_games = filter_games_by_search_query()
config.current_game = 0
config.scroll_offset = 0
config.needs_redraw = True
@@ -571,10 +990,7 @@ def handle_controls(event, sources, joystick, screen):
if config.search_query:
config.search_query = config.search_query[:-1]
# Appliquer d'abord les filtres avancés si actifs, puis le filtre par nom
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = [game for game in base_games if config.search_query.lower() in game[0].lower()]
config.filtered_games = filter_games_by_search_query()
config.current_game = 0
config.scroll_offset = 0
config.needs_redraw = True
@@ -582,10 +998,7 @@ def handle_controls(event, sources, joystick, screen):
elif is_input_matched(event, "space"):
config.search_query += " "
# Appliquer d'abord les filtres avancés si actifs, puis le filtre par nom
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = [game for game in base_games if config.search_query.lower() in game[0].lower()]
config.filtered_games = filter_games_by_search_query()
config.current_game = 0
config.scroll_offset = 0
config.needs_redraw = True
@@ -638,10 +1051,7 @@ def handle_controls(event, sources, joystick, screen):
if event.unicode.isalnum() or event.unicode == ' ':
config.search_query += event.unicode
# Appliquer d'abord les filtres avancés si actifs, puis le filtre par nom
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = [game for game in base_games if config.search_query.lower() in game[0].lower()]
config.filtered_games = filter_games_by_search_query()
config.current_game = 0
config.scroll_offset = 0
config.needs_redraw = True
@@ -651,10 +1061,7 @@ def handle_controls(event, sources, joystick, screen):
if config.search_query:
config.search_query = config.search_query[:-1]
# Appliquer d'abord les filtres avancés si actifs, puis le filtre par nom
base_games = config.games
if config.game_filter_obj and config.game_filter_obj.is_active():
base_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = [game for game in base_games if config.search_query.lower() in game[0].lower()]
config.filtered_games = filter_games_by_search_query()
config.current_game = 0
config.scroll_offset = 0
config.needs_redraw = True
@@ -714,6 +1121,7 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug("Ouverture du menu de filtrage")
elif is_input_matched(event, "history"):
config.history_origin = "game"
config.menu_state = "history"
config.needs_redraw = True
logger.debug("Ouverture history depuis game")
@@ -722,8 +1130,8 @@ def handle_controls(event, sources, joystick, screen):
if games:
idx = config.current_game
game = games[idx]
url = game[1]
game_name = game[0]
url = game.url
game_name = game.name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
pending_download = check_extension_before_download(url, platform, game_name)
@@ -2001,7 +2409,7 @@ def handle_controls(event, sources, joystick, screen):
# Sous-menu Games
elif config.menu_state == "pause_games_menu":
sel = getattr(config, 'pause_games_selection', 0)
total = 7 # update cache, history, source, unsupported, hide premium, filter, back
total = 8 # update cache, scan roms, history, source, unsupported, hide premium, filter, back
if is_input_matched(event, "up"):
config.pause_games_selection = (sel - 1) % total
config.needs_redraw = True
@@ -2014,14 +2422,25 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "reload_games_data"
config.redownload_confirm_selection = 0
config.needs_redraw = True
elif sel == 1 and is_input_matched(event, "confirm"): # history
elif sel == 1 and is_input_matched(event, "confirm"): # scan local roms
try:
added_games, scanned_platforms = scan_roms_for_downloaded_games()
config.popup_message = _("popup_scan_owned_roms_done").format(added_games, scanned_platforms) if _ else f"ROM scan complete: {added_games} games added across {scanned_platforms} platforms"
config.popup_timer = 4000
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur scan ROMs locaux: {e}")
config.popup_message = _("popup_scan_owned_roms_error").format(str(e)) if _ else f"ROM scan error: {e}"
config.popup_timer = 5000
config.needs_redraw = True
elif sel == 2 and is_input_matched(event, "confirm"): # history
config.history = load_history()
config.current_history_item = 0
config.history_scroll_offset = 0
config.previous_menu_state = "pause_games_menu"
config.menu_state = "history"
config.needs_redraw = True
elif sel == 2 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # source mode
elif sel == 3 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # source mode
try:
current_mode = get_sources_mode()
new_mode = set_sources_mode('custom' if current_mode == 'rgsx' else 'rgsx')
@@ -2036,7 +2455,7 @@ def handle_controls(event, sources, joystick, screen):
logger.info(f"Changement du mode des sources vers {new_mode}")
except Exception as e:
logger.error(f"Erreur changement mode sources: {e}")
elif sel == 3 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # unsupported toggle
elif sel == 4 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # unsupported toggle
try:
current = get_show_unsupported_platforms()
new_val = set_show_unsupported_platforms(not current)
@@ -2046,7 +2465,7 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle unsupported: {e}")
elif sel == 4 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # hide premium
elif sel == 5 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # hide premium
try:
cur = get_hide_premium_systems()
new_val = set_hide_premium_systems(not cur)
@@ -2055,13 +2474,13 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle hide_premium_systems: {e}")
elif sel == 5 and is_input_matched(event, "confirm"): # filter platforms
elif sel == 6 and is_input_matched(event, "confirm"): # filter platforms
config.filter_return_to = "pause_games_menu"
config.menu_state = "filter_platforms"
config.selected_filter_index = 0
config.filter_platforms_scroll_offset = 0
config.needs_redraw = True
elif sel == 6 and is_input_matched(event, "confirm"): # back
elif sel == 7 and is_input_matched(event, "confirm"): # back
config.menu_state = "pause_menu"
config.last_state_change_time = pygame.time.get_ticks()
config.needs_redraw = True
@@ -2074,21 +2493,23 @@ def handle_controls(event, sources, joystick, screen):
elif config.menu_state == "pause_settings_menu":
sel = getattr(config, 'pause_settings_selection', 0)
# Calculer le nombre total d'options selon le système
# Liste des options : music, symlink, auto_extract, roms_folder, [web_service], [custom_dns], api keys, back
total = 6 # music, symlink, auto_extract, roms_folder, api keys, back (Windows)
# Liste des options : music, symlink, auto_extract, roms_folder, [web_service], [custom_dns], api keys, connection_status, back
total = 7 # music, symlink, auto_extract, roms_folder, api keys, connection_status, back (Windows)
auto_extract_index = 2
roms_folder_index = 3
web_service_index = -1
custom_dns_index = -1
api_keys_index = 4
back_index = 5
connection_status_index = 5
back_index = 6
if config.OPERATING_SYSTEM == "Linux":
total = 8 # music, symlink, auto_extract, roms_folder, web_service, custom_dns, api keys, back
total = 9 # music, symlink, auto_extract, roms_folder, web_service, custom_dns, api keys, connection_status, back
web_service_index = 4
custom_dns_index = 5
api_keys_index = 6
back_index = 7
connection_status_index = 7
back_index = 8
if is_input_matched(event, "up"):
config.pause_settings_selection = (sel - 1) % total
@@ -2208,6 +2629,11 @@ def handle_controls(event, sources, joystick, screen):
elif sel == api_keys_index and is_input_matched(event, "confirm"):
config.menu_state = "pause_api_keys_status"
config.needs_redraw = True
# Option Connection Status
elif sel == connection_status_index and is_input_matched(event, "confirm"):
start_connection_status_check(force=True)
config.menu_state = "pause_connection_status"
config.needs_redraw = True
# Option Back (dernière option)
elif sel == back_index and is_input_matched(event, "confirm"):
config.menu_state = "pause_menu"
@@ -2224,6 +2650,12 @@ def handle_controls(event, sources, joystick, screen):
config.last_state_change_time = pygame.time.get_ticks()
config.needs_redraw = True
elif config.menu_state == "pause_connection_status":
if is_input_matched(event, "cancel") or is_input_matched(event, "confirm") or is_input_matched(event, "start"):
config.menu_state = "pause_settings_menu"
config.last_state_change_time = pygame.time.get_ticks()
config.needs_redraw = True
# Aide contrôles
elif config.menu_state == "controls_help":
if is_input_matched(event, "cancel"):
@@ -3167,8 +3599,8 @@ def handle_controls(event, sources, joystick, screen):
# Déclencher le téléchargement normal
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
url = games[config.current_game][1]
game_name = games[config.current_game][0]
url = games[config.current_game].url
game_name = games[config.current_game].name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")
@@ -3299,8 +3731,8 @@ def handle_controls(event, sources, joystick, screen):
# Déclencher le téléchargement normal (même code que pour KEYUP)
games = config.filtered_games if config.filter_active or config.search_mode else config.games
if games:
url = games[config.current_game][1]
game_name = games[config.current_game][0]
url = games[config.current_game].url
game_name = games[config.current_game].name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")

View File

@@ -69,7 +69,7 @@ SDL_TO_PYGAME_KEY = {
# Noms lisibles pour les touches clavier
KEY_NAMES = {
pygame.K_RETURN: "Enter",
pygame.K_ESCAPE: "Échap",
pygame.K_ESCAPE: "Esc/Echap",
pygame.K_SPACE: "Espace",
pygame.K_UP: "",
pygame.K_DOWN: "",
@@ -87,7 +87,7 @@ KEY_NAMES = {
pygame.K_RMETA: "RMeta",
pygame.K_CAPSLOCK: "Verr Maj",
pygame.K_NUMLOCK: "Verr Num",
pygame.K_SCROLLOCK: "Verr Déf",
pygame.K_SCROLLOCK: "Verr Def",
pygame.K_a: "A",
pygame.K_b: "B",
pygame.K_c: "C",
@@ -158,7 +158,7 @@ KEY_NAMES = {
pygame.K_F15: "F15",
pygame.K_INSERT: "Inser",
pygame.K_DELETE: "Suppr",
pygame.K_HOME: "Début",
pygame.K_HOME: "Debut",
pygame.K_END: "Fin",
pygame.K_PAGEUP: "Page+",
pygame.K_PAGEDOWN: "Page-",

View File

@@ -5,10 +5,12 @@ import os
import io
import platform
import random
from datetime import datetime
import config
from utils import (truncate_text_middle, wrap_text, load_system_image, truncate_text_end,
check_web_service_status, check_custom_dns_status, load_api_keys,
_get_dest_folder_name, find_file_with_or_without_extension)
_get_dest_folder_name, find_file_with_or_without_extension,
get_connection_status_targets, get_connection_status_snapshot)
import logging
import math
from history import load_history, is_game_downloaded
@@ -19,6 +21,11 @@ from rgsx_settings import (load_rgsx_settings, get_light_mode, get_show_unsuppor
get_hide_premium_systems, get_symlink_option)
from game_filters import GameFilters
import json
from pathlib import Path
from typing import Dict, Any
import urllib.request
logger = logging.getLogger(__name__)
OVERLAY = None # Initialisé dans init_display()
@@ -659,12 +666,32 @@ def draw_error_screen(screen):
# Récupérer les noms d'affichage des contrôles
def get_control_display(action, default):
"""Récupère le nom d'affichage d'une action depuis controls_config."""
keyboard_defaults = {
"confirm": "Enter",
"cancel": "Esc/Echap",
"left": "",
"right": "",
"up": "",
"down": "",
"start": "AltGR",
"clear_history": "X",
"history": "H",
"page_up": "Page+",
"page_down": "Page-",
"filter": "F",
"delete": "Backspace",
"space": "Espace",
}
keyboard_default = keyboard_defaults.get(action)
if not config.controls_config:
logger.warning(f"controls_config vide pour l'action {action}, utilisation de la valeur par défaut")
return default
return keyboard_default or default
control_config = config.controls_config.get(action, {})
control_type = control_config.get('type', '')
if getattr(config, 'keyboard', False) and control_type != 'key' and keyboard_default:
return keyboard_default
# Si un libellé personnalisé est défini dans controls.json, on le privilégie
custom_label = control_config.get('display')
@@ -676,7 +703,7 @@ def get_control_display(action, default):
key_code = control_config.get('key')
key_names = {
pygame.K_RETURN: "Enter",
pygame.K_ESCAPE: "Échap",
pygame.K_ESCAPE: "Esc/Echap",
pygame.K_SPACE: "Espace",
pygame.K_UP: "",
pygame.K_DOWN: "",
@@ -694,7 +721,7 @@ def get_control_display(action, default):
pygame.K_RMETA: "RMeta",
pygame.K_CAPSLOCK: "Verr Maj",
pygame.K_NUMLOCK: "Verr Num",
pygame.K_SCROLLOCK: "Verr Déf",
pygame.K_SCROLLOCK: "Verr Def",
pygame.K_a: "A",
pygame.K_b: "B",
pygame.K_c: "C",
@@ -765,7 +792,7 @@ def get_control_display(action, default):
pygame.K_F15: "F15",
pygame.K_INSERT: "Inser",
pygame.K_DELETE: "Suppr",
pygame.K_HOME: "Début",
pygame.K_HOME: "Debut",
pygame.K_END: "Fin",
pygame.K_PAGEUP: "Page+",
pygame.K_PAGEDOWN: "Page-",
@@ -822,6 +849,57 @@ def get_control_display(action, default):
# Cache pour les images des plateformes
platform_images_cache = {}
def draw_header_badge(screen, lines, badge_x, badge_y, light_mode=False):
"""Affiche une cartouche compacte de texte dans l'en-tete."""
header_font = config.tiny_font
text_surfaces = [header_font.render(line, True, THEME_COLORS["text"]) for line in lines if line]
if not text_surfaces:
return
content_width = max((surface.get_width() for surface in text_surfaces), default=0)
content_height = sum(surface.get_height() for surface in text_surfaces) + max(0, len(text_surfaces) - 1) * 4
padding_x = 12
padding_y = 8
badge_width = content_width + padding_x * 2
badge_height = content_height + padding_y * 2
if light_mode:
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (badge_x, badge_y, badge_width, badge_height), border_radius=12)
else:
shadow = pygame.Surface((badge_width + 8, badge_height + 8), pygame.SRCALPHA)
pygame.draw.rect(shadow, (0, 0, 0, 110), (4, 4, badge_width, badge_height), border_radius=12)
screen.blit(shadow, (badge_x - 4, badge_y - 4))
badge_surface = pygame.Surface((badge_width, badge_height), pygame.SRCALPHA)
pygame.draw.rect(badge_surface, THEME_COLORS["button_idle"], (0, 0, badge_width, badge_height), border_radius=12)
highlight = pygame.Surface((badge_width - 6, max(10, badge_height // 3)), pygame.SRCALPHA)
highlight.fill((255, 255, 255, 18))
badge_surface.blit(highlight, (3, 3))
screen.blit(badge_surface, (badge_x, badge_y))
pygame.draw.rect(screen, THEME_COLORS["border"], (badge_x, badge_y, badge_width, badge_height), 2, border_radius=12)
current_y = badge_y + padding_y
for surface in text_surfaces:
line_x = badge_x + (badge_width - surface.get_width()) // 2
screen.blit(surface, (line_x, current_y))
current_y += surface.get_height() + 4
def draw_platform_header_info(screen, light_mode=False):
"""Affiche version et controleur connecte dans un cartouche en haut a droite."""
lines = [f"v{config.app_version}"]
device_name = (getattr(config, 'controller_device_name', '') or '').strip()
if device_name:
lines.append(truncate_text_end(device_name, config.tiny_font, int(config.screen_width * 0.24)))
badge_width = max(config.tiny_font.size(line)[0] for line in lines) + 24
badge_x = config.screen_width - badge_width - 14
badge_y = 10
draw_header_badge(screen, lines, badge_x, badge_y, light_mode)
# Grille des systèmes 3x3
def draw_platform_grid(screen):
"""Affiche la grille des plateformes avec un style moderne et fluide."""
@@ -952,11 +1030,9 @@ def draw_platform_grid(screen):
total_pages = (len(visible_platforms) + systems_per_page - 1) // systems_per_page
if total_pages > 1:
page_indicator_text = _("platform_page").format(config.current_page + 1, total_pages)
page_indicator = config.small_font.render(page_indicator_text, True, THEME_COLORS["text"])
# Position en haut à gauche
page_x = 10
page_y = 10
screen.blit(page_indicator, (page_x, page_y))
draw_header_badge(screen, [page_indicator_text], 14, 10, light_mode)
draw_platform_header_info(screen, light_mode)
# Calculer une seule fois la pulsation pour les éléments sélectionnés (réduite)
if not light_mode:
@@ -1152,12 +1228,75 @@ def draw_platform_grid(screen):
for key in keys_to_remove:
del platform_images_cache[key]
FBNEO_GAME_LIST = "fbneo_gamelist.txt"
def download_fbneo_list(path_to_save: str) -> None:
url = "https://raw.githubusercontent.com/libretro/FBNeo/master/gamelist.txt"
path = Path(path_to_save)
if not path.exists():
logger.debug("Downloading fbneo gamelist.txt from github ...")
urllib.request.urlretrieve(url, path)
logger.debug("Download finished: %s", path)
...
def parse_fbneo_list(path: str) -> Dict[str, Any]:
games : Dict[str, Any] = {}
headers = None
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.rstrip()
if line.startswith("+"):
continue
if "|" not in line:
continue
parts = [p.strip() for p in line.split("|")[1:-1]]
if headers is None:
headers = parts
continue
row = dict(zip(headers, parts))
name = row["name"]
games[name] = row
return games
# Liste des jeux
def draw_game_list(screen):
"""Affiche la liste des jeux avec un style moderne."""
#logger.debug(f"[DRAW_GAME_LIST] Called - platform={config.current_platform}, search_mode={config.search_mode}, filter_active={config.filter_active}")
platform = config.platforms[config.current_platform]
platform_name = config.platform_names.get(platform, platform)
fbneo_selected = platform_name == 'Final Burn Neo'
if fbneo_selected:
fbneo_game_list_path = os.path.join(config.SAVE_FOLDER, FBNEO_GAME_LIST)
if not config.fbneo_games:
download_fbneo_list(fbneo_game_list_path) # download the fbneo game list if necessary - 10 MB file
config.fbneo_games = parse_fbneo_list(fbneo_game_list_path)
for game in config.games:
clean_name = game.display_name
if clean_name in config.fbneo_games:
fbneo_game = config.fbneo_games[clean_name]
full_name = fbneo_game["full name"]
if game.display_name != full_name:
game.display_name = full_name
game.regions = None
game.is_non_release = None
game.base_name = None
...
if config.game_filter_obj and config.game_filter_obj.is_active() and not config.search_query:
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
games = config.filtered_games if config.filter_active or config.search_mode else config.games
game_count = len(games)
#logger.debug(f"[DRAW_GAME_LIST] Games count={game_count}, current_game={config.current_game}, filtered_games={len(config.filtered_games) if config.filtered_games else 0}, config.games={len(config.games) if config.games else 0}")
@@ -1281,23 +1420,30 @@ def draw_game_list(screen):
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
# Largeur colonne taille (15%) mini 120px, reste pour nom
# Largeur colonnes nom / ext / taille
ext_col_width = max(90, int(rect_width * 0.08))
size_col_width = max(120, int(rect_width * 0.15))
name_col_width = rect_width - 40 - size_col_width # padding horizontal 40
name_col_width = rect_width - 40 - ext_col_width - size_col_width
# ---- En-tête ----
header_name = _("game_header_name")
header_ext = _("game_header_ext")
header_size = _("game_header_size")
header_y_center = rect_y + margin_top_bottom + header_height // 2
# Nom aligné gauche
header_name_surface = config.small_font.render(header_name, True, THEME_COLORS["text"])
header_name_rect = header_name_surface.get_rect()
header_name_rect.midleft = (rect_x + 20, header_y_center)
# Extension centree
header_ext_surface = config.small_font.render(header_ext, True, THEME_COLORS["text"])
header_ext_rect = header_ext_surface.get_rect()
header_ext_rect.center = (rect_x + rect_width - 20 - size_col_width - ext_col_width // 2, header_y_center)
# Taille alignée droite
header_size_surface = config.small_font.render(header_size, True, THEME_COLORS["text"])
header_size_rect = header_size_surface.get_rect()
header_size_rect.midright = (rect_x + rect_width - 20, header_y_center)
screen.blit(header_name_surface, header_name_rect)
screen.blit(header_ext_surface, header_ext_rect)
screen.blit(header_size_surface, header_size_rect)
# Ligne de séparation sous l'en-tête
separator_y = rect_y + margin_top_bottom + header_height
@@ -1308,16 +1454,13 @@ def draw_game_list(screen):
for i in range(config.scroll_offset, min(config.scroll_offset + items_per_page, len(games))):
item = games[i]
if isinstance(item, (list, tuple)) and item:
game_name = item[0]
size_val = item[2] if len(item) > 2 else None
else:
game_name = str(item)
size_val = None
# Vérifier si le jeu est déjà téléchargé
is_downloaded = is_game_downloaded(platform_name, game_name)
game_name = item.display_name
size_val = item.size
# Vérifier si le jeu est déjà téléchargé en comparant le nom réel sans extension
is_downloaded = is_game_downloaded(platform_name, item.name)
ext_text = get_display_extension(item.name)
size_text = size_val if (isinstance(size_val, str) and size_val.strip()) else "N/A"
color = THEME_COLORS["fond_lignes"] if i == config.current_game else THEME_COLORS["text"]
@@ -1328,11 +1471,14 @@ def draw_game_list(screen):
# Utiliser une couleur verte pour les jeux téléchargés
name_color = (100, 255, 100) if is_downloaded else color # Vert clair si téléchargé
name_surface = config.small_font.render(truncated_name, True, name_color)
ext_surface = config.small_font.render(ext_text, True, THEME_COLORS["text"])
size_surface = config.small_font.render(size_text, True, THEME_COLORS["text"])
row_center_y = list_start_y + (i - config.scroll_offset) * line_height + line_height // 2
# Position nom (aligné à gauche dans la boite)
name_rect = name_surface.get_rect()
name_rect.midleft = (rect_x + 20, row_center_y)
ext_rect = ext_surface.get_rect()
ext_rect.center = (rect_x + rect_width - 20 - size_col_width - ext_col_width // 2, row_center_y)
size_rect = size_surface.get_rect()
size_rect.midright = (rect_x + rect_width - 20, row_center_y)
if i == config.current_game:
@@ -1361,6 +1507,7 @@ def draw_game_list(screen):
pygame.draw.rect(screen, (*THEME_COLORS["fond_lignes"][:3], 120), border_rect, width=1, border_radius=8)
screen.blit(name_surface, name_rect)
screen.blit(ext_surface, ext_rect)
screen.blit(size_surface, size_rect)
if len(games) > items_per_page:
@@ -1387,6 +1534,205 @@ def draw_game_scrollbar(screen, scroll_offset, total_items, visible_items, x, y,
scrollbar_y = y + (game_area_height - scrollbar_height) * (scroll_offset / max(1, total_items - visible_items))
pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (x, scrollbar_y, 15, scrollbar_height), border_radius=4)
def get_display_extension(file_name):
"""Retourne l'extension finale d'un nom de fichier pour affichage."""
if not isinstance(file_name, str) or not file_name.strip():
return "-"
suffix = Path(file_name).suffix.strip()
if not suffix:
return "-"
return suffix.lower()
def draw_global_search_list(screen):
"""Affiche la recherche globale par nom sur toutes les plateformes."""
query = getattr(config, 'global_search_query', '') or ''
results = getattr(config, 'global_search_results', []) or []
screen.blit(OVERLAY, (0, 0))
title_query = query + "_" if (getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False)) or (not getattr(config, 'joystick', False)) else query
title_text = _("global_search_title").format(title_query)
if results:
title_text += f" ({len(results)})"
title_surface = config.search_font.render(title_text, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20))
title_rect_inflated = title_rect.inflate(60, 30)
title_rect_inflated.topleft = ((config.screen_width - title_rect_inflated.width) // 2, 10)
shadow = pygame.Surface((title_rect_inflated.width + 10, title_rect_inflated.height + 10), pygame.SRCALPHA)
pygame.draw.rect(shadow, (0, 0, 0, 120), (5, 5, title_rect_inflated.width, title_rect_inflated.height), border_radius=14)
screen.blit(shadow, (title_rect_inflated.left - 5, title_rect_inflated.top - 5))
glow = pygame.Surface((title_rect_inflated.width + 20, title_rect_inflated.height + 20), pygame.SRCALPHA)
pygame.draw.rect(glow, (*THEME_COLORS["glow"][:3], 60), glow.get_rect(), border_radius=16)
screen.blit(glow, (title_rect_inflated.left - 10, title_rect_inflated.top - 10))
pygame.draw.rect(screen, THEME_COLORS["button_idle"], title_rect_inflated, border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12)
screen.blit(title_surface, title_rect)
if not query.strip():
message = _("global_search_empty_query")
lines = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
text_height = len(lines) * line_height
margin_top_bottom = 20
rect_height = text_height + 2 * margin_top_bottom
max_text_width = max([config.font.size(line)[0] for line in lines], default=300)
rect_width = max_text_width + 80
rect_x = (config.screen_width - rect_width) // 2
rect_y = (config.screen_height - rect_height) // 2
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
for i, line in enumerate(lines):
text_surface = config.font.render(line, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2))
screen.blit(text_surface, text_rect)
return
if not results:
message = _("global_search_no_results").format(query)
lines = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
text_height = len(lines) * line_height
margin_top_bottom = 20
rect_height = text_height + 2 * margin_top_bottom
max_text_width = max([config.font.size(line)[0] for line in lines], default=300)
rect_width = max_text_width + 80
rect_x = (config.screen_width - rect_width) // 2
rect_y = (config.screen_height - rect_height) // 2
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
for i, line in enumerate(lines):
text_surface = config.font.render(line, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(config.screen_width // 2, rect_y + margin_top_bottom + i * line_height + line_height // 2))
screen.blit(text_surface, text_rect)
return
line_height = config.small_font.get_height() + 10
header_height = line_height
margin_top_bottom = 20
extra_margin_top = 20
extra_margin_bottom = 60
title_height = config.title_font.get_height() + 20
available_height = config.screen_height - title_height - extra_margin_top - extra_margin_bottom - 2 * margin_top_bottom - header_height
items_per_page = max(1, available_height // line_height)
rect_height = header_height + items_per_page * line_height + 2 * margin_top_bottom
rect_width = int(0.95 * config.screen_width)
rect_x = (config.screen_width - rect_width) // 2
rect_y = title_height + extra_margin_top + (config.screen_height - title_height - extra_margin_top - extra_margin_bottom - rect_height) // 2
config.global_search_scroll_offset = max(0, min(config.global_search_scroll_offset, max(0, len(results) - items_per_page)))
if config.global_search_selected < config.global_search_scroll_offset:
config.global_search_scroll_offset = config.global_search_selected
elif config.global_search_selected >= config.global_search_scroll_offset + items_per_page:
config.global_search_scroll_offset = config.global_search_selected - items_per_page + 1
shadow_rect = pygame.Rect(rect_x + 6, rect_y + 6, rect_width, rect_height)
shadow_surf = pygame.Surface((rect_width + 8, rect_height + 8), pygame.SRCALPHA)
pygame.draw.rect(shadow_surf, (0, 0, 0, 100), (4, 4, rect_width, rect_height), border_radius=14)
screen.blit(shadow_surf, (rect_x - 4, rect_y - 4))
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12)
highlight = pygame.Surface((rect_width - 8, 40), pygame.SRCALPHA)
highlight.fill((255, 255, 255, 15))
screen.blit(highlight, (rect_x + 4, rect_y + 4))
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
ext_col_width = max(90, int(rect_width * 0.08))
size_col_width = max(120, int(rect_width * 0.15))
platform_col_width = max(220, int(rect_width * 0.22))
name_col_width = rect_width - 40 - platform_col_width - ext_col_width - size_col_width
header_y_center = rect_y + margin_top_bottom + header_height // 2
header_platform_surface = config.small_font.render(_("history_column_system"), True, THEME_COLORS["text"])
header_platform_rect = header_platform_surface.get_rect()
header_platform_rect.midleft = (rect_x + 20, header_y_center)
header_name_surface = config.small_font.render(_("game_header_name"), True, THEME_COLORS["text"])
header_name_rect = header_name_surface.get_rect()
header_name_rect.midleft = (rect_x + 20 + platform_col_width, header_y_center)
header_ext_surface = config.small_font.render(_("game_header_ext"), True, THEME_COLORS["text"])
header_ext_rect = header_ext_surface.get_rect()
header_ext_rect.center = (rect_x + rect_width - 20 - size_col_width - ext_col_width // 2, header_y_center)
header_size_surface = config.small_font.render(_("game_header_size"), True, THEME_COLORS["text"])
header_size_rect = header_size_surface.get_rect()
header_size_rect.midright = (rect_x + rect_width - 20, header_y_center)
screen.blit(header_platform_surface, header_platform_rect)
screen.blit(header_name_surface, header_name_rect)
screen.blit(header_ext_surface, header_ext_rect)
screen.blit(header_size_surface, header_size_rect)
separator_y = rect_y + margin_top_bottom + header_height
pygame.draw.line(screen, THEME_COLORS["border"], (rect_x + 20, separator_y), (rect_x + rect_width - 20, separator_y), 2)
list_start_y = rect_y + margin_top_bottom + header_height
for i in range(config.global_search_scroll_offset, min(config.global_search_scroll_offset + items_per_page, len(results))):
item = results[i]
row_center_y = list_start_y + (i - config.global_search_scroll_offset) * line_height + line_height // 2
is_selected = i == config.global_search_selected
row_color = THEME_COLORS["fond_lignes"] if is_selected else THEME_COLORS["text"]
platform_text = truncate_text_end(item["platform_label"], config.small_font, platform_col_width - 10)
game_text = truncate_text_middle(item["display_name"], config.small_font, name_col_width - 10, is_filename=False)
ext_text = get_display_extension(item.get("game_name"))
size_value = item.get("size")
size_text = size_value if (isinstance(size_value, str) and size_value.strip()) else "N/A"
platform_surface = config.small_font.render(platform_text, True, row_color)
game_surface = config.small_font.render(game_text, True, row_color)
ext_surface = config.small_font.render(ext_text, True, THEME_COLORS["text"])
size_surface = config.small_font.render(size_text, True, THEME_COLORS["text"])
platform_rect = platform_surface.get_rect()
platform_rect.midleft = (rect_x + 20, row_center_y)
game_rect = game_surface.get_rect()
game_rect.midleft = (rect_x + 20 + platform_col_width, row_center_y)
ext_rect = ext_surface.get_rect()
ext_rect.center = (rect_x + rect_width - 20 - size_col_width - ext_col_width // 2, row_center_y)
size_rect = size_surface.get_rect()
size_rect.midright = (rect_x + rect_width - 20, row_center_y)
if is_selected:
glow_width = rect_width - 40
glow_height = game_rect.height + 12
glow_surface = pygame.Surface((glow_width + 6, glow_height + 6), pygame.SRCALPHA)
pygame.draw.rect(glow_surface, (*THEME_COLORS["fond_lignes"][:3], 50), (3, 3, glow_width, glow_height), border_radius=8)
screen.blit(glow_surface, (rect_x + 17, row_center_y - glow_height // 2 - 3))
selection_bg = pygame.Surface((glow_width, glow_height), pygame.SRCALPHA)
for j in range(glow_height):
ratio = j / glow_height
alpha = int(60 + 20 * ratio)
pygame.draw.line(selection_bg, (*THEME_COLORS["fond_lignes"][:3], alpha), (0, j), (glow_width, j))
screen.blit(selection_bg, (rect_x + 20, row_center_y - glow_height // 2))
border_rect = pygame.Rect(rect_x + 20, row_center_y - glow_height // 2, glow_width, glow_height)
pygame.draw.rect(screen, (*THEME_COLORS["fond_lignes"][:3], 120), border_rect, width=1, border_radius=8)
screen.blit(platform_surface, platform_rect)
screen.blit(game_surface, game_rect)
screen.blit(ext_surface, ext_rect)
screen.blit(size_surface, size_rect)
if len(results) > items_per_page:
draw_game_scrollbar(
screen,
config.global_search_scroll_offset,
len(results),
items_per_page,
rect_x + rect_width - 10,
rect_y,
rect_height
)
def format_size(size):
"""Convertit une taille en octets en format lisible avec unités adaptées à la langue."""
if not isinstance(size, (int, float)) or size == 0:
@@ -1439,14 +1785,18 @@ def draw_history_list(screen):
# Define column widths as percentages of available space (give more space to status/error messages)
column_width_percentages = {
"platform": 0.15, # narrower platform column
"game_name": 0.45, # game name column
"size": 0.10, # size column remains compact
"status": 0.30 # wider status column for long error codes/messages
"platform": 0.13,
"game_name": 0.25,
"ext": 0.08,
"folder": 0.12,
"size": 0.08,
"status": 0.34
}
available_width = int(0.95 * config.screen_width - 60) # Total available width for columns
col_platform_width = int(available_width * column_width_percentages["platform"])
col_game_width = int(available_width * column_width_percentages["game_name"])
col_ext_width = int(available_width * column_width_percentages["ext"])
col_folder_width = int(available_width * column_width_percentages["folder"])
col_size_width = int(available_width * column_width_percentages["size"])
col_status_width = int(available_width * column_width_percentages["status"])
rect_width = int(0.95 * config.screen_width)
@@ -1525,13 +1875,15 @@ def draw_history_list(screen):
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (rect_x, rect_y, rect_width, rect_height), border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], (rect_x, rect_y, rect_width, rect_height), 2, border_radius=12)
headers = [_("history_column_system"), _("history_column_game"), _("history_column_size"), _("history_column_status")]
headers = [_("history_column_system"), _("history_column_game"), _("game_header_ext"), _("history_column_folder"), _("history_column_size"), _("history_column_status")]
header_y = rect_y + margin_top_bottom + header_height // 2
header_x_positions = [
rect_x + 20 + col_platform_width // 2,
rect_x + 20 + col_platform_width + col_game_width // 2,
rect_x + 20 + col_platform_width + col_game_width + col_size_width // 2,
rect_x + 20 + col_platform_width + col_game_width + col_size_width + col_status_width // 2
rect_x + 20 + col_platform_width + col_game_width + col_ext_width // 2,
rect_x + 20 + col_platform_width + col_game_width + col_ext_width + col_folder_width // 2,
rect_x + 20 + col_platform_width + col_game_width + col_ext_width + col_folder_width + col_size_width // 2,
rect_x + 20 + col_platform_width + col_game_width + col_ext_width + col_folder_width + col_size_width + col_status_width // 2
]
for header, x_pos in zip(headers, header_x_positions):
text_surface = config.small_font.render(header, True, THEME_COLORS["text"])
@@ -1545,6 +1897,8 @@ def draw_history_list(screen):
entry = history[i]
platform = entry.get("platform", "Inconnu")
game_name = entry.get("game_name", "Inconnu")
ext_text = get_display_extension(game_name)
folder_text = _get_dest_folder_name(platform)
# Correction du calcul de la taille
size = entry.get("total_size", 0)
@@ -1618,20 +1972,26 @@ def draw_history_list(screen):
status_color = THEME_COLORS.get("text", (255, 255, 255))
platform_text = truncate_text_end(platform, config.small_font, col_platform_width - 10)
game_text = truncate_text_end(game_name, config.small_font, col_game_width - 10)
game_text = truncate_text_end(str(game_name).rsplit('.', 1)[0] if '.' in str(game_name) else str(game_name), config.small_font, col_game_width - 10)
ext_text = truncate_text_end(ext_text, config.small_font, col_ext_width - 10)
folder_text = truncate_text_end(folder_text, config.small_font, col_folder_width - 10)
size_text = truncate_text_end(size_text, config.small_font, col_size_width - 10)
status_text = truncate_text_middle(str(status_text or ""), config.small_font, col_status_width - 10, is_filename=False)
y_pos = rect_y + margin_top_bottom + header_height + idx * line_height + line_height // 2
platform_surface = config.small_font.render(platform_text, True, color)
game_surface = config.small_font.render(game_text, True, color)
ext_surface = config.small_font.render(ext_text, True, color)
folder_surface = config.small_font.render(folder_text, True, color)
size_surface = config.small_font.render(size_text, True, color) # Correction ici
status_surface = config.small_font.render(status_text, True, status_color)
platform_rect = platform_surface.get_rect(center=(header_x_positions[0], y_pos))
game_rect = game_surface.get_rect(center=(header_x_positions[1], y_pos))
size_rect = size_surface.get_rect(center=(header_x_positions[2], y_pos))
status_rect = status_surface.get_rect(center=(header_x_positions[3], y_pos))
ext_rect = ext_surface.get_rect(center=(header_x_positions[2], y_pos))
folder_rect = folder_surface.get_rect(center=(header_x_positions[3], y_pos))
size_rect = size_surface.get_rect(center=(header_x_positions[4], y_pos))
status_rect = status_surface.get_rect(center=(header_x_positions[5], y_pos))
if i == current_history_item_inverted:
glow_surface = pygame.Surface((rect_width - 40, line_height), pygame.SRCALPHA)
@@ -1640,6 +2000,8 @@ def draw_history_list(screen):
screen.blit(platform_surface, platform_rect)
screen.blit(game_surface, game_rect)
screen.blit(ext_surface, ext_rect)
screen.blit(folder_surface, folder_rect)
screen.blit(size_surface, size_rect)
screen.blit(status_surface, status_rect)
@@ -1908,14 +2270,31 @@ def draw_extension_warning(screen):
# Affichage des contrôles en bas de page
def draw_controls(screen, menu_state, current_music_name=None, music_popup_start_time=0):
"""Affiche les contrôles contextuels en bas de l'écran selon le menu_state."""
if menu_state == "platform_search" and getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False):
menu_state = "platform_search_edit"
# Mapping des contrôles par menu_state
controls_map = {
"platform": [
("history", _("controls_action_history")),
("filter", _("controls_filter_search")),
("confirm", _("controls_confirm_select")),
("start", _("controls_action_start")),
],
"platform_search": [
("confirm", _("controls_confirm_select")),
("clear_history", _("controls_action_queue")),
(("page_up", "page_down"), _("controls_pages")),
("filter", _("controls_action_edit_search")),
("cancel", _("controls_cancel_back")),
],
"platform_search_edit": [
("confirm", _("controls_action_select_char")),
("delete", _("controls_action_delete")),
("space", _("controls_action_space")),
("filter", _("controls_action_show_results")),
("cancel", _("controls_cancel_back")),
],
"game": [
("confirm", _("controls_confirm_select")),
("clear_history", _("controls_action_queue")),
@@ -1965,6 +2344,9 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
("clear_history", _("settings_roms_folder_default")),
("cancel", _("controls_cancel_back")),
],
"pause_connection_status": [
("cancel", _("controls_cancel_back")),
],
}
# Cas spécial : pause_settings_menu avec option roms_folder sélectionnée
@@ -1982,41 +2364,9 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
# Construire les lignes avec icônes
icon_lines = []
# Sur la page d'accueil et la page loading afficher version et musique
if menu_state == "platform" or menu_state == "loading":
control_parts = []
start_button = get_control_display('start', 'START')
# Si aucun joystick, afficher la touche entre crochets
if not getattr(config, 'joystick', True):
start_button = f"[{start_button}]"
start_text = _("controls_action_start")
control_parts.append(f"RGSX v{config.app_version} - {start_button} : {start_text}")
# Afficher le nom du joystick s'il est détecté
try:
device_name = getattr(config, 'controller_device_name', '') or ''
if device_name:
try:
joy_label = _("footer_joystick")
except Exception:
joy_label = "Joystick: {0}"
if isinstance(joy_label, str) and "{0}" in joy_label:
joy_text = joy_label.format(device_name)
else:
joy_text = f"{joy_label} {device_name}" if joy_label else f"Joystick: {device_name}"
control_parts.append(f"| {joy_text}")
except Exception:
pass
# Ajouter le nom de la musique si disponible
if config.current_music_name and config.music_popup_start_time > 0:
current_time = pygame.time.get_ticks() / 1000
if current_time - config.music_popup_start_time < 3.0:
control_parts.append(f"| {config.current_music_name}")
control_text = " ".join(control_parts)
icon_lines.append(control_text)
# Sur la page loading afficher version et musique
if menu_state == "loading":
icon_lines.append(f"RGSX v{config.app_version}")
else:
# Pour les autres menus: affichage avec icônes et contrôles contextuels sur une seule ligne
all_controls = []
@@ -2839,6 +3189,7 @@ def draw_pause_games_menu(screen, selected_index):
source_label = _("games_source_rgsx") if mode == "rgsx" else _("games_source_custom")
source_txt = f"{_('menu_games_source_prefix')}: < {source_label} >"
update_txt = _("menu_redownload_cache")
scan_txt = _("menu_scan_owned_roms") if _ else "Scan owned ROMs"
history_txt = _("menu_history") if _ else "History"
# Show unsupported systems
@@ -2859,9 +3210,10 @@ def draw_pause_games_menu(screen, selected_index):
filter_txt = _("submenu_display_filter_platforms") if _ else "Filter Platforms"
back_txt = _("menu_back") if _ else "Back"
options = [update_txt, history_txt, source_txt, unsupported_txt, hide_premium_txt, filter_txt, back_txt]
options = [update_txt, scan_txt, history_txt, source_txt, unsupported_txt, hide_premium_txt, filter_txt, back_txt]
instruction_keys = [
"instruction_games_update_cache",
"instruction_games_scan_owned",
"instruction_games_history",
"instruction_games_source_mode",
"instruction_display_show_unsupported",
@@ -2948,6 +3300,7 @@ def draw_pause_settings_menu(screen, selected_index):
custom_dns_txt = f"{_('settings_custom_dns')} : < {custom_dns_status} >"
api_keys_txt = _("menu_api_keys_status") if _ else "API Keys"
connection_status_txt = _("menu_connection_status") if _ else "Connection status"
back_txt = _("menu_back") if _ else "Back"
# Construction de la liste des options
@@ -2956,7 +3309,7 @@ def draw_pause_settings_menu(screen, selected_index):
options.append(web_service_txt)
if custom_dns_txt: # Ajouter seulement si Linux/Batocera
options.append(custom_dns_txt)
options.extend([api_keys_txt, back_txt])
options.extend([api_keys_txt, connection_status_txt, back_txt])
# Index de l'option Dossier ROMs
roms_folder_index = 3
@@ -2974,6 +3327,7 @@ def draw_pause_settings_menu(screen, selected_index):
instruction_keys.append("instruction_settings_custom_dns")
instruction_keys.extend([
"instruction_settings_api_keys",
"instruction_settings_connection_status",
"instruction_generic_back",
])
key = instruction_keys[selected_index] if 0 <= selected_index < len(instruction_keys) else None
@@ -2997,6 +3351,7 @@ def draw_pause_api_keys_status(screen):
providers = [
("1fichier", keys.get('1fichier')),
("AllDebrid", keys.get('alldebrid')),
("Debrid-Link", keys.get('debridlink')),
("RealDebrid", keys.get('realdebrid'))
]
# Dimensions dynamiques en fonction du contenu
@@ -3054,6 +3409,7 @@ def draw_pause_api_keys_status(screen):
filename_display = {
'1fichier': '1FichierAPI.txt',
'AllDebrid': 'AllDebridAPI.txt',
'Debrid-Link': 'DebridLinkAPI.txt',
'RealDebrid': 'RealDebridAPI.txt'
}.get(provider, 'key.txt')
empty_suffix = _("api_key_empty_suffix") if _ and _("api_key_empty_suffix") != "api_key_empty_suffix" else "empty"
@@ -3084,6 +3440,147 @@ def draw_pause_api_keys_status(screen):
screen.blit(hint_surf, hint_rect)
def draw_pause_connection_status(screen):
screen.blit(OVERLAY, (0, 0))
status_map, last_ts, in_progress, progress = get_connection_status_snapshot()
targets = get_connection_status_targets()
title = _("connection_status_title") if _ else "Connection status"
cat_updates = _("connection_status_category_updates") if _ else "Updates"
cat_sources = _("connection_status_category_sources") if _ else "Sources"
# Group rows by category
categories_order = ["updates", "sources"]
category_labels = {
"updates": cat_updates,
"sources": cat_sources,
}
rows = [] # list of (type, data)
for cat in categories_order:
cat_items = [t for t in targets if t.get("category") == cat]
if not cat_items:
continue
rows.append(("header", category_labels.get(cat, cat)))
for item in cat_items:
rows.append(("item", item))
# Title surface (used for sizing)
title_surface = config.font.render(title, True, THEME_COLORS["text"])
# Dimensions
row_height = config.small_font.get_height() + 14
header_row_height = config.small_font.get_height() + 10
title_height = 60
footer_height = 55
content_height = 0
for row_type, row_data in rows:
content_height += header_row_height if row_type == "header" else row_height
# Measure max text width to size the menu
max_text_width = title_surface.get_width()
for row_type, row_data in rows:
if row_type == "header":
w = config.small_font.size(str(row_data))[0]
else:
label = row_data.get("label") or row_data.get("key", "")
w = config.small_font.size(str(label))[0]
if w > max_text_width:
max_text_width = w
circle_area_width = 46 # status circle + gap
inner_padding = 70
menu_width = min(int(config.screen_width * 0.70), max(360, max_text_width + circle_area_width + inner_padding))
menu_height = title_height + content_height + footer_height
menu_x = (config.screen_width - menu_width) // 2
menu_y = (config.screen_height - menu_height) // 2
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (menu_x, menu_y, menu_width, menu_height), border_radius=22)
pygame.draw.rect(screen, THEME_COLORS["border"], (menu_x, menu_y, menu_width, menu_height), 2, border_radius=22)
# Title
title_rect = title_surface.get_rect(center=(config.screen_width // 2, menu_y + 34))
screen.blit(title_surface, title_rect)
# Columns
col_site_x = menu_x + 40
col_status_x = menu_x + int(menu_width * 0.70)
y = menu_y + title_height - 5
for row_type, data in rows:
if row_type == "header":
header_text = data
header_surf = config.small_font.render(header_text, True, THEME_COLORS.get("text_dim", THEME_COLORS["text"]))
screen.blit(header_surf, (col_site_x, y))
# separator line
sep_y = y + header_row_height - 6
pygame.draw.line(screen, THEME_COLORS["border"], (menu_x + 25, sep_y), (menu_x + menu_width - 25, sep_y), 1)
y += header_row_height
continue
item = data
key = item.get("key")
label = item.get("label") or item.get("key", "")
status_val = status_map.get(key)
if status_val is True:
circle_color = (60, 170, 60)
circle_bg = (30, 70, 30)
elif status_val is False:
circle_color = (180, 55, 55)
circle_bg = (70, 25, 25)
else:
circle_color = (140, 140, 140)
circle_bg = (60, 60, 60)
# Site label (indent to distinguish from category title)
label_surf = config.small_font.render(label, True, THEME_COLORS["text"])
screen.blit(label_surf, (col_site_x + 18, y))
# Status circle
radius = 14
center_x = col_status_x + radius
center_y = y + config.small_font.get_height() // 2
pygame.draw.circle(screen, circle_bg, (center_x, center_y), radius)
pygame.draw.circle(screen, circle_color, (center_x, center_y), radius, 2)
# Separator
sep_y = y + row_height - 8
pygame.draw.line(screen, THEME_COLORS["border"], (menu_x + 25, sep_y), (menu_x + menu_width - 25, sep_y), 1)
y += row_height
# Footer hint
hint_font = config.tiny_font if hasattr(config, "tiny_font") else config.small_font
if in_progress:
done = int(progress.get("done", 0)) if isinstance(progress, dict) else 0
total = int(progress.get("total", 0)) if isinstance(progress, dict) else 0
if _ and _("connection_status_progress") != "connection_status_progress":
try:
hint_txt = _("connection_status_progress").format(done=done, total=total)
except Exception:
hint_txt = _("connection_status_checking") if _ else "Checking..."
else:
hint_txt = f"Checking... {done}/{total}" if total else ("Checking..." if not _ else _("connection_status_checking"))
elif last_ts:
try:
time_str = datetime.fromtimestamp(last_ts).strftime("%H:%M:%S")
except Exception:
time_str = ""
if _ and _("connection_status_last_check") != "connection_status_last_check":
try:
hint_txt = _("connection_status_last_check").format(time=time_str)
except Exception:
hint_txt = f"Last check: {time_str}" if time_str else ""
else:
hint_txt = f"Last check: {time_str}" if time_str else ""
else:
hint_txt = ""
if hint_txt:
hint_surf = hint_font.render(hint_txt, True, THEME_COLORS.get("text_dim", THEME_COLORS["text"]))
hint_rect = hint_surf.get_rect(center=(config.screen_width // 2, menu_y + menu_height - 26))
screen.blit(hint_surf, hint_rect)
def draw_filter_platforms_menu(screen):
"""Affiche le menu de filtrage des plateformes (afficher/masquer)."""
screen.blit(OVERLAY, (0, 0))
@@ -4444,7 +4941,7 @@ def draw_scraper_screen(screen):
# Redimensionner l'image en conservant le ratio
image = config.scraper_image_surface
img_width, img_height = image.get_size()
img_width, img_height = image.get_size() if image else (0, 0)
# Calculer le ratio de redimensionnement
width_ratio = max_image_width / img_width

View File

@@ -9,6 +9,8 @@ import re
import logging
from typing import List, Tuple, Dict, Any
from config import Game
logger = logging.getLogger(__name__)
@@ -156,9 +158,7 @@ class GameFilters:
r'\([^\)]*PRERELEASE[^\)]*\)',
r'\([^\)]*UNFINISHED[^\)]*\)',
r'\([^\)]*WIP[^\)]*\)',
r'\[[^\]]*BETA[^\]]*\]',
r'\[[^\]]*DEMO[^\]]*\]',
r'\[[^\]]*TEST[^\]]*\]'
r'\([^\)]*BOOTLEG[^\)]*\)',
]
return any(re.search(pattern, name) for pattern in non_release_patterns)
@@ -191,11 +191,31 @@ class GameFilters:
base = base + disc_info
return base
@staticmethod
def get_cached_regions(game: Game) -> List[str]:
"""Retourne les régions en les calculant une seule fois par jeu."""
if game.regions is None:
game.regions = GameFilters.get_game_regions(game.display_name)
return game.regions
@staticmethod
def get_cached_non_release(game: Game) -> bool:
"""Retourne le flag non-release en le calculant à la demande."""
if game.is_non_release is None:
game.is_non_release = GameFilters.is_non_release_game(game.display_name)
return game.is_non_release
@staticmethod
def get_cached_base_name(game: Game) -> str:
"""Retourne le nom de base en le calculant une seule fois par jeu."""
if game.base_name is None:
game.base_name = GameFilters.get_base_game_name(game.display_name)
return game.base_name
def get_region_priority(self, game_name: str) -> int:
def get_region_priority(self, game: Game) -> int:
"""Obtient la priorité de région pour un jeu (pour one-rom-per-game)"""
# Utiliser la fonction de détection de régions pour être cohérent
game_regions = self.get_game_regions(game_name)
game_regions = self.get_cached_regions(game)
# Trouver la meilleure priorité parmi toutes les régions détectées
best_priority = len(self.region_priority) # Par défaut: priorité la plus basse
@@ -211,7 +231,7 @@ class GameFilters:
return best_priority
def apply_filters(self, games: List[Tuple]) -> List[Tuple]:
def apply_filters(self, games: list[Game]) -> list[Game]:
"""
Applique les filtres à une liste de jeux
games: Liste de tuples (game_name, game_url, size)
@@ -221,14 +241,13 @@ class GameFilters:
return games
filtered_games = []
has_region_excludes = any(state == 'exclude' for state in self.region_filters.values())
# Filtrage par région
for game in games:
game_name = game[0]
# Vérifier les filtres de région
if self.region_filters:
game_regions = self.get_game_regions(game_name)
if has_region_excludes:
game_regions = self.get_cached_regions(game)
# Vérifier si le jeu a au moins une région incluse
has_included_region = False
@@ -244,7 +263,7 @@ class GameFilters:
continue
# Filtrer les non-release
if self.hide_non_release and self.is_non_release_game(game_name):
if self.hide_non_release and self.get_cached_non_release(game):
continue
filtered_games.append(game)
@@ -255,13 +274,12 @@ class GameFilters:
return filtered_games
def _apply_one_rom_per_game(self, games: List[Tuple]) -> List[Tuple]:
def _apply_one_rom_per_game(self, games: List[Game]) -> List[Game]:
"""Garde seulement une ROM par jeu selon la priorité de région"""
games_by_base = {}
for game in games:
game_name = game[0]
base_name = self.get_base_game_name(game_name)
base_name = self.get_cached_base_name(game)
if base_name not in games_by_base:
games_by_base[base_name] = []
@@ -276,7 +294,7 @@ class GameFilters:
else:
# Trier par priorité de région
sorted_games = sorted(game_list,
key=lambda g: self.get_region_priority(g[0]))
key=self.get_region_priority)
result.append(sorted_games[0])
return result

View File

@@ -119,18 +119,39 @@ def clear_history():
try:
# Charger l'historique actuel
current_history = load_history()
# Conserver uniquement les entrées avec statut actif (téléchargement, extraction ou conversion en cours)
# Supporter les deux variantes de statut (anglais et français)
active_statuses = {"Downloading", "Téléchargement", "downloading", "Extracting", "Converting", "Queued"}
preserved_entries = [
entry for entry in current_history
if entry.get("status") in active_statuses
]
# Sauvegarder l'historique filtré
with open(history_path, "w", encoding='utf-8') as f:
json.dump(preserved_entries, f, indent=2, ensure_ascii=False)
active_task_ids = set(getattr(config, 'download_tasks', {}).keys())
active_progress_urls = set(getattr(config, 'download_progress', {}).keys())
queued_urls = {
item.get("url") for item in getattr(config, 'download_queue', [])
if isinstance(item, dict) and item.get("url")
}
queued_task_ids = {
item.get("task_id") for item in getattr(config, 'download_queue', [])
if isinstance(item, dict) and item.get("task_id")
}
def is_truly_active(entry):
if not isinstance(entry, dict):
return False
status = entry.get("status")
if status not in active_statuses:
return False
task_id = entry.get("task_id")
url = entry.get("url")
if status == "Queued":
return task_id in queued_task_ids or url in queued_urls
return task_id in active_task_ids or url in active_progress_urls
preserved_entries = [entry for entry in current_history if is_truly_active(entry)]
save_history(preserved_entries)
removed_count = len(current_history) - len(preserved_entries)
logger.info(f"Historique vidé : {history_path} ({removed_count} entrées supprimées, {len(preserved_entries)} conservées)")
@@ -140,6 +161,115 @@ def clear_history():
# ==================== GESTION DES JEUX TÉLÉCHARGÉS ====================
IGNORED_ROM_SCAN_EXTENSIONS = {
'.bak', '.bmp', '.db', '.gif', '.ini', '.jpeg', '.jpg', '.json', '.log', '.mp4',
'.nfo', '.pdf', '.png', '.srm', '.sav', '.state', '.svg', '.txt', '.webp', '.xml'
}
def normalize_downloaded_game_name(game_name):
"""Normalise un nom de jeu pour les comparaisons en ignorant l'extension."""
if not isinstance(game_name, str):
return ""
normalized = os.path.basename(game_name.strip())
if not normalized:
return ""
return os.path.splitext(normalized)[0].strip().lower()
def _normalize_downloaded_games_dict(downloaded):
"""Normalise la structure de downloaded_games.json en restant rétrocompatible."""
normalized_downloaded = {}
if not isinstance(downloaded, dict):
return normalized_downloaded
for platform_name, games in downloaded.items():
if not isinstance(platform_name, str):
continue
if not isinstance(games, dict):
continue
normalized_games = {}
for game_name, metadata in games.items():
normalized_name = normalize_downloaded_game_name(game_name)
if not normalized_name:
continue
normalized_games[normalized_name] = metadata if isinstance(metadata, dict) else {}
if normalized_games:
normalized_downloaded[platform_name] = normalized_games
return normalized_downloaded
def _count_downloaded_games(downloaded_games_dict):
return sum(len(games) for games in downloaded_games_dict.values() if isinstance(games, dict))
def scan_roms_for_downloaded_games():
"""Scanne les dossiers ROMs et ajoute les jeux trouvés à downloaded_games.json."""
from utils import load_games
downloaded = _normalize_downloaded_games_dict(getattr(config, 'downloaded_games', {}))
platform_dicts = list(getattr(config, 'platform_dicts', []) or [])
if not platform_dicts:
return 0, 0
scanned_platforms = 0
added_games = 0
for platform_entry in platform_dicts:
if not isinstance(platform_entry, dict):
continue
platform_name = (platform_entry.get('platform_name') or '').strip()
folder_name = (platform_entry.get('folder') or '').strip()
if not platform_name or not folder_name:
continue
roms_path = os.path.join(config.ROMS_FOLDER, folder_name)
if not os.path.isdir(roms_path):
continue
available_games = load_games(platform_name)
available_names = {
normalize_downloaded_game_name(game.name)
for game in available_games
if normalize_downloaded_game_name(game.name)
}
if not available_names:
continue
platform_games = downloaded.setdefault(platform_name, {})
scanned_platforms += 1
for root, _, filenames in os.walk(roms_path):
for filename in filenames:
file_ext = os.path.splitext(filename)[1].lower()
if file_ext in IGNORED_ROM_SCAN_EXTENSIONS:
continue
normalized_name = normalize_downloaded_game_name(filename)
if not normalized_name or normalized_name not in available_names:
continue
if normalized_name not in platform_games:
platform_games[normalized_name] = {}
added_games += 1
config.downloaded_games = downloaded
save_downloaded_games(downloaded)
logger.info(
"Scan ROMs terminé : %s jeux ajoutés sur %s plateformes",
added_games,
scanned_platforms,
)
return added_games, scanned_platforms
def load_downloaded_games():
"""Charge la liste des jeux déjà téléchargés depuis downloaded_games.json."""
downloaded_path = getattr(config, 'DOWNLOADED_GAMES_PATH')
@@ -162,9 +292,10 @@ def load_downloaded_games():
if not isinstance(downloaded, dict):
logger.warning(f"Format downloaded_games.json invalide (pas un dict)")
return {}
logger.debug(f"Jeux téléchargés chargés : {sum(len(v) for v in downloaded.values())} jeux")
return downloaded
normalized_downloaded = _normalize_downloaded_games_dict(downloaded)
logger.debug(f"Jeux téléchargés chargés : {_count_downloaded_games(normalized_downloaded)} jeux")
return normalized_downloaded
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Erreur lors de la lecture de {downloaded_path} : {e}")
return {}
@@ -177,17 +308,18 @@ def save_downloaded_games(downloaded_games_dict):
"""Sauvegarde la liste des jeux téléchargés dans downloaded_games.json."""
downloaded_path = getattr(config, 'DOWNLOADED_GAMES_PATH')
try:
normalized_downloaded = _normalize_downloaded_games_dict(downloaded_games_dict)
os.makedirs(os.path.dirname(downloaded_path), exist_ok=True)
# Écriture atomique
temp_path = downloaded_path + '.tmp'
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(downloaded_games_dict, f, indent=2, ensure_ascii=False)
json.dump(normalized_downloaded, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, downloaded_path)
logger.debug(f"Jeux téléchargés sauvegardés : {sum(len(v) for v in downloaded_games_dict.values())} jeux")
logger.debug(f"Jeux téléchargés sauvegardés : {_count_downloaded_games(normalized_downloaded)} jeux")
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {downloaded_path} : {e}")
try:
@@ -200,21 +332,22 @@ def save_downloaded_games(downloaded_games_dict):
def mark_game_as_downloaded(platform_name, game_name, file_size=None):
"""Marque un jeu comme téléchargé."""
downloaded = config.downloaded_games
normalized_name = normalize_downloaded_game_name(game_name)
if not normalized_name:
return
if platform_name not in downloaded:
downloaded[platform_name] = {}
downloaded[platform_name][game_name] = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"size": file_size or "N/A"
}
downloaded[platform_name][normalized_name] = {}
# Sauvegarder immédiatement
save_downloaded_games(downloaded)
logger.info(f"Jeu marqué comme téléchargé : {platform_name} / {game_name}")
logger.info(f"Jeu marqué comme téléchargé : {platform_name} / {normalized_name}")
def is_game_downloaded(platform_name, game_name):
"""Vérifie si un jeu a déjà été téléchargé."""
downloaded = config.downloaded_games
return platform_name in downloaded and game_name in downloaded.get(platform_name, {})
normalized_name = normalize_downloaded_game_name(game_name)
return bool(normalized_name) and platform_name in downloaded and normalized_name in downloaded.get(platform_name, {})

View File

@@ -114,14 +114,14 @@ def get_text(key, default=None):
pass
return str(default) if default is not None else str(key)
def get_available_languages():
def get_available_languages() -> list[str]:
"""Récupère la liste des langues disponibles."""
if not os.path.exists(config.LANGUAGES_FOLDER):
logger.warning(f"Dossier des langues {config.LANGUAGES_FOLDER} non trouvé")
return []
languages = []
languages: list[str] = []
for file in os.listdir(config.LANGUAGES_FOLDER):
if file.endswith(".json"):
lang_code = os.path.splitext(file)[0]

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} Spiele)",
"game_filter": "Aktiver Filter: {0}",
"game_search": "Filtern: {0}",
"global_search_title": "Globale Suche: {0}",
"global_search_empty_query": "Geben Sie einen Namen ein, um alle Systeme zu durchsuchen",
"global_search_no_results": "Keine Ergebnisse fur: {0}",
"game_header_name": "Name",
"game_header_ext": "Ext",
"game_header_size": "Größe",
"history_title": "Downloads ({0})",
"history_empty": "Keine Downloads im Verlauf",
@@ -111,6 +115,7 @@
"controls_action_clear_history": "Verlauf leeren",
"controls_action_history": "Verlauf / Downloads",
"controls_action_close_history": "Verlauf schließen",
"history_column_folder": "Ordner",
"controls_action_queue": "Warteschlange",
"controls_action_delete": "Löschen",
"controls_action_space": "Leerzeichen",
@@ -144,6 +149,8 @@
"controls_confirm_select": "Bestätigen/Auswählen",
"controls_cancel_back": "Abbrechen/Zurück",
"controls_filter_search": "Filtern/Suchen",
"controls_action_edit_search": "Suche bearbeiten",
"controls_action_show_results": "Ergebnisse zeigen",
"network_download_failed": "Download nach {0} Versuchen fehlgeschlagen",
"network_api_error": "Fehler bei der API-Anfrage, der Schlüssel könnte falsch sein: {0}",
"network_download_error": "Downloadfehler {0}: {1}",
@@ -189,7 +196,14 @@
"status_present": "Vorhanden",
"status_missing": "Fehlt",
"menu_api_keys_status": "API-Schlüssel",
"menu_connection_status": "Verbindungsstatus",
"api_keys_status_title": "Status der API-Schlüssel",
"connection_status_title": "Verbindungsstatus",
"connection_status_category_updates": "App-/Gamelist-Update",
"connection_status_category_sources": "Spielquellen",
"connection_status_checking": "Prüfe...",
"connection_status_progress": "Prüfe... {done}/{total}",
"connection_status_last_check": "Letzte Prüfung: {time}",
"menu_games": "Spiele",
"api_keys_hint_manage": "Legen Sie Ihre Schlüssel in {path}",
"api_key_empty_suffix": "leer",
@@ -225,11 +239,13 @@
"instruction_games_history": "Vergangene Downloads und Status anzeigen",
"instruction_games_source_mode": "Zwischen RGSX oder eigener Quellliste wechseln",
"instruction_games_update_cache": "Aktuelle Spieleliste erneut herunterladen & aktualisieren",
"instruction_games_scan_owned": "ROM-Ordner scannen und bereits vorhandene Spiele markieren",
"instruction_settings_music": "Hintergrundmusik aktivieren oder deaktivieren",
"instruction_settings_symlink": "Verwendung von Symlinks für Installationen umschalten",
"instruction_settings_auto_extract": "Automatische Archivextraktion nach Download aktivieren/deaktivieren",
"instruction_settings_roms_folder": "Standard-Download-Verzeichnis für ROMs ändern",
"instruction_settings_api_keys": "Gefundene Premium-API-Schlüssel ansehen",
"instruction_settings_connection_status": "Zugriff auf Update- und Quellen-Seiten prüfen",
"instruction_settings_web_service": "Web-Dienst Autostart beim Booten aktivieren/deaktivieren",
"instruction_settings_custom_dns": "Custom DNS (Cloudflare 1.1.1.1) beim Booten aktivieren/deaktivieren",
"settings_auto_extract": "Auto-Extraktion Archive",
@@ -250,6 +266,9 @@
"settings_web_service_success_disabled": "Web-Dienst beim Booten deaktiviert",
"settings_web_service_error": "Fehler: {0}",
"settings_custom_dns": "Custom DNS beim Booten",
"menu_scan_owned_roms": "Vorhandene ROMs scannen",
"popup_scan_owned_roms_done": "ROM-Scan abgeschlossen: {0} Spiele auf {1} Plattformen hinzugefügt",
"popup_scan_owned_roms_error": "ROM-Scan-Fehler: {0}",
"settings_custom_dns_enabled": "Aktiviert",
"settings_custom_dns_disabled": "Deaktiviert",
"settings_custom_dns_enabling": "Custom DNS wird aktiviert...",

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} games)",
"game_filter": "Active filter: {0}",
"game_search": "Filter: {0}",
"global_search_title": "Global search: {0}",
"global_search_empty_query": "Type a game name to search across all systems",
"global_search_no_results": "No results for: {0}",
"game_header_name": "Name",
"game_header_ext": "Ext",
"game_header_size": "Size",
"history_title": "Downloads ({0})",
"history_empty": "No downloads in history",
@@ -112,6 +116,7 @@
"support_dialog_error": "Error generating support file:\n{0}\n\nPress {1} to return to the menu.",
"controls_action_history": "History / Downloads",
"controls_action_close_history": "Close History",
"history_column_folder": "Folder",
"network_checking_updates": "Update in progress please wait...",
"network_update_available": "Update available: {0}",
"network_extracting_update": "Extracting update...",
@@ -165,6 +170,8 @@
"controls_confirm_select": "Confirm/Select",
"controls_cancel_back": "Cancel/Back",
"controls_filter_search": "Filter/Search",
"controls_action_edit_search": "Edit search",
"controls_action_show_results": "Show results",
"symlink_option_enabled": "Symlink option enabled",
"symlink_option_disabled": "Symlink option disabled",
"menu_games_source_prefix": "Game source",
@@ -188,7 +195,14 @@
"status_present": "Present",
"status_missing": "Missing",
"menu_api_keys_status": "API Keys",
"menu_connection_status": "Connection status",
"api_keys_status_title": "API Keys Status",
"connection_status_title": "Connection status",
"connection_status_category_updates": "App/Gamelist update",
"connection_status_category_sources": "Game sources",
"connection_status_checking": "Checking...",
"connection_status_progress": "Checking... {done}/{total}",
"connection_status_last_check": "Last check: {time}",
"menu_games": "Games",
"api_keys_hint_manage": "Put your keys in {path}",
"api_key_empty_suffix": "empty",
@@ -227,11 +241,13 @@
"instruction_games_history": "List past downloads and statuses",
"instruction_games_source_mode": "Switch between RGSX or your own custom list source",
"instruction_games_update_cache": "Redownload & refresh current games list",
"instruction_games_scan_owned": "Scan your ROM folders and mark matching games as already owned",
"instruction_settings_music": "Enable or disable background music playback",
"instruction_settings_symlink": "Toggle using filesystem symlinks for installs",
"instruction_settings_auto_extract": "Toggle automatic archive extraction after download",
"instruction_settings_roms_folder": "Change the default ROMs download directory",
"instruction_settings_api_keys": "See detected premium provider API keys",
"instruction_settings_connection_status": "Check access to update and source sites",
"instruction_settings_web_service": "Enable/disable web service auto-start at boot",
"instruction_settings_custom_dns": "Enable/disable custom DNS (Cloudflare 1.1.1.1) at boot",
"settings_auto_extract": "Auto Extract Archives",
@@ -290,6 +306,9 @@
"history_option_delete_game": "Delete game",
"history_option_error_info": "Error details",
"history_option_retry": "Retry download",
"menu_scan_owned_roms": "Scan owned ROMs",
"popup_scan_owned_roms_done": "ROM scan complete: {0} games added across {1} platforms",
"popup_scan_owned_roms_error": "ROM scan error: {0}",
"history_option_back": "Back",
"history_folder_path_label": "Destination path:",
"history_scraper_not_implemented": "Scraper not yet implemented",

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} juegos)",
"game_filter": "Filtro activo: {0}",
"game_search": "Filtrar: {0}",
"global_search_title": "Busqueda global: {0}",
"global_search_empty_query": "Escribe un nombre para buscar en todas las consolas",
"global_search_no_results": "Sin resultados para: {0}",
"game_header_name": "Nombre",
"game_header_ext": "Ext",
"game_header_size": "Tamaño",
"history_title": "Descargas ({0})",
"history_empty": "No hay descargas en el historial",
@@ -109,6 +113,7 @@
"controls_action_clear_history": "Vaciar historial",
"controls_action_history": "Historial / Descargas",
"controls_action_close_history": "Cerrar Historial",
"history_column_folder": "Carpeta",
"controls_action_delete": "Eliminar",
"controls_action_space": "Espacio",
"controls_action_start": "Ayuda / Configuración",
@@ -142,6 +147,8 @@
"controls_confirm_select": "Confirmar/Seleccionar",
"controls_cancel_back": "Cancelar/Volver",
"controls_filter_search": "Filtrar/Buscar",
"controls_action_edit_search": "Editar busqueda",
"controls_action_show_results": "Ver resultados",
"network_download_failed": "Error en la descarga tras {0} intentos",
"network_api_error": "Error en la solicitud de API, la clave puede ser incorrecta: {0}",
"network_download_error": "Error en la descarga {0}: {1}",
@@ -189,7 +196,14 @@
"status_present": "Presente",
"status_missing": "Ausente",
"menu_api_keys_status": "Claves API",
"menu_connection_status": "Estado de conexión",
"api_keys_status_title": "Estado de las claves API",
"connection_status_title": "Estado de conexión",
"connection_status_category_updates": "Actualización app/lista de juegos",
"connection_status_category_sources": "Fuentes de juegos",
"connection_status_checking": "Comprobando...",
"connection_status_progress": "Comprobando... {done}/{total}",
"connection_status_last_check": "Última comprobación: {time}",
"menu_games": "Juegos",
"api_keys_hint_manage": "Coloca tus claves en {path}",
"api_key_empty_suffix": "vacío",
@@ -225,11 +239,13 @@
"instruction_games_history": "Ver descargas pasadas y su estado",
"instruction_games_source_mode": "Cambiar entre lista RGSX o fuente personalizada",
"instruction_games_update_cache": "Volver a descargar y refrescar la lista de juegos",
"instruction_games_scan_owned": "Escanear las carpetas ROMs y marcar los juegos que ya posees",
"instruction_settings_music": "Activar o desactivar música de fondo",
"instruction_settings_symlink": "Alternar uso de symlinks en instalaciones",
"instruction_settings_auto_extract": "Activar/desactivar extracción automática de archivos después de descargar",
"instruction_settings_roms_folder": "Cambiar el directorio de descarga de ROMs por defecto",
"instruction_settings_api_keys": "Ver claves API premium detectadas",
"instruction_settings_connection_status": "Comprobar acceso a sitios de actualizaciones y fuentes",
"instruction_settings_web_service": "Activar/desactivar inicio automático del servicio web",
"instruction_settings_custom_dns": "Activar/desactivar DNS personalizado (Cloudflare 1.1.1.1) al inicio",
"settings_auto_extract": "Extracción auto de archivos",
@@ -250,6 +266,9 @@
"settings_web_service_success_disabled": "Servicio web desactivado al inicio",
"settings_web_service_error": "Error: {0}",
"settings_custom_dns": "DNS Personalizado al Inicio",
"menu_scan_owned_roms": "Escanear ROMs disponibles",
"popup_scan_owned_roms_done": "Escaneo ROM completado: {0} juegos añadidos en {1} plataformas",
"popup_scan_owned_roms_error": "Error al escanear ROMs: {0}",
"settings_custom_dns_enabled": "Activado",
"settings_custom_dns_disabled": "Desactivado",
"settings_custom_dns_enabling": "Activando DNS personalizado...",

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} jeux)",
"game_filter": "Filtre actif : {0}",
"game_search": "Filtrer : {0}",
"global_search_title": "Recherche globale : {0}",
"global_search_empty_query": "Saisissez un nom pour rechercher dans toutes les consoles",
"global_search_no_results": "Aucun resultat pour : {0}",
"game_header_name": "Nom",
"game_header_ext": "Ext",
"game_header_size": "Taille",
"history_title": "Téléchargements ({0})",
"history_empty": "Aucun téléchargement dans l'historique",
@@ -106,6 +110,7 @@
"controls_action_queue": "Mettre en file d'attente",
"controls_action_history": "Historique / Téléchargements",
"controls_action_close_history": "Fermer l'historique",
"history_column_folder": "Dossier",
"controls_action_delete": "Supprimer",
"controls_action_space": "Espace",
"controls_action_start": "Aide / Réglages",
@@ -138,6 +143,8 @@
"controls_confirm_select": "Confirmer/Sélectionner",
"controls_cancel_back": "Annuler/Retour",
"controls_filter_search": "Filtrer/Rechercher",
"controls_action_edit_search": "Modifier recherche",
"controls_action_show_results": "Voir resultats",
"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}",
@@ -185,7 +192,14 @@
"status_present": "Présente",
"status_missing": "Absente",
"menu_api_keys_status": "Clés API",
"menu_connection_status": "État de connexion",
"api_keys_status_title": "Statut des clés API",
"connection_status_title": "État de connexion",
"connection_status_category_updates": "Mise à jour App/Liste de jeux",
"connection_status_category_sources": "Sources de jeux",
"connection_status_checking": "Vérification...",
"connection_status_progress": "Vérification... {done}/{total}",
"connection_status_last_check": "Dernière vérif : {time}",
"menu_games": "Jeux",
"api_keys_hint_manage": "Placez vos clés dans {path}",
"api_key_empty_suffix": "vide",
@@ -227,11 +241,13 @@
"instruction_games_history": "Lister les téléchargements passés et leur statut",
"instruction_games_source_mode": "Basculer entre liste RGSX ou source personnalisée",
"instruction_games_update_cache": "Retélécharger & rafraîchir la liste des jeux",
"instruction_games_scan_owned": "Scanner les dossiers ROMs et marquer les jeux déjà possédés",
"instruction_settings_music": "Activer ou désactiver la lecture musicale",
"instruction_settings_symlink": "Basculer l'utilisation de symlinks pour l'installation",
"instruction_settings_auto_extract": "Activer/désactiver l'extraction automatique des archives après téléchargement",
"instruction_settings_roms_folder": "Changer le répertoire de téléchargement des ROMs par défaut",
"instruction_settings_api_keys": "Voir les clés API détectées des services premium",
"instruction_settings_connection_status": "Vérifier l'accès aux sites d'update et de sources",
"instruction_settings_web_service": "Activer/désactiver le démarrage automatique du service web",
"instruction_settings_custom_dns": "Activer/désactiver les DNS personnalisés (Cloudflare 1.1.1.1) au démarrage",
"settings_auto_extract": "Extraction auto des archives",
@@ -290,6 +306,9 @@
"history_option_delete_game": "Supprimer le jeu",
"history_option_error_info": "Détails de l'erreur",
"history_option_retry": "Retenter le téléchargement",
"menu_scan_owned_roms": "Scanner les ROMs présentes",
"popup_scan_owned_roms_done": "Scan ROMs terminé : {0} jeux ajoutés sur {1} plateformes",
"popup_scan_owned_roms_error": "Erreur scan ROMs : {0}",
"history_option_back": "Retour",
"history_folder_path_label": "Chemin de destination :",
"history_scraper_not_implemented": "Scraper pas encore implémenté",

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} giochi)",
"game_filter": "Filtro attivo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Ricerca globale: {0}",
"global_search_empty_query": "Digita un nome per cercare in tutte le console",
"global_search_no_results": "Nessun risultato per: {0}",
"game_header_name": "Nome",
"game_header_ext": "Ext",
"game_header_size": "Dimensione",
"history_title": "Download ({0})",
"history_empty": "Nessun download nella cronologia",
@@ -107,6 +111,7 @@
"controls_action_clear_history": "Cancella cronologia",
"controls_action_history": "Cronologia / Downloads",
"controls_action_close_history": "Chiudi Cronologia",
"history_column_folder": "Cartella",
"controls_action_delete": "Elimina",
"controls_action_space": "Spazio",
"controls_action_start": "Aiuto / Impostazioni",
@@ -164,6 +169,8 @@
"controls_confirm_select": "Conferma/Seleziona",
"controls_cancel_back": "Annulla/Indietro",
"controls_filter_search": "Filtro/Ricerca",
"controls_action_edit_search": "Modifica ricerca",
"controls_action_show_results": "Mostra risultati",
"games_source_rgsx": "RGSX",
"sources_mode_rgsx_select_info": "RGSX: aggiorna l'elenco dei giochi",
"games_source_custom": "Personalizzato",
@@ -184,7 +191,14 @@
"status_present": "Presente",
"status_missing": "Assente",
"menu_api_keys_status": "Chiavi API",
"menu_connection_status": "Stato connessione",
"api_keys_status_title": "Stato delle chiavi API",
"connection_status_title": "Stato connessione",
"connection_status_category_updates": "Aggiornamento app/lista giochi",
"connection_status_category_sources": "Sorgenti giochi",
"connection_status_checking": "Verifica in corso...",
"connection_status_progress": "Verifica in corso... {done}/{total}",
"connection_status_last_check": "Ultimo controllo: {time}",
"menu_games": "Giochi",
"api_keys_hint_manage": "Metti le tue chiavi in {path}",
"api_key_empty_suffix": "vuoto",
@@ -220,11 +234,13 @@
"instruction_games_history": "Elencare download passati e stato",
"instruction_games_source_mode": "Passare tra elenco RGSX o sorgente personalizzata",
"instruction_games_update_cache": "Riscaria e aggiorna l'elenco dei giochi",
"instruction_games_scan_owned": "Scansiona le cartelle ROMs e segna i giochi gia posseduti",
"instruction_settings_music": "Abilitare o disabilitare musica di sottofondo",
"instruction_settings_symlink": "Abilitare/disabilitare uso symlink per installazioni",
"instruction_settings_auto_extract": "Attivare/disattivare estrazione automatica archivi dopo il download",
"instruction_settings_roms_folder": "Cambiare la directory di download ROMs predefinita",
"instruction_settings_api_keys": "Mostrare chiavi API premium rilevate",
"instruction_settings_connection_status": "Verifica accesso ai siti di aggiornamento e sorgenti",
"instruction_settings_web_service": "Attivare/disattivare avvio automatico servizio web all'avvio",
"instruction_settings_custom_dns": "Attivare/disattivare DNS personalizzato (Cloudflare 1.1.1.1) all'avvio",
"settings_auto_extract": "Estrazione auto archivi",
@@ -250,6 +266,9 @@
"settings_custom_dns_enabling": "Abilitazione DNS personalizzato...",
"settings_custom_dns_disabling": "Disabilitazione DNS personalizzato...",
"settings_custom_dns_success_enabled": "DNS personalizzato abilitato all'avvio (1.1.1.1)",
"menu_scan_owned_roms": "Scansiona ROM presenti",
"popup_scan_owned_roms_done": "Scansione ROM completata: {0} giochi aggiunti su {1} piattaforme",
"popup_scan_owned_roms_error": "Errore scansione ROM: {0}",
"settings_custom_dns_success_disabled": "DNS personalizzato disabilitato all'avvio",
"controls_desc_confirm": "Confermare (es. A/Croce)",
"controls_desc_cancel": "Annullare/Indietro (es. B/Cerchio)",

View File

@@ -24,7 +24,11 @@
"game_count": "{0} ({1} jogos)",
"game_filter": "Filtro ativo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Busca global: {0}",
"global_search_empty_query": "Digite um nome para buscar em todos os consoles",
"global_search_no_results": "Nenhum resultado para: {0}",
"game_header_name": "Nome",
"game_header_ext": "Ext",
"game_header_size": "Tamanho",
"history_title": "Downloads ({0})",
"history_empty": "Nenhum download no histórico",
@@ -111,6 +115,7 @@
"controls_action_clear_history": "Limpar histórico",
"controls_action_history": "Histórico / Downloads",
"controls_action_close_history": "Fechar Histórico",
"history_column_folder": "Pasta",
"controls_action_delete": "Deletar",
"controls_action_space": "Espaço",
"controls_action_start": "Ajuda / Configurações",
@@ -167,6 +172,8 @@
"controls_confirm_select": "Confirmar/Selecionar",
"controls_cancel_back": "Cancelar/Voltar",
"controls_filter_search": "Filtrar/Buscar",
"controls_action_edit_search": "Editar busca",
"controls_action_show_results": "Ver resultados",
"symlink_option_enabled": "Opção de symlink ativada",
"symlink_option_disabled": "Opção de symlink desativada",
"menu_games_source_prefix": "Fonte de jogos",
@@ -190,7 +197,14 @@
"status_present": "Presente",
"status_missing": "Ausente",
"menu_api_keys_status": "Chaves API",
"menu_connection_status": "Estado da conexão",
"api_keys_status_title": "Status das chaves API",
"connection_status_title": "Estado da conexão",
"connection_status_category_updates": "Atualização do app/lista de jogos",
"connection_status_category_sources": "Fontes de jogos",
"connection_status_checking": "Verificando...",
"connection_status_progress": "Verificando... {done}/{total}",
"connection_status_last_check": "Última verificação: {time}",
"menu_games": "Jogos",
"api_keys_hint_manage": "Coloque suas chaves em {path}",
"api_key_empty_suffix": "vazio",
@@ -226,11 +240,13 @@
"instruction_games_history": "Listar downloads anteriores e status",
"instruction_games_source_mode": "Alternar entre lista RGSX ou fonte personalizada",
"instruction_games_update_cache": "Baixar novamente e atualizar a lista de jogos",
"instruction_games_scan_owned": "Verificar as pastas ROMs e marcar os jogos ja existentes",
"instruction_settings_music": "Ativar ou desativar música de fundo",
"instruction_settings_symlink": "Ativar/desativar uso de symlinks para instalações",
"instruction_settings_auto_extract": "Ativar/desativar extração automática de arquivos após download",
"instruction_settings_roms_folder": "Alterar o diretório de download de ROMs padrão",
"instruction_settings_api_keys": "Ver chaves API premium detectadas",
"instruction_settings_connection_status": "Verificar acesso a sites de atualização e fontes",
"instruction_settings_web_service": "Ativar/desativar início automático do serviço web na inicialização",
"instruction_settings_custom_dns": "Ativar/desativar DNS personalizado (Cloudflare 1.1.1.1) na inicialização",
"settings_auto_extract": "Extração auto de arquivos",
@@ -250,6 +266,9 @@
"settings_web_service_success_enabled": "Serviço web ativado na inicialização",
"settings_web_service_success_disabled": "Serviço web desativado na inicialização",
"settings_web_service_error": "Erro: {0}",
"menu_scan_owned_roms": "Verificar ROMs existentes",
"popup_scan_owned_roms_done": "Verificacao de ROMs concluida: {0} jogos adicionados em {1} plataformas",
"popup_scan_owned_roms_error": "Erro ao verificar ROMs: {0}",
"settings_custom_dns": "DNS Personalizado na Inicialização",
"settings_custom_dns_enabled": "Ativado",
"settings_custom_dns_disabled": "Desativado",

View File

@@ -1,4 +1,4 @@
import requests
import requests # type: ignore
import subprocess
import os
import sys
@@ -15,7 +15,7 @@ try:
except Exception:
pygame = None # type: ignore
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, load_api_key_alldebrid, normalize_platform_name, load_api_keys
from utils import sanitize_filename, extract_zip, extract_rar, extract_7z, load_api_key_1fichier, load_api_key_alldebrid, normalize_platform_name, load_api_keys, load_archive_org_cookie
from history import save_history
from display import show_toast
import logging
@@ -32,11 +32,45 @@ from language import _ # Import de la fonction de traduction
import re
import html as html_module
from urllib.parse import urljoin, unquote
import urllib.parse
logger = logging.getLogger(__name__)
def _redact_headers(headers: dict) -> dict:
"""Return a copy of headers with sensitive fields redacted for logs."""
if not isinstance(headers, dict):
return {}
safe = headers.copy()
if 'Cookie' in safe and safe['Cookie']:
safe['Cookie'] = '<redacted>'
return safe
def _split_archive_org_path(url: str):
"""Parse archive.org download URL and return (identifier, archive_name, inner_path)."""
try:
parsed = urllib.parse.urlsplit(url)
parts = parsed.path.split('/download/', 1)
if len(parts) != 2:
return None, None, None
after = parts[1]
identifier = after.split('/', 1)[0]
rest = after[len(identifier):]
if rest.startswith('/'):
rest = rest[1:]
rest_decoded = urllib.parse.unquote(rest)
if '/' not in rest_decoded:
return identifier, None, None
first_seg, remainder = rest_decoded.split('/', 1)
if first_seg.lower().endswith(('.zip', '.rar', '.7z')):
return identifier, first_seg, remainder
return identifier, None, None
except Exception:
return None, None, None
# --- File d'attente de téléchargements (worker) ---
def download_queue_worker():
"""Worker qui surveille la file d'attente et lance le prochain téléchargement si aucun n'est actif."""
@@ -821,6 +855,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
cancel_events[task_id] = threading.Event()
def download_thread():
nonlocal url
try:
# IMPORTANT: Créer l'entrée dans config.history dès le début avec status "Downloading"
# pour que l'interface web puisse afficher le téléchargement en cours
@@ -1059,14 +1094,67 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
download_headers = headers.copy()
download_headers['Accept'] = 'application/octet-stream, */*'
download_headers['Referer'] = 'https://myrient.erista.me/'
archive_cookie = load_archive_org_cookie()
archive_alt_urls = []
meta_json = None
# Préparation spécifique archive.org : récupérer quelques pages pour obtenir cookies éventuels
# Préparation spécifique archive.org : normaliser URL + récupérer cookies/metadata
if 'archive.org/download/' in url:
try:
pre_id = url.split('/download/')[1].split('/')[0]
session.get('https://archive.org/robots.txt', timeout=20)
session.get(f'https://archive.org/metadata/{pre_id}', timeout=20)
parsed = urllib.parse.urlsplit(url)
parts = parsed.path.split('/download/', 1)
pre_id = None
rest_decoded = None
if len(parts) == 2:
after = parts[1]
pre_id = after.split('/', 1)[0]
rest = after[len(pre_id):]
if rest.startswith('/'):
rest = rest[1:]
rest_decoded = urllib.parse.unquote(rest)
rest_encoded = urllib.parse.quote(rest_decoded, safe='/') if rest_decoded else ''
new_path = f"/download/{pre_id}/" + rest_encoded
url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment))
logger.debug(f"URL archive.org normalisée: {url}")
if not pre_id:
pre_id = url.split('/download/')[1].split('/')[0]
download_headers['Referer'] = f"https://archive.org/details/{pre_id}"
download_headers['Origin'] = 'https://archive.org'
if archive_cookie:
download_headers['Cookie'] = archive_cookie
if archive_cookie:
# Apply cookie to session for redirects to ia*.us.archive.org
for pair in archive_cookie.split(';'):
if '=' in pair:
name, value = pair.split('=', 1)
session.cookies.set(name.strip(), value.strip(), domain='.archive.org')
session.get('https://archive.org/robots.txt', timeout=20, headers={'Cookie': archive_cookie} if archive_cookie else None)
meta_resp = session.get(f'https://archive.org/metadata/{pre_id}', timeout=20, headers={'Cookie': archive_cookie} if archive_cookie else None)
if meta_resp.status_code == 200:
try:
meta_json = meta_resp.json()
except Exception:
meta_json = None
logger.debug(f"Pré-chargement cookies/metadata archive.org pour {pre_id}")
# Construire des URLs alternatives pour archive interne
identifier, archive_name, inner_path = _split_archive_org_path(url)
if identifier and archive_name and inner_path:
# Variante sans préfixe archive
archive_alt_urls.append(f"https://archive.org/download/{identifier}/" + urllib.parse.quote(inner_path, safe='/'))
# Variante filename
archive_alt_urls.append(f"https://archive.org/download/{identifier}/{archive_name}?filename=" + urllib.parse.quote(inner_path, safe='/'))
# Variante view_archive.php via serveur/dir metadata
if meta_json:
server = meta_json.get('server')
directory = meta_json.get('dir')
if server and directory:
archive_path = f"{directory}/{archive_name}"
view_url = f"https://{server}/view_archive.php?archive=" + urllib.parse.quote(archive_path, safe='/') + "&file=" + urllib.parse.quote(inner_path, safe='/')
# Prioriser view_archive.php (cas valide observe dans le navigateur)
archive_alt_urls.insert(0, view_url)
except Exception as e:
logger.debug(f"Pré-chargement archive.org ignoré: {e}")
@@ -1087,19 +1175,22 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
header_variants = [
download_headers,
{ # Variante sans Referer spécifique
'User-Agent': headers['User-Agent'],
'User-Agent': headers.get('User-Agent', download_headers.get('User-Agent', 'Mozilla/5.0')),
'Accept': 'application/octet-stream,*/*;q=0.8',
'Accept-Language': headers['Accept-Language'],
'Connection': 'keep-alive'
'Accept-Language': headers.get('Accept-Language', 'en-US,en;q=0.5'),
'Connection': 'keep-alive',
**({'Cookie': archive_cookie} if archive_cookie else {})
},
{ # Variante minimaliste type curl
'User-Agent': 'curl/8.4.0',
'Accept': '*/*'
'Accept': '*/*',
**({'Cookie': archive_cookie} if archive_cookie else {})
},
{ # Variante avec Referer archive.org
'User-Agent': headers['User-Agent'],
'User-Agent': headers.get('User-Agent', download_headers.get('User-Agent', 'Mozilla/5.0')),
'Accept': '*/*',
'Referer': 'https://archive.org/'
'Referer': 'https://archive.org/',
**({'Cookie': archive_cookie} if archive_cookie else {})
}
]
response = None
@@ -1117,7 +1208,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
# Mettre à jour le fichier web
# Plus besoin de update_web_progress
logger.debug(f"Tentative téléchargement {attempt}/{len(header_variants)} avec headers: {hv}")
logger.debug(f"Tentative téléchargement {attempt}/{len(header_variants)} avec headers: {_redact_headers(hv)}")
# Timeout plus long pour archive.org, avec tuple (connect_timeout, read_timeout)
timeout_val = (60, 90) if 'archive.org' in url else 30
r = session.get(url, stream=True, timeout=timeout_val, allow_redirects=True, headers=hv)
@@ -1161,13 +1252,36 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
time.sleep(2)
if response is None:
if archive_alt_urls and (last_status in (401, 403) or last_error_type in ("timeout", "connection", "request")):
for alt_url in archive_alt_urls:
try:
timeout_val = (45, 90)
logger.debug(f"Tentative archive.org alt URL: {alt_url}")
alt_headers = download_headers.copy()
try:
alt_host = urllib.parse.urlsplit(alt_url).netloc
if alt_host.startswith("ia") and alt_host.endswith(".archive.org"):
alt_headers["Referer"] = f"https://{alt_host}/"
alt_headers["Origin"] = "https://archive.org"
except Exception:
pass
r = session.get(alt_url, stream=True, timeout=timeout_val, allow_redirects=True, headers=alt_headers)
if r.status_code not in (401, 403):
r.raise_for_status()
response = r
url = alt_url
break
except Exception as e:
logger.debug(f"Alt URL archive.org échec: {e}")
# Fallback metadata archive.org pour message clair
if 'archive.org/download/' in url:
try:
identifier = url.split('/download/')[1].split('/')[0]
meta_resp = session.get(f'https://archive.org/metadata/{identifier}', timeout=30)
if meta_resp.status_code == 200:
meta_json = meta_resp.json()
if meta_json is None:
meta_resp = session.get(f'https://archive.org/metadata/{identifier}', timeout=30)
if meta_resp.status_code == 200:
meta_json = meta_resp.json()
if meta_json:
if meta_json.get('is_dark'):
raise requests.HTTPError(f"Item archive.org restreint (is_dark=true): {identifier}")
if not meta_json.get('files'):
@@ -1176,7 +1290,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
available = [f.get('name') for f in meta_json.get('files', [])][:10]
raise requests.HTTPError(f"Accès refusé (HTTP {last_status}). Fichiers disponibles exemples: {available}")
else:
raise requests.HTTPError(f"HTTP {last_status} & metadata {meta_resp.status_code} pour {identifier}")
raise requests.HTTPError(f"HTTP {last_status} & metadata indisponible pour {identifier}")
except requests.HTTPError:
raise
except Exception as e:
@@ -1365,6 +1479,21 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
logger.error(f"Exception lors de l'extraction RAR: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction RAR {game_name}: {str(e)}"
elif extension == ".7z":
try:
success, msg = extract_7z(dest_path, dest_dir, url)
if success:
logger.debug(f"Extraction 7z réussie: {msg}")
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction 7z: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction 7z: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction 7z {game_name}: {str(e)}"
else:
logger.warning(f"Type d'archive non supporté: {extension}")
result[0] = True
@@ -1415,7 +1544,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
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"]:
if "url" in entry and entry["url"] == url and entry["status"] in ["Downloading", "Téléchargement", "Extracting", "Converting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
@@ -1487,7 +1616,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
logger.debug(f"[DRAIN_QUEUE] Processing final message: success={success}, message={message[:100] if message else 'None'}")
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"]:
if "url" in entry and entry["url"] == url and entry["status"] in ["Downloading", "Téléchargement", "Extracting", "Converting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
@@ -1530,17 +1659,21 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
keys_info = load_api_keys()
config.API_KEY_1FICHIER = keys_info.get('1fichier', '')
config.API_KEY_ALLDEBRID = keys_info.get('alldebrid', '')
config.API_KEY_DEBRIDLINK = keys_info.get('debridlink', '')
config.API_KEY_REALDEBRID = keys_info.get('realdebrid', '')
if not config.API_KEY_1FICHIER and config.API_KEY_ALLDEBRID:
logger.debug("Clé 1fichier absente, utilisation fallback AllDebrid")
if not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and config.API_KEY_REALDEBRID:
logger.debug("Clé 1fichier & AllDebrid absentes, utilisation fallback RealDebrid")
elif not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and not config.API_KEY_REALDEBRID:
logger.debug("Aucune clé API disponible (1fichier, AllDebrid, RealDebrid)")
if not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and config.API_KEY_DEBRIDLINK:
logger.debug("Clé 1fichier & AllDebrid absentes, utilisation fallback Debrid-Link")
if not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and not config.API_KEY_DEBRIDLINK and config.API_KEY_REALDEBRID:
logger.debug("Clé 1fichier, AllDebrid & Debrid-Link absentes, utilisation fallback RealDebrid")
elif not config.API_KEY_1FICHIER and not config.API_KEY_ALLDEBRID and not config.API_KEY_DEBRIDLINK and not config.API_KEY_REALDEBRID:
logger.debug("Aucune clé API disponible (1fichier, AllDebrid, Debrid-Link, RealDebrid)")
logger.debug(f"Début téléchargement 1fichier: {game_name} depuis {url}, is_zip_non_supported={is_zip_non_supported}, task_id={task_id}")
logger.debug(
f"Clé API 1fichier: {'présente' if config.API_KEY_1FICHIER else 'absente'} / "
f"AllDebrid: {'présente' if config.API_KEY_ALLDEBRID else 'absente'} / "
f"Debrid-Link: {'présente' if config.API_KEY_DEBRIDLINK else 'absente'} / "
f"RealDebrid: {'présente' if config.API_KEY_REALDEBRID else 'absente'} (reloaded={keys_info.get('reloaded')})"
)
result = [None, None]
@@ -1586,7 +1719,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
if task_id not in cancel_events:
cancel_events[task_id] = threading.Event()
provider_used = None # '1F', 'AD', 'RD'
provider_used = None # '1F', 'AD', 'DL', 'RD'
def _set_provider_in_history(pfx: str):
try:
@@ -1945,6 +2078,92 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
logger.warning(f"AllDebrid status != success: {ad_json}")
except Exception as e:
logger.error(f"Erreur AllDebrid fallback: {e}")
# Tentative Debrid-Link si pas de final_url
if not final_url and getattr(config, 'API_KEY_DEBRIDLINK', ''):
logger.debug("Tentative fallback Debrid-Link (downloader/add)")
try:
dl_key = config.API_KEY_DEBRIDLINK
headers_dl = {
"Authorization": f"Bearer {dl_key}",
"Content-Type": "application/json",
}
payload_dl = {"url": link}
dl_resp = requests.post(
"https://debrid-link.com/api/v2/downloader/add",
json=payload_dl,
headers=headers_dl,
timeout=30
)
dl_status = dl_resp.status_code
raw_text_dl = None
dl_json = None
try:
raw_text_dl = dl_resp.text
except Exception:
pass
try:
dl_json = dl_resp.json()
except Exception:
dl_json = None
logger.debug(f"Réponse Debrid-Link code={dl_status} body_snippet={(raw_text_dl[:120] + '...') if raw_text_dl and len(raw_text_dl) > 120 else raw_text_dl}")
DEBRIDLINK_ERROR_MAP = {
"badToken": "DL: Invalid API key",
"notDebrid": "DL: Host unavailable",
"hostNotValid": "DL: Unsupported host",
"fileNotFound": "DL: File not found",
"fileNotAvailable": "DL: File temporarily unavailable",
"badFileUrl": "DL: Invalid link",
"badFilePassword": "DL: Invalid file password",
"notFreeHost": "DL: Premium account only",
"maintenanceHost": "DL: Host in maintenance",
"noServerHost": "DL: No server available",
"maxLink": "DL: Daily link limit reached",
"maxLinkHost": "DL: Daily host limit reached",
"maxData": "DL: Daily data limit reached",
"maxDataHost": "DL: Daily host data limit reached",
"disabledServerHost": "DL: Server or VPN not allowed",
"floodDetected": "DL: Rate limit reached",
}
error_message = None
error_message_raw = None
if dl_json and isinstance(dl_json, dict):
if dl_json.get('success') is True:
value = dl_json.get('value') or {}
if isinstance(value, dict):
final_url = value.get('downloadUrl') or value.get('downloadURL') or value.get('link') or value.get('url')
filename = value.get('name') or value.get('filename') or filename or game_name
else:
error_code = dl_json.get('error')
if error_code:
error_message = DEBRIDLINK_ERROR_MAP.get(error_code, f"DL: {error_code}")
error_message_raw = str(error_code)
if dl_status in (200, 201) and final_url:
logger.debug("Débridage réussi via Debrid-Link")
provider_used = 'DL'
_set_provider_in_history(provider_used)
elif not final_url:
if not error_message:
if dl_status == 401:
error_message = "DL: Unauthorized (401)"
elif dl_status == 429:
error_message = "DL: Rate limited (429)"
elif dl_status >= 500:
error_message = f"DL: Server error ({dl_status})"
else:
error_message = f"DL: Unexpected status ({dl_status})"
error_message_raw = raw_text_dl or error_message
logger.warning(f"Debrid-Link fallback échec: {error_message}")
result[0] = False
result[1] = error_message
try:
if isinstance(result, list):
result.append({"raw_error_debridlink": error_message_raw})
except Exception:
pass
except Exception as e:
logger.error(f"Exception Debrid-Link fallback: {e}")
# Tentative RealDebrid si pas de final_url
if not final_url and getattr(config, 'API_KEY_REALDEBRID', ''):
logger.debug("Tentative fallback RealDebrid (unlock)")
@@ -2171,9 +2390,9 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
content_length = head_response.headers.get('content-length')
if content_length:
remote_size = int(content_length)
logger.debug(f"Taille du fichier serveur (AllDebrid/RealDebrid): {remote_size} octets")
logger.debug(f"Taille du fichier serveur (AllDebrid/Debrid-Link/RealDebrid): {remote_size} octets")
except Exception as e:
logger.debug(f"Impossible de vérifier la taille serveur (AllDebrid/RealDebrid): {e}")
logger.debug(f"Impossible de vérifier la taille serveur (AllDebrid/Debrid-Link/RealDebrid): {e}")
# Vérifier si le fichier existe déjà (exact ou avec autre extension)
file_found = False
@@ -2401,6 +2620,21 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
logger.error(f"Exception lors de l'extraction RAR: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction RAR {game_name}: {str(e)}"
elif extension == ".7z":
try:
success, msg = extract_7z(dest_path, dest_dir, url)
logger.debug(f"Extraction 7z terminée: {msg}")
if success:
result[0] = True
result[1] = _("network_download_extract_ok").format(game_name)
else:
logger.error(f"Erreur extraction 7z: {msg}")
result[0] = False
result[1] = _("network_extraction_failed").format(msg)
except Exception as e:
logger.error(f"Exception lors de l'extraction 7z: {str(e)}")
result[0] = False
result[1] = f"Erreur extraction 7z {game_name}: {str(e)}"
else:
logger.warning(f"Type d'archive non supporté: {extension}")
result[0] = True
@@ -2456,7 +2690,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
success, message = data[1], data[2]
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"]:
if "url" in entry and entry["url"] == url and entry["status"] in ["Downloading", "Téléchargement", "Extracting", "Converting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message
@@ -2511,7 +2745,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
logger.debug(f"[1F_DRAIN_QUEUE] Processing final message: success={success}, message={message[:100] if message else 'None'}")
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"]:
if "url" in entry and entry["url"] == url and entry["status"] in ["Downloading", "Téléchargement", "Extracting", "Converting"]:
entry["status"] = "Download_OK" if success else "Erreur"
entry["progress"] = 100 if success else 0
entry["message"] = message

View File

@@ -17,7 +17,7 @@ import re
import config # paths, settings, SAVE_FOLDER, etc.
from utils import load_api_keys as _prime_api_keys # ensure API key files are created
import network as network_mod # for progress_queues access
from utils import load_sources, load_games, is_extension_supported, load_extensions_json, sanitize_filename, extract_zip_data
from utils import load_sources, load_games, is_extension_supported, load_extensions_json, sanitize_filename
from history import load_history, save_history, add_to_history
from network import download_rom, download_from_1fichier, is_1fichier_url
from rgsx_settings import get_sources_zip_url
@@ -277,7 +277,7 @@ def cmd_games(args):
suggestions = [] # (priority, score, game_obj)
# 1) Substring match (full or sans extension) priority 0, score = position
for g in games:
title = g[0] if isinstance(g, (list, tuple)) and g else None
title = g.name
if not title:
continue
t_lower = title.lower()
@@ -303,7 +303,7 @@ def cmd_games(args):
# 2) Ordered non-contiguous tokens (priority 1)
if q_tokens:
for g in games:
title = g[0] if isinstance(g, (list, tuple)) and g else None
title = g.name
if not title:
continue
tt = _tokens(title)
@@ -313,7 +313,7 @@ def cmd_games(args):
# 3) All tokens present, any order (priority 2), score = token set size
if q_tokens:
for g in games:
title = g[0] if isinstance(g, (list, tuple)) and g else None
title = g.name
if not title:
continue
t_tokens = set(_tokens(title))
@@ -322,12 +322,12 @@ def cmd_games(args):
# Deduplicate by title keeping best (lowest priority, then score)
best = {}
for prio, score, g in suggestions:
title = g[0] if isinstance(g, (list, tuple)) and g else str(g)
title = g.name
key = title.lower()
cur = best.get(key)
if cur is None or (prio, score) < (cur[0], cur[1]):
best[key] = (prio, score, g)
ranked = sorted(best.values(), key=lambda x: (x[0], x[1], (x[2][0] if isinstance(x[2], (list, tuple)) and x[2] else str(x[2])).lower()))
ranked = sorted(best.values(), key=lambda x: (x[0], x[1], (x[2].name if isinstance(x[2], (list, config.Game)) and x[2] else str(x[2])).lower()))
games = [g for _, _, g in ranked]
# Table: Name (60) | Size (12) to allow "xxxx.xx MiB"
NAME_W = 60
@@ -344,7 +344,7 @@ def cmd_games(args):
print(header)
print(border)
for g in games:
title = g[0] if isinstance(g, (list, tuple)) and g else str(g)
title = g.name
size_val = ''
if isinstance(g, (list, tuple)) and len(g) >= 3:
size_val = display_size(g[2])
@@ -447,11 +447,11 @@ def cmd_download(args):
def _tokens(s: str) -> list[str]:
return re.findall(r"[a-z0-9]+", s.lower())
def _game_title(g) -> str | None:
return g[0] if isinstance(g, (list, tuple)) and g else None
def _game_title(g: config.Game) -> str | None:
return g.name
def _game_url(g) -> str | None:
return g[1] if isinstance(g, (list, tuple)) and len(g) > 1 else None
def _game_url(g: config.Game) -> str | None:
return g.url
# 1) Exact match (case-insensitive), with and without extension
match = None
@@ -561,8 +561,8 @@ def cmd_download(args):
size_val = ''
size_raw = None
for g in games:
if isinstance(g, (list, tuple)) and g and g[0] == title and len(g) >= 3:
size_raw = g[2]
if g.name == title:
size_raw = g.size
break
if size_raw is not None:
size_val = display_size(size_raw)

View File

@@ -25,6 +25,7 @@ from utils import load_sources, load_games, extract_data
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
from config import Game
try:
from watchdog.observers import Observer # type: ignore
@@ -161,7 +162,7 @@ def get_cached_sources() -> tuple[list[dict], str, datetime]:
return copy.deepcopy(platforms), etag, last_modified
def get_cached_games(platform: str) -> tuple[list[tuple], str, datetime]:
def get_cached_games(platform: str) -> tuple[list[Game], str, datetime]:
"""Return cached games list for platform with metadata."""
now = time.time()
with cache_lock:
@@ -696,14 +697,14 @@ class RGSXHandler(BaseHTTPRequestHandler):
elif games_last_modified:
latest_modified = games_last_modified
for game in games:
game_name = game[0] if isinstance(game, (list, tuple)) else str(game)
game_name = game.name
game_name_lower = game_name.lower()
if all(word in game_name_lower for word in search_words):
matching_games.append({
'game_name': game_name,
'platform': platform_name,
'url': game[1] if len(game) > 1 and isinstance(game, (list, tuple)) else None,
'size': normalize_size(game[2] if len(game) > 2 and isinstance(game, (list, tuple)) else None, self._get_language_from_cookies())
'url': game.url,
'size': normalize_size(game.size, self._get_language_from_cookies())
})
except Exception as e:
logger.debug(f"Erreur lors de la recherche dans {platform_name}: {e}")
@@ -750,9 +751,9 @@ class RGSXHandler(BaseHTTPRequestHandler):
games, _, games_last_modified = get_cached_games(platform_name)
games_formatted = [
{
'name': g[0],
'url': g[1] if len(g) > 1 else None,
'size': normalize_size(g[2] if len(g) > 2 else None, lang)
'name': g.name,
'url': g.url,
'size': normalize_size(g.size, lang)
}
for g in games
]
@@ -882,6 +883,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
settings['api_keys'] = {
'1fichier': api_keys_data.get('1fichier', ''),
'alldebrid': api_keys_data.get('alldebrid', ''),
'debridlink': api_keys_data.get('debridlink', ''),
'realdebrid': api_keys_data.get('realdebrid', '')
}
@@ -1134,7 +1136,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
if game_name_param and game_index is None:
game_index = None
for idx, game in enumerate(games):
current_game_name = game[0] if isinstance(game, (list, tuple)) else str(game)
current_game_name = game.name
if current_game_name == game_name_param:
game_index = idx
break
@@ -1155,8 +1157,8 @@ class RGSXHandler(BaseHTTPRequestHandler):
return
game = games[game_index]
game_name = game[0]
game_url = game[1] if len(game) > 1 else None
game_name = game.name
game_url = game.url
if not game_url:
self._send_json({
@@ -2068,18 +2070,47 @@ def run_server(host='0.0.0.0', port=5000):
class ReuseAddrHTTPServer(HTTPServer):
allow_reuse_address = True
# Tuer les processus existants utilisant le port
# Tuer les processus existants utilisant le port (plateforme spécifique)
try:
import subprocess
result = subprocess.run(['lsof', '-ti', f':{port}'], capture_output=True, text=True, timeout=2)
pids = result.stdout.strip().split('\n')
for pid in pids:
if pid:
try:
subprocess.run(['kill', '-9', pid], timeout=2)
logger.info(f"Processus {pid} tué (port {port} libéré)")
except Exception as e:
logger.warning(f"Impossible de tuer le processus {pid}: {e}")
# Windows: utiliser netstat + taskkill
if os.name == 'nt' or getattr(config, 'OPERATING_SYSTEM', '').lower() == 'windows':
try:
netstat = subprocess.run(['netstat', '-ano'], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=3)
lines = netstat.stdout.splitlines()
pids = set()
for line in lines:
parts = line.split()
if len(parts) >= 5:
local = parts[1]
pid = parts[-1]
if local.endswith(f':{port}'):
pids.add(pid)
for pid in pids:
# Safer: ignore PID 0 and non-numeric entries (system / header lines)
if not pid or not pid.isdigit():
continue
pid_int = int(pid)
if pid_int <= 0:
continue
try:
subprocess.run(['taskkill', '/PID', pid, '/F'], timeout=3)
logger.info(f"Processus {pid} tué (port {port} libéré) [Windows]")
except Exception as e:
logger.warning(f"Impossible de tuer le processus {pid}: {e}")
except Exception as e:
logger.debug(f"Windows port release check failed: {e}")
else:
# Unix-like: utiliser lsof + kill
result = subprocess.run(['lsof', '-ti', f':{port}'], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=2)
pids = result.stdout.strip().split('\n')
for pid in pids:
if pid:
try:
subprocess.run(['kill', '-9', pid], timeout=2)
logger.info(f"Processus {pid} tué (port {port} libéré)")
except Exception as e:
logger.warning(f"Impossible de tuer le processus {pid}: {e}")
except Exception as e:
logger.warning(f"Impossible de libérer le port {port}: {e}")

View File

@@ -2115,6 +2115,12 @@
<input type="password" id="setting-api-alldebrid" value="${settings.api_keys?.alldebrid || ''}"
placeholder="Enter AllDebrid API key">
</div>
<div style="margin-bottom: 15px;">
<label>Debrid-Link API Key</label>
<input type="password" id="setting-api-debridlink" value="${settings.api_keys?.debridlink || ''}"
placeholder="Enter Debrid-Link API key">
</div>
<div style="margin-bottom: 20px;">
<label>RealDebrid API Key</label>
@@ -2187,6 +2193,7 @@
api_keys: {
'1fichier': document.getElementById('setting-api-1fichier')?.value.trim() || '',
'alldebrid': document.getElementById('setting-api-alldebrid')?.value.trim() || '',
'debridlink': document.getElementById('setting-api-debridlink')?.value.trim() || '',
'realdebrid': document.getElementById('setting-api-realdebrid')?.value.trim() || ''
},
game_filters: {

View File

@@ -1,4 +1,6 @@
from pathlib import Path
import shutil
import requests # type: ignore
import re
import json
import os
@@ -6,7 +8,7 @@ import logging
import platform
import subprocess
import config
from config import HEADLESS
from config import HEADLESS, Game
try:
if not HEADLESS:
import pygame # type: ignore
@@ -171,6 +173,121 @@ DO NOT share this file publicly as it may contain sensitive information.
return (False, str(e), None)
VERSIONCLEAN_SERVICE_NAME = "versionclean"
VERSIONCLEAN_BACKUP_PATH = "/usr/bin/batocera-version.bak"
def _get_enabled_services():
"""Retourne la liste des services activés dans batocera-settings, ou None si indisponible."""
try:
result = subprocess.run(
["batocera-settings-get", "system.services"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
logger.warning(f"batocera-settings-get failed: {result.stderr}")
return None
return result.stdout.split()
except FileNotFoundError:
logger.warning("batocera-settings-get command not found")
return None
except Exception as e:
logger.warning(f"Failed to read enabled services: {e}")
return None
def _ensure_versionclean_service():
"""Installe et active versionclean si nécessaire.
- Installe uniquement si le service n'est pas déjà présent.
- Active uniquement si le service n'est pas déjà activé.
- Démarre uniquement si le nettoyage n'est pas déjà appliqué.
"""
try:
if config.OPERATING_SYSTEM != "Linux":
return (True, "Versionclean skipped (non-Linux)")
services_dir = "/userdata/system/services"
service_file = os.path.join(services_dir, VERSIONCLEAN_SERVICE_NAME)
source_file = os.path.join(config.APP_FOLDER, "assets", "progs", VERSIONCLEAN_SERVICE_NAME)
if not os.path.exists(service_file):
try:
os.makedirs(services_dir, exist_ok=True)
except Exception as e:
error_msg = f"Failed to create services directory: {str(e)}"
logger.error(error_msg)
return (False, error_msg)
if not os.path.exists(source_file):
error_msg = f"Source service file not found: {source_file}"
logger.error(error_msg)
return (False, error_msg)
try:
shutil.copy2(source_file, service_file)
os.chmod(service_file, 0o755)
logger.info(f"Versionclean service installed: {service_file}")
except Exception as e:
error_msg = f"Failed to copy versionclean service file: {str(e)}"
logger.error(error_msg)
return (False, error_msg)
else:
logger.debug("Versionclean service already present, skipping install")
enabled_services = _get_enabled_services()
if enabled_services is None or VERSIONCLEAN_SERVICE_NAME not in enabled_services:
try:
result = subprocess.run(
["batocera-services", "enable", VERSIONCLEAN_SERVICE_NAME],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
error_msg = f"batocera-services enable versionclean failed: {result.stderr}"
logger.error(error_msg)
return (False, error_msg)
logger.debug(f"Versionclean enabled: {result.stdout}")
except FileNotFoundError:
error_msg = "batocera-services command not found"
logger.error(error_msg)
return (False, error_msg)
except Exception as e:
error_msg = f"Failed to enable versionclean: {str(e)}"
logger.error(error_msg)
return (False, error_msg)
else:
logger.debug("Versionclean already enabled, skipping enable")
if os.path.exists(VERSIONCLEAN_BACKUP_PATH):
logger.debug("Versionclean already active (backup present), skipping start")
return (True, "Versionclean already active")
try:
result = subprocess.run(
["batocera-services", "start", VERSIONCLEAN_SERVICE_NAME],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
logger.warning(f"batocera-services start versionclean warning: {result.stderr}")
else:
logger.debug(f"Versionclean started: {result.stdout}")
except Exception as e:
logger.warning(f"Failed to start versionclean (non-critical): {str(e)}")
return (True, "Versionclean ensured")
except Exception as e:
error_msg = f"Unexpected versionclean error: {str(e)}"
logger.exception(error_msg)
return (False, error_msg)
def toggle_web_service_at_boot(enable: bool):
"""Active ou désactive le service web au démarrage de Batocera.
@@ -203,6 +320,11 @@ def toggle_web_service_at_boot(enable: bool):
error_msg = f"Failed to create services directory: {str(e)}"
logger.error(error_msg)
return (False, error_msg)
# 1b. Assurer versionclean (install/enable/start si nécessaire)
ensure_ok, ensure_msg = _ensure_versionclean_service()
if not ensure_ok:
return (False, ensure_msg)
# 2. Copier le fichier rgsx_web
try:
@@ -339,6 +461,11 @@ def toggle_custom_dns_at_boot(enable: bool):
error_msg = f"Failed to create services directory: {str(e)}"
logger.error(error_msg)
return (False, error_msg)
# 1b. Assurer versionclean (install/enable/start si nécessaire)
ensure_ok, ensure_msg = _ensure_versionclean_service()
if not ensure_ok:
return (False, ensure_msg)
# 2. Copier le fichier custom_dns
try:
@@ -479,6 +606,149 @@ def check_custom_dns_status():
return False
CONNECTION_STATUS_TTL_SECONDS = 120
def get_connection_status_targets():
"""Retourne la liste des sites à vérifier pour le status de connexion."""
return [
{
"key": "retrogamesets",
"label": "Retrogamesets.fr",
"url": "https://retrogamesets.fr",
"category": "updates",
},
{
"key": "github",
"label": "GitHub.com",
"url": "https://github.com",
"category": "updates",
},
{
"key": "myrient",
"label": "Myrient.erista.me",
"url": "https://myrient.erista.me",
"category": "sources",
},
{
"key": "1fichier",
"label": "1fichier.com",
"url": "https://1fichier.com",
"category": "sources",
},
{
"key": "archive",
"label": "Archive.org",
"url": "https://archive.org",
"category": "sources",
},
]
def _check_url_connectivity(url: str, timeout: int = 6) -> bool:
"""Teste rapidement la connectivité à une URL (DNS + HTTPS)."""
headers = {"User-Agent": "RGSX-Connectivity/1.0"}
try:
try:
try:
response = requests.head(url, timeout=timeout, allow_redirects=True, headers=headers)
if response.status_code < 500:
return True
except Exception:
pass
try:
response = requests.get(url, timeout=timeout, allow_redirects=True, stream=True, headers=headers)
return response.status_code < 500
except Exception:
return False
except Exception:
import urllib.request
try:
req = urllib.request.Request(url, method="HEAD", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status < 500
except Exception:
try:
req = urllib.request.Request(url, method="GET", headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status < 500
except Exception:
return False
except Exception:
return False
def start_connection_status_check(force: bool = False) -> None:
"""Lance un check asynchrone des sites (avec cache/TTL)."""
try:
now = time.time()
if getattr(config, "connection_status_in_progress", False):
return
last_ts = getattr(config, "connection_status_timestamp", 0.0) or 0.0
if not force and last_ts and now - last_ts < CONNECTION_STATUS_TTL_SECONDS:
return
targets = get_connection_status_targets()
status = getattr(config, "connection_status", {})
if not isinstance(status, dict):
status = {}
if not status:
for item in targets:
status[item["key"]] = None
config.connection_status = status
config.connection_status_in_progress = True
config.connection_status_progress = {"done": 0, "total": len(targets)}
def _worker():
try:
results = {}
done = 0
total = len(targets)
for item in targets:
results[item["key"]] = _check_url_connectivity(item["url"])
done += 1
config.connection_status_progress = {"done": done, "total": total}
try:
config.needs_redraw = True
except Exception:
pass
config.connection_status.update(results)
config.connection_status_timestamp = time.time()
try:
summary = ", ".join([f"{k}={'OK' if v else 'FAIL'}" for k, v in results.items()])
logger.info(f"Connection status results: {summary}")
except Exception:
pass
try:
config.needs_redraw = True
except Exception:
pass
except Exception as e:
logger.debug(f"Connection status check failed: {e}")
finally:
config.connection_status_in_progress = False
threading.Thread(target=_worker, daemon=True).start()
except Exception as e:
logger.debug(f"Failed to start connection status check: {e}")
def get_connection_status_snapshot():
"""Retourne (status_dict, timestamp, in_progress, progress)."""
status = getattr(config, "connection_status", {})
if not isinstance(status, dict):
status = {}
ts = getattr(config, "connection_status_timestamp", 0.0) or 0.0
in_progress = getattr(config, "connection_status_in_progress", False)
progress = getattr(config, "connection_status_progress", {"done": 0, "total": 0})
if not isinstance(progress, dict):
progress = {"done": 0, "total": 0}
return status, ts, in_progress, progress
_extensions_cache = None # type: ignore
_extensions_json_regenerated = False
@@ -659,7 +929,7 @@ def check_extension_before_download(url, platform, game_name):
is_supported = is_extension_supported(sanitized_name, platform, extensions_data)
extension = os.path.splitext(sanitized_name)[1].lower()
is_archive = extension in (".zip", ".rar")
is_archive = extension in (".zip", ".rar", ".7z")
# Déterminer si le système (dossier) est connu dans extensions_data
dest_folder_name = _get_dest_folder_name(platform)
@@ -886,12 +1156,18 @@ def load_sources():
for platform_name in config.platforms:
games = load_games(platform_name)
config.games_count[platform_name] = len(games)
if config.games_count:
try:
summary = ", ".join([f"{name}: {count}" for name, count in config.games_count.items()])
logger.debug(f"Nombre de jeux par système: {summary}")
except Exception:
pass
return sources
except Exception as e:
logger.error(f"Erreur fusion systèmes + détection jeux: {e}")
return []
def load_games(platform_id):
def load_games(platform_id:str) -> list[Game]:
try:
# Retrouver l'objet plateforme pour accéder éventuellement à 'folder'
platform_dict = None
@@ -958,8 +1234,15 @@ def load_games(platform_id):
else:
logger.warning(f"Format de fichier jeux inattendu pour {platform_id}: {type(data)}")
logger.debug(f"{os.path.basename(game_file)}: {len(normalized)} jeux")
return normalized
if getattr(config, "games_count_log_verbose", False):
logger.debug(f"{os.path.basename(game_file)}: {len(normalized)} jeux")
games_list: list[Game] = []
for name, url, size in normalized:
display_name = Path(name).stem
display_name = display_name.replace(platform_id, "")
games_list.append(Game(name=name, url=url, size=size, display_name=display_name))
return games_list
except Exception as e:
logger.error(f"Erreur lors du chargement des jeux pour {platform_id}: {e}")
return []
@@ -1544,7 +1827,7 @@ def extract_rar(rar_path, dest_dir, url):
os.chmod(os.path.join(root, dir_name), 0o755)
# Gestion plateformes spéciales (uniquement PS3 pour RAR)
success, error_msg = _handle_special_platforms(dest_dir, rar_path, before_dirs)
success, error_msg = _handle_special_platforms(dest_dir, rar_path, before_dirs, url=url)
if not success:
return False, error_msg
@@ -1563,6 +1846,95 @@ def extract_rar(rar_path, dest_dir, url):
except Exception as e:
logger.error(f"Erreur lors de la suppression de {rar_path}: {str(e)}")
def extract_7z(archive_path, dest_dir, url):
"""Extrait le contenu d'un fichier 7z dans le dossier cible."""
try:
os.makedirs(dest_dir, exist_ok=True)
if config.OPERATING_SYSTEM == "Windows":
seven_z_cmd = config.SEVEN_Z_EXE
else:
seven_z_cmd = config.SEVEN_Z_LINUX
try:
if os.path.exists(seven_z_cmd) and not os.access(seven_z_cmd, os.X_OK):
logger.warning("7zz n'est pas exécutable, correction des permissions...")
os.chmod(seven_z_cmd, 0o755)
except Exception as e:
logger.error(f"Erreur lors de la vérification des permissions de 7zz: {e}")
if not os.path.exists(seven_z_cmd):
return False, "7z non trouvé - vérifiez que 7z.exe (Windows) ou 7zz (Linux) est présent dans assets/progs"
# Capture état initial
before_dirs = _capture_directories_before_extraction(dest_dir)
before_items = _capture_all_items_before_extraction(dest_dir)
iso_before = set()
for root, dirs, files in os.walk(dest_dir):
for file in files:
if file.lower().endswith('.iso'):
iso_before.add(os.path.abspath(os.path.join(root, file)))
# Calcul taille totale via 7z l -slt (best effort)
total_size = 0
try:
list_cmd = [seven_z_cmd, "l", "-slt", archive_path]
result = subprocess.run(list_cmd, capture_output=True, text=True)
if result.returncode == 0:
current_size = None
is_dir = False
for line in result.stdout.splitlines():
line = line.strip()
if not line:
if current_size is not None and not is_dir:
total_size += current_size
current_size = None
is_dir = False
continue
if line.startswith("Attributes ="):
attrs = line.split("=", 1)[1].strip()
if "D" in attrs:
is_dir = True
elif line.startswith("Size ="):
try:
current_size = int(line.split("=", 1)[1].strip())
except Exception:
current_size = None
if current_size is not None and not is_dir:
total_size += current_size
except Exception as e:
logger.debug(f"Impossible de calculer la taille 7z: {e}")
if url not in getattr(config, 'download_progress', {}):
config.download_progress[url] = {}
config.download_progress[url].update({
"downloaded_size": 0,
"total_size": total_size,
"status": "Extracting",
"progress_percent": 0
})
config.needs_redraw = True
extract_cmd = [seven_z_cmd, "x", archive_path, f"-o{dest_dir}", "-y"]
logger.debug(f"Commande d'extraction 7z: {' '.join(extract_cmd)}")
result = subprocess.run(extract_cmd, capture_output=True, text=True)
if result.returncode > 2:
error_msg = result.stderr.strip() or f"Erreur extraction 7z (code {result.returncode})"
logger.error(error_msg)
return False, error_msg
if result.returncode != 0:
logger.warning(f"7z a retourné un avertissement (code {result.returncode}): {result.stderr}")
# Gestion plateformes spéciales
success, error_msg = _handle_special_platforms(dest_dir, archive_path, before_dirs, iso_before, url, before_items)
if not success:
return False, error_msg
return _finalize_extraction(archive_path, dest_dir, url)
except Exception as e:
logger.error(f"Erreur lors de l'extraction 7z: {str(e)}")
return False, _("utils_extraction_failed").format(str(e))
def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archive_name=None):
"""Gère le traitement spécifique des jeux PS3.
PS3 Redump (ps3): Décryptage ISO + extraction dans dossier .ps3
@@ -1589,18 +1961,31 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
# MODE PS3 : Décryptage et extraction
# ============================================
logger.info(f"Mode PS3 détecté pour: {archive_name}")
# L'extraction de l'archive est terminée; basculer l'UI en mode conversion/décryptage.
try:
if url:
if url not in getattr(config, 'download_progress', {}):
config.download_progress[url] = {}
config.download_progress[url]["status"] = "Converting"
config.download_progress[url]["progress_percent"] = 0
config.needs_redraw = True
if isinstance(config.history, list):
for entry in config.history:
if entry.get("url") == url and entry.get("status") in ("Extracting", "Téléchargement", "Downloading"):
entry["status"] = "Converting"
entry["progress"] = 0
entry["message"] = "PS3 conversion in progress"
save_history(config.history)
break
except Exception as e:
logger.debug(f"MAJ statut conversion PS3 ignorée: {e}")
try:
# Construire l'URL de la clé en remplaçant le dossier
if url and ("Sony%20-%20PlayStation%203/" in url or "Sony - PlayStation 3/" in url):
key_url = url.replace("Sony%20-%20PlayStation%203/", "Sony%20-%20PlayStation%203%20-%20Disc%20Keys%20TXT/")
key_url = key_url.replace("Sony - PlayStation 3/", "Sony - PlayStation 3 - Disc Keys TXT/")
else:
logger.warning("URL PS3 invalide ou manquante, tentative sans clé distante")
key_url = None
ps3_keys_base_url = "https://retrogamesets.fr/softs/ps3/"
logger.debug(f"URL jeu: {url}")
logger.debug(f"URL clé: {key_url}")
logger.debug(f"Base URL des clés PS3: {ps3_keys_base_url}")
# Chercher le fichier .iso déjà extrait
iso_files = [f for f in os.listdir(dest_dir) if f.endswith('.iso') and not f.endswith('_decrypted.iso')]
@@ -1611,42 +1996,51 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
iso_path = os.path.join(dest_dir, iso_file)
logger.info(f"Fichier ISO trouvé: {iso_path}")
# Étape 1: Télécharger et extraire la clé si URL disponible
# Étape 1: Télécharger directement la clé .dkey depuis la nouvelle source
dkey_path = None
if key_url:
logger.info("Téléchargement de la clé de décryption...")
key_zip_name = os.path.basename(archive_name) if archive_name else "key.zip"
key_zip_path = os.path.join(dest_dir, f"_temp_key_{key_zip_name}")
logger.info("Téléchargement de la clé de décryption (.dkey)...")
candidate_bases = []
def _add_candidate_base(base_name):
if not base_name:
return
cleaned = str(base_name).strip()
if not cleaned:
return
if cleaned.lower().endswith('.dkey'):
cleaned = cleaned[:-5]
if cleaned not in candidate_bases:
candidate_bases.append(cleaned)
if archive_name:
_add_candidate_base(os.path.splitext(os.path.basename(archive_name))[0])
if extracted_basename:
_add_candidate_base(extracted_basename)
_add_candidate_base(os.path.splitext(os.path.basename(iso_file))[0])
for base_name in candidate_bases:
remote_name = f"{base_name}.dkey"
encoded_name = remote_name.replace(" ", "%20")
key_url = f"{ps3_keys_base_url}{encoded_name}"
logger.debug(f"Tentative clé distante: {key_url}")
try:
import requests
response = requests.get(key_url, stream=True, timeout=30)
response.raise_for_status()
with open(key_zip_path, 'wb') as f:
if response.status_code != 200:
logger.debug(f"Clé distante introuvable ({response.status_code}): {remote_name}")
continue
local_dkey_path = os.path.join(dest_dir, remote_name)
with open(local_dkey_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
logger.info(f"Clé téléchargée: {key_zip_path}")
# Extraire la clé
logger.info("Extraction de la clé...")
with zipfile.ZipFile(key_zip_path, 'r') as zf:
dkey_files = [f for f in zf.namelist() if f.endswith('.dkey')]
if not dkey_files:
logger.warning("Aucun fichier .dkey trouvé dans l'archive de clé")
else:
dkey_file = dkey_files[0]
zf.extract(dkey_file, dest_dir)
dkey_path = os.path.join(dest_dir, dkey_file)
logger.info(f"Clé extraite: {dkey_path}")
# Supprimer le ZIP de la clé
os.remove(key_zip_path)
dkey_path = local_dkey_path
logger.info(f"Clé téléchargée: {dkey_path}")
break
except Exception as e:
logger.error(f"Erreur lors du téléchargement/extraction de la clé: {e}")
logger.warning(f"Échec téléchargement clé {remote_name}: {e}")
# Chercher une clé .dkey si pas téléchargée
if not dkey_path:
@@ -2344,24 +2738,25 @@ def set_music_popup(music_name):
config.needs_redraw = True # Forcer le redraw pour afficher le nom de la musique
def load_api_keys(force: bool = False):
"""Charge les clés API (1fichier, AllDebrid, RealDebrid) en une seule passe.
"""Charge les clés API (1fichier, AllDebrid, Debrid-Link, RealDebrid) en une seule passe.
- Crée les fichiers vides s'ils n'existent pas
- Met à jour config.API_KEY_1FICHIER, config.API_KEY_ALLDEBRID, config.API_KEY_REALDEBRID
- Met à jour config.API_KEY_1FICHIER, config.API_KEY_ALLDEBRID, config.API_KEY_DEBRIDLINK, config.API_KEY_REALDEBRID
- Utilise un cache basé sur le mtime pour éviter des relectures
- force=True ignore le cache et relit systématiquement
Retourne: { '1fichier': str, 'alldebrid': str, 'realdebrid': str, 'reloaded': bool }
Retourne: { '1fichier': str, 'alldebrid': str, 'debridlink': str, 'realdebrid': str, 'reloaded': bool }
"""
try:
paths = {
'1fichier': getattr(config, 'API_KEY_1FICHIER_PATH', ''),
'alldebrid': getattr(config, 'API_KEY_ALLDEBRID_PATH', ''),
'debridlink': getattr(config, 'API_KEY_DEBRIDLINK_PATH', ''),
'realdebrid': getattr(config, 'API_KEY_REALDEBRID_PATH', ''),
}
cache_attr = '_api_keys_cache'
if not hasattr(config, cache_attr):
setattr(config, cache_attr, {'1fichier_mtime': None, 'alldebrid_mtime': None, 'realdebrid_mtime': None})
setattr(config, cache_attr, {'1fichier_mtime': None, 'alldebrid_mtime': None, 'debridlink_mtime': None, 'realdebrid_mtime': None})
cache_data = getattr(config, cache_attr)
reloaded = False
@@ -2395,6 +2790,8 @@ def load_api_keys(force: bool = False):
config.API_KEY_1FICHIER = value
elif key_name == 'alldebrid':
config.API_KEY_ALLDEBRID = value
elif key_name == 'debridlink':
config.API_KEY_DEBRIDLINK = value
elif key_name == 'realdebrid':
config.API_KEY_REALDEBRID = value
cache_data[cache_key] = mtime
@@ -2402,6 +2799,7 @@ def load_api_keys(force: bool = False):
return {
'1fichier': getattr(config, 'API_KEY_1FICHIER', ''),
'alldebrid': getattr(config, 'API_KEY_ALLDEBRID', ''),
'debridlink': getattr(config, 'API_KEY_DEBRIDLINK', ''),
'realdebrid': getattr(config, 'API_KEY_REALDEBRID', ''),
'reloaded': reloaded
}
@@ -2410,16 +2808,68 @@ def load_api_keys(force: bool = False):
return {
'1fichier': getattr(config, 'API_KEY_1FICHIER', ''),
'alldebrid': getattr(config, 'API_KEY_ALLDEBRID', ''),
'debridlink': getattr(config, 'API_KEY_DEBRIDLINK', ''),
'realdebrid': getattr(config, 'API_KEY_REALDEBRID', ''),
'reloaded': False
}
def load_archive_org_cookie(force: bool = False) -> str:
"""Charge le cookie Archive.org depuis un fichier texte.
- Fichier: config.ARCHIVE_ORG_COOKIE_PATH
- Accepte soit une ligne brute de cookie, soit une ligne "Cookie: ..."
- Utilise un cache mtime pour éviter les relectures
"""
try:
path = getattr(config, 'ARCHIVE_ORG_COOKIE_PATH', '')
if not path:
return ""
cache_attr = '_archive_cookie_cache'
if not hasattr(config, cache_attr):
setattr(config, cache_attr, {'mtime': None, 'value': ''})
cache_data = getattr(config, cache_attr)
# Créer le fichier vide si absent
try:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
f.write("")
except Exception as ce:
logger.error(f"Impossible de préparer le fichier cookie archive.org: {ce}")
return ""
try:
mtime = os.path.getmtime(path)
except Exception:
mtime = None
if force or (mtime is not None and mtime != cache_data.get('mtime')):
try:
with open(path, 'r', encoding='utf-8') as f:
value = f.read().strip()
except Exception as re:
logger.error(f"Erreur lecture cookie archive.org: {re}")
value = ""
if value.lower().startswith("cookie:"):
value = value.split(":", 1)[1].strip()
cache_data['mtime'] = mtime
cache_data['value'] = value
return cache_data.get('value', '') or ""
except Exception as e:
logger.error(f"Erreur load_archive_org_cookie: {e}")
return ""
def save_api_keys(api_keys: dict):
"""Sauvegarde les clés API (1fichier, AllDebrid, RealDebrid) dans leurs fichiers respectifs.
"""Sauvegarde les clés API (1fichier, AllDebrid, Debrid-Link, RealDebrid) dans leurs fichiers respectifs.
Args:
api_keys: dict avec les clés '1fichier', 'alldebrid', 'realdebrid'
api_keys: dict avec les clés '1fichier', 'alldebrid', 'debridlink', 'realdebrid'
Retourne: True si au moins une clé a été sauvegardée avec succès
"""
@@ -2429,6 +2879,7 @@ def save_api_keys(api_keys: dict):
paths = {
'1fichier': getattr(config, 'API_KEY_1FICHIER_PATH', ''),
'alldebrid': getattr(config, 'API_KEY_ALLDEBRID_PATH', ''),
'debridlink': getattr(config, 'API_KEY_DEBRIDLINK_PATH', ''),
'realdebrid': getattr(config, 'API_KEY_REALDEBRID_PATH', ''),
}
@@ -2456,6 +2907,8 @@ def save_api_keys(api_keys: dict):
config.API_KEY_1FICHIER = value.strip()
elif key_name == 'alldebrid':
config.API_KEY_ALLDEBRID = value.strip()
elif key_name == 'debridlink':
config.API_KEY_DEBRIDLINK = value.strip()
elif key_name == 'realdebrid':
config.API_KEY_REALDEBRID = value.strip()
@@ -2481,6 +2934,9 @@ def load_api_key_1fichier(force: bool = False): # pragma: no cover
def load_api_key_alldebrid(force: bool = False): # pragma: no cover
return load_api_keys(force).get('alldebrid', '')
def load_api_key_debridlink(force: bool = False): # pragma: no cover
return load_api_keys(force).get('debridlink', '')
def load_api_key_realdebrid(force: bool = False): # pragma: no cover
return load_api_keys(force).get('realdebrid', '')
@@ -2493,19 +2949,19 @@ def ensure_api_keys_loaded(force: bool = False): # pragma: no cover
# ------------------------------
def build_provider_paths_string():
"""Retourne une chaîne listant les chemins des fichiers de clés pour affichage/erreurs."""
return f"{getattr(config, 'API_KEY_1FICHIER_PATH', '')} or {getattr(config, 'API_KEY_ALLDEBRID_PATH', '')} or {getattr(config, 'API_KEY_REALDEBRID_PATH', '')}"
return f"{getattr(config, 'API_KEY_1FICHIER_PATH', '')} or {getattr(config, 'API_KEY_ALLDEBRID_PATH', '')} or {getattr(config, 'API_KEY_DEBRIDLINK_PATH', '')} or {getattr(config, 'API_KEY_REALDEBRID_PATH', '')}"
def ensure_download_provider_keys(force: bool = False): # pragma: no cover
"""S'assure que les clés 1fichier/AllDebrid/RealDebrid sont chargées et retourne le dict.
"""S'assure que les clés 1fichier/AllDebrid/Debrid-Link/RealDebrid sont chargées et retourne le dict.
Utilise load_api_keys (cache mtime). force=True invalide le cache.
"""
return load_api_keys(force)
def missing_all_provider_keys(): # pragma: no cover
"""True si aucune des trois clés n'est définie."""
"""True si aucune des clés premium n'est définie."""
keys = load_api_keys(False)
return not keys.get('1fichier') and not keys.get('alldebrid') and not keys.get('realdebrid')
return not keys.get('1fichier') and not keys.get('alldebrid') and not keys.get('debridlink') and not keys.get('realdebrid')
def provider_keys_status(): # pragma: no cover
"""Retourne un dict de présence pour debug/log."""
@@ -2513,6 +2969,7 @@ def provider_keys_status(): # pragma: no cover
return {
'1fichier': bool(keys.get('1fichier')),
'alldebrid': bool(keys.get('alldebrid')),
'debridlink': bool(keys.get('debridlink')),
'realdebrid': bool(keys.get('realdebrid')),
}

View File

@@ -1,3 +1,3 @@
{
"version": "2.5.0.0"
"version": "2.6.0.1"
}

View File

@@ -301,6 +301,7 @@ set PYGAME_HIDE_SUPPORT_PROMPT=1
set SDL_VIDEODRIVER=windows
set SDL_AUDIODRIVER=directsound
set PYTHONWARNINGS=ignore::UserWarning:pygame.pkgdata
set PYTHONIOENCODING=utf-8
:: =============================================================================
:: Configuration multi-ecran
@@ -331,6 +332,7 @@ echo [%DATE% %TIME%] Environment variables set: >> "%LOG_FILE%"
echo [%DATE% %TIME%] RGSX_ROOT=%RGSX_ROOT% >> "%LOG_FILE%"
echo [%DATE% %TIME%] SDL_VIDEODRIVER=%SDL_VIDEODRIVER% >> "%LOG_FILE%"
echo [%DATE% %TIME%] SDL_AUDIODRIVER=%SDL_AUDIODRIVER% >> "%LOG_FILE%"
echo [%DATE% %TIME%] PYTHONIOENCODING=%PYTHONIOENCODING% >> "%LOG_FILE%"
echo.
if defined DISPLAY_NUM (