Compare commits

...

10 Commits

Author SHA1 Message Date
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
22 changed files with 1275 additions and 240 deletions

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,
@@ -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")
@@ -729,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)
@@ -835,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:
@@ -1119,6 +1120,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é

View File

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

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.4"
app_version = "2.6.0.0"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 7
@@ -364,7 +376,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
@@ -389,10 +402,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é)

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 (
@@ -20,7 +20,7 @@ from utils import (
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,
@@ -31,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
@@ -70,6 +72,7 @@ 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
@@ -107,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
@@ -115,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:
@@ -258,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.
@@ -326,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."""
@@ -488,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
@@ -507,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'],
@@ -563,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
@@ -575,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
@@ -586,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
@@ -642,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
@@ -655,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
@@ -718,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")
@@ -726,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)
@@ -2005,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
@@ -2018,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')
@@ -2040,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)
@@ -2050,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)
@@ -2059,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
@@ -3184,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}")
@@ -3316,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

@@ -21,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()
@@ -661,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')
@@ -678,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: "",
@@ -696,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",
@@ -767,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-",
@@ -824,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."""
@@ -954,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:
@@ -1154,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}")
@@ -1283,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
@@ -1310,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"]
@@ -1330,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:
@@ -1363,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:
@@ -1389,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:
@@ -1441,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)
@@ -1527,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"])
@@ -1547,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)
@@ -1620,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)
@@ -1642,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)
@@ -1910,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")),
@@ -1987,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 = []
@@ -2844,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
@@ -2864,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",
@@ -4592,7 +4939,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}",
@@ -232,6 +239,7 @@
"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",
@@ -258,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",
@@ -234,6 +241,7 @@
"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",
@@ -298,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}",
@@ -232,6 +239,7 @@
"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",
@@ -258,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}",
@@ -234,6 +241,7 @@
"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",
@@ -298,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",
@@ -227,6 +234,7 @@
"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",
@@ -258,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",
@@ -233,6 +240,7 @@
"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",
@@ -258,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

@@ -1544,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
@@ -1616,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
@@ -2600,7 +2600,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
@@ -2655,7 +2655,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
]
@@ -1134,7 +1135,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 +1156,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({

View File

@@ -1,3 +1,4 @@
from pathlib import Path
import shutil
import requests # type: ignore
import re
@@ -7,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
@@ -1166,7 +1167,7 @@ def load_sources():
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
@@ -1235,7 +1236,13 @@ def load_games(platform_id):
if getattr(config, "games_count_log_verbose", False):
logger.debug(f"{os.path.basename(game_file)}: {len(normalized)} jeux")
return normalized
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 []
@@ -1820,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
@@ -1954,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')]
@@ -1976,41 +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:
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:

View File

@@ -1,3 +1,3 @@
{
"version": "2.5.0.4"
"version": "2.6.0.0"
}