Compare commits

...

14 Commits

Author SHA1 Message Date
skymike03
21b39c66b9 v2.6.1.4 (2026.03.30)
- Add browser-like headers for file downloads with debrids and enhance AllDebrid link handling
2026-03-30 21:12:59 +02:00
skymike03
42b2204aeb Reverted back to original version after test 2026-03-22 12:25:45 +01:00
skymike03
67a38c45aa ## v2.6.3.1.0 TEST (2026.03.22)
- Test discord auto release changelog
- Test
2026-03-22 12:20:46 +01:00
skymike03
893b73ecc5 Refactor Discord changelog notification step in release workflow 2026-03-22 12:09:32 +01:00
skymike03
5e1a684275 Enhance Discord notifications with changelog and bot details 2026-03-22 12:06:06 +01:00
skymike03
9226a818f3 v2.6.3.1 (test update) 2026-03-22 11:56:58 +01:00
skymike03
2fd1bcaf01 test discord 2026-03-22 11:55:58 +01:00
skymike03
875bf8fa23 v2.6.1.3 (2026.03.21)
- add update changelog on start before applying new update
2026-03-21 18:26:39 +01:00
skymike03
f9cbf0196e v2.6.1.2 (2026.03.21)
- added paging navigation on folder browser and full page list
2026-03-21 17:36:06 +01:00
skymike03
eb86d69895 v2.6.1.1 (2026.21.03)
- Improved History/Downloads table readability by giving more space to game titles and using middle truncation for long names
- Cleaned displayed game names to remove platform/path prefixes from titles
- Improved file matching for downloaded and extracted games, including support for filename variants and tag differences
- Updated Locate file to show all matching files instead of only one path
- Added a Move action from the locate screen, using the existing folder browser to move all listed files to a selected destination
- Added collision-safe file moves and persisted moved paths in history
- Added localized labels/messages for the new move flow
- Fixed a startup crash caused by a translation function name conflict
- Fixed navigation after move so OK and Back work correctly from the locate screen
2026-03-21 17:29:39 +01:00
skymike03
b09b3da371 v2.6.1.0 (2026.03.20)
- Added the IP address to the top-right info badge.
- Added disk usage to the left badge using a used/total(percentage) format.
- Added screen resolution below disk usage on the platforms page.
- Improved entry speed for global cross-platform search.
- Fixed virtual keyboard positioning in the search screen.
- Adjusted the platform grid to avoid overlapping the footer on small resolutions.
- Made the header badges responsive to prevent overlap on smaller screens.
- Made the footer responsive with automatic scaling of text, icons, and spacing.
- Asking to update gamelist once a day
2026-03-20 19:14:27 +01:00
skymike03
0915a90fbe Merge branch 'main' of https://github.com/RetroGameSets/RGSX 2026-03-17 23:24:33 +01:00
skymike03
3ae3c151eb v2.6.0.3 (2025.03.17)
- Add support and donation information to release notes
- Add normalize game name for roms scanning (ie. Game (USA).ext will be shown owned for a rom named only "Game.ext"
- Add fulscreen/windowed mode in Settings > Display with auto resize window
2026-03-17 23:24:31 +01:00
RGS
7460b12d71 Delete snes directory error 2026-03-17 23:16:49 +01:00
17 changed files with 1547 additions and 289 deletions

View File

@@ -148,3 +148,16 @@ jobs:
dist/RGSX_full_latest.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Send changelog to Discord
run: |
CHANGELOG=$(git log -1 --format=%B ${{ github.ref_name }} | sed ':a;N;$!ba;s/\n/\\n/g')
curl -H "Content-Type: application/json" \
-X POST \
-d "{
\"username\": \"RGSX Releases Bot\",
\"avatar_url\": \"https://retrogamesets.fr/assets/images/avatar.png\",
\"content\": \"📦 **RGSX ${{ github.ref_name }}**\n\n📝 **Changelog :**\n${CHANGELOG}\"
}" \
${{ secrets.DISCORD_WEBHOOK }}

View File

@@ -32,10 +32,10 @@ from display import (
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,
draw_toast, show_toast, THEME_COLORS
draw_toast, show_toast, THEME_COLORS, sync_display_metrics
)
from language import _
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, cancel_all_downloads, download_queue_worker
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, apply_pending_update, cancel_all_downloads, download_queue_worker
from controls import handle_controls, validate_menu_state, process_key_repeats, get_emergency_controls
from controls_mapper import map_controls, draw_controls_mapping, get_actions
from controls import load_controls_config
@@ -45,7 +45,7 @@ from utils import (
)
from history import load_history, save_history, load_downloaded_games
from config import OTA_data_ZIP
from rgsx_settings import get_sources_mode, get_custom_sources_url, get_sources_zip_url
from rgsx_settings import get_sources_mode, get_custom_sources_url, get_sources_zip_url, get_display_fullscreen
from accessibility import load_accessibility_settings
# Configuration du logging
@@ -99,6 +99,9 @@ _run_windows_gamelist_update()
try:
config.update_checked = False
config.gamelist_update_prompted = False # Flag pour ne pas redemander la mise à jour plusieurs fois
config.pending_update_version = ""
config.startup_update_confirmed = False
config.text_file_mode = ""
except Exception:
pass
@@ -430,7 +433,7 @@ def stop_web_server():
# Boucle principale
async def main():
global current_music, music_files, music_folder, joystick
global current_music, music_files, music_folder, joystick, screen
logger.debug("Début main")
# Charger les filtres de jeux sauvegardés
@@ -457,6 +460,7 @@ async def main():
running = True
loading_step = "none"
ota_update_task = None
sources = []
config.last_state_change_time = 0
config.debounce_delay = 50
@@ -489,6 +493,9 @@ async def main():
if config.menu_state == "download_progress" and current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
if config.menu_state == "loading" and current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
# Forcer redraw toutes les 100 ms dans history avec téléchargement actif
if config.menu_state == "history" and any(entry["status"] in ["Downloading", "Téléchargement"] for entry in config.history):
if current_time - last_redraw_time >= 100:
@@ -610,6 +617,27 @@ async def main():
current_music = play_random_music(music_files, music_folder, current_music)
continue
resize_events = {
getattr(pygame, 'VIDEORESIZE', -1),
getattr(pygame, 'WINDOWSIZECHANGED', -2),
getattr(pygame, 'WINDOWRESIZED', -3),
}
if event.type in resize_events and not get_display_fullscreen():
try:
if event.type == getattr(pygame, 'VIDEORESIZE', -1):
new_width = max(640, int(getattr(event, 'w', config.screen_width)))
new_height = max(360, int(getattr(event, 'h', config.screen_height)))
screen = pygame.display.set_mode((new_width, new_height), pygame.RESIZABLE)
else:
screen = pygame.display.get_surface() or screen
sync_display_metrics(screen)
config.needs_redraw = True
logger.debug(f"Fenêtre redimensionnée: {config.screen_width}x{config.screen_height}")
except Exception as e:
logger.error(f"Erreur lors du redimensionnement de la fenêtre: {e}")
continue
if event.type == pygame.QUIT:
config.menu_state = "confirm_exit"
config.confirm_selection = 0
@@ -1336,6 +1364,10 @@ async def main():
config.error_message = message or _("error_check_updates_failed")
config.needs_redraw = True
logger.debug(f"Erreur OTA : {message}")
elif getattr(config, "pending_update_version", ""):
loading_step = "await_ota_confirmation"
config.needs_redraw = True
continue
else:
loading_step = "check_data"
config.current_loading_system = _("loading_downloading_games_images")
@@ -1343,6 +1375,38 @@ async def main():
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
continue # Passer immédiatement à check_data
elif loading_step == "await_ota_confirmation":
if not getattr(config, "startup_update_confirmed", False):
await asyncio.sleep(0.01)
continue
latest_version = getattr(config, "pending_update_version", "")
config.startup_update_confirmed = False
ota_update_task = asyncio.create_task(apply_pending_update(latest_version))
loading_step = "apply_ota_update"
config.needs_redraw = True
continue
elif loading_step == "apply_ota_update":
if ota_update_task is None:
loading_step = "check_data"
continue
if not ota_update_task.done():
await asyncio.sleep(0.01)
continue
success, message = await ota_update_task
ota_update_task = None
if not success:
config.menu_state = "error"
config.error_message = message or _("error_check_updates_failed")
config.needs_redraw = True
else:
config.pending_update_version = ""
config.text_file_mode = ""
config.text_file_content = ""
config.loading_detail_lines = []
config.needs_redraw = True
continue
elif loading_step == "check_data":
is_data_empty = not os.path.exists(config.GAMES_FOLDER) or not any(os.scandir(config.GAMES_FOLDER))
if is_data_empty:

View File

@@ -2,6 +2,7 @@
import os
import logging
import platform
import socket
from typing import Optional
from dataclasses import dataclass
@@ -26,10 +27,10 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.0.1"
app_version = "2.6.1.4"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 7
GAMELIST_UPDATE_DAYS = 1
def get_application_root():
@@ -250,6 +251,30 @@ SYSTEM_INFO = {
def get_batocera_system_info():
"""Récupère les informations système via la commande batocera-info."""
global SYSTEM_INFO
def get_local_network_ip():
try:
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
udp_socket.connect(("8.8.8.8", 80))
local_ip = udp_socket.getsockname()[0]
if local_ip and not local_ip.startswith("127."):
return local_ip
finally:
udp_socket.close()
except Exception:
pass
try:
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
if local_ip and not local_ip.startswith("127."):
return local_ip
except Exception:
pass
return ""
try:
import subprocess
result = subprocess.run(['batocera-info'], capture_output=True, text=True, timeout=5)
@@ -305,6 +330,7 @@ def get_batocera_system_info():
SYSTEM_INFO["system"] = f"{platform.system()} {platform.release()}"
SYSTEM_INFO["architecture"] = platform.machine()
SYSTEM_INFO["cpu_model"] = platform.processor() or "Unknown"
SYSTEM_INFO["network_ip"] = get_local_network_ip()
return False
@@ -468,6 +494,10 @@ hide_premium_systems = False # Indicateur pour masquer les systèmes premium
# Variables diverses
update_checked = False
pending_update_version = ""
startup_update_confirmed = False
text_file_mode = ""
loading_detail_lines = []
extension_confirm_selection = 0 # Index de sélection pour confirmation d'extension
controls_config = {} # Configuration des contrôles personnalisés
selected_key = (0, 0) # Position du curseur dans le clavier virtuel

View File

@@ -15,10 +15,11 @@ from utils import (
load_games, check_extension_before_download, is_extension_supported,
load_extensions_json, play_random_music, sanitize_filename,
save_music_config, load_api_keys, _get_dest_folder_name,
extract_zip, extract_rar, find_file_with_or_without_extension, toggle_web_service_at_boot, check_web_service_status,
extract_zip, extract_rar, find_file_with_or_without_extension, find_matching_files, toggle_web_service_at_boot, check_web_service_status,
restart_application, generate_support_zip, load_sources,
ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string,
start_connection_status_check
start_connection_status_check, get_clean_display_name, get_existing_history_matches,
move_files_to_directory
)
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
@@ -442,12 +443,14 @@ def build_global_search_index() -> list[dict]:
platform_id = _get_platform_id(platform)
platform_label = _get_platform_label(platform_id)
for game in load_games(platform_id):
display_name = game.display_name or Path(game.name).stem
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,
"display_name": display_name,
"search_name": display_name.lower(),
"url": game.url,
"size": game.size,
})
@@ -463,7 +466,7 @@ def refresh_global_search_results(reset_selection: bool = True) -> None:
else:
config.global_search_results = [
item for item in config.global_search_index
if query in item["display_name"].lower()
if query in item.get("search_name", item["display_name"].lower())
]
if reset_selection:
@@ -476,7 +479,10 @@ def refresh_global_search_results(reset_selection: bool = True) -> None:
def enter_global_search() -> None:
config.global_search_index = build_global_search_index()
index_signature = tuple(config.platforms)
if not getattr(config, 'global_search_index', None) or getattr(config, 'global_search_index_signature', None) != index_signature:
config.global_search_index = build_global_search_index()
config.global_search_index_signature = index_signature
config.global_search_query = ""
config.global_search_results = []
config.global_search_selected = 0
@@ -551,7 +557,7 @@ def trigger_global_search_download(queue_only: bool = False) -> None:
url = result.get("url")
platform = result.get("platform_id")
game_name = result.get("game_name")
display_name = result.get("display_name") or game_name
display_name = result.get("display_name") or get_clean_display_name(game_name, platform)
if not url or not platform or not game_name:
logger.error(f"Resultat de recherche globale invalide: {result}")
@@ -597,6 +603,7 @@ def trigger_global_search_download(queue_only: bool = False) -> None:
config.history.append({
'platform': platform,
'game_name': game_name,
'display_name': display_name,
'status': 'Queued',
'url': url,
'progress': 0,
@@ -1171,6 +1178,7 @@ def handle_controls(event, sources, joystick, screen):
config.history.append({
'platform': platform,
'game_name': game_name,
'display_name': get_clean_display_name(game_name, platform),
'status': 'Queued',
'url': url,
'progress': 0,
@@ -1243,6 +1251,7 @@ def handle_controls(event, sources, joystick, screen):
config.history.append({
'platform': platform,
'game_name': game_name,
'display_name': get_clean_display_name(game_name, platform),
'status': 'Queued',
'url': url,
'progress': 0,
@@ -1483,6 +1492,13 @@ def handle_controls(event, sources, joystick, screen):
dest_folder = _get_dest_folder_name(platform)
base_path = os.path.join(config.ROMS_FOLDER, dest_folder)
file_exists, actual_filename, actual_path = find_file_with_or_without_extension(base_path, game_name)
actual_matches = find_matching_files(base_path, game_name)
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
if actual_matches:
actual_filename, actual_path = actual_matches[0]
file_exists = True
config.history_actual_matches = actual_matches
# Stocker les informations pour les autres handlers
config.history_actual_filename = actual_filename
@@ -1742,7 +1758,47 @@ def handle_controls(event, sources, joystick, screen):
# Affichage du dossier de téléchargement
elif config.menu_state == "history_show_folder":
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
if is_input_matched(event, "clear_history"):
if not config.history or config.current_history_item >= len(config.history):
config.menu_state = "history"
config.needs_redraw = True
else:
entry = config.history[config.current_history_item]
actual_matches = getattr(config, 'history_actual_matches', None) or []
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
start_path = None
if actual_matches:
start_path = os.path.dirname(actual_matches[0][1])
else:
actual_path = getattr(config, 'history_actual_path', None)
if actual_path and os.path.exists(actual_path):
start_path = os.path.dirname(actual_path)
if not start_path or not os.path.isdir(start_path):
start_path = config.ROMS_FOLDER
config.folder_browser_path = start_path
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
config.folder_browser_mode = "history_move"
config.platform_config_name = entry.get("display_name") or get_clean_display_name(entry.get("game_name", ""), entry.get("platform", ""))
try:
items = [".."]
for item in sorted(os.listdir(start_path)):
full_path = os.path.join(start_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {start_path}: {e}")
config.folder_browser_items = [".."]
config.menu_state = "folder_browser"
config.needs_redraw = True
elif is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
config.menu_state = validate_menu_state(config.previous_menu_state)
config.needs_redraw = True
@@ -1772,60 +1828,6 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
# Affichage détails erreur
# Visualiseur de fichiers texte
elif config.menu_state == "text_file_viewer":
content = getattr(config, 'text_file_content', '')
if content:
lines = content.split('\n')
line_height = config.small_font.get_height() + 2
# Calculer le nombre de lignes visibles (approximation)
controls_y = config.screen_height - int(config.screen_height * 0.037)
margin = 40
header_height = 60
content_area_height = controls_y - 2 * margin - 10 - header_height - 20
visible_lines = int(content_area_height / line_height)
scroll_offset = getattr(config, 'text_file_scroll_offset', 0)
max_scroll = max(0, len(lines) - visible_lines)
if is_input_matched(event, "up"):
config.text_file_scroll_offset = max(0, scroll_offset - 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_input_matched(event, "down"):
config.text_file_scroll_offset = min(max_scroll, scroll_offset + 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_input_matched(event, "page_up"):
config.text_file_scroll_offset = max(0, scroll_offset - visible_lines)
update_key_state("page_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_input_matched(event, "page_down"):
config.text_file_scroll_offset = min(max_scroll, scroll_offset + visible_lines)
update_key_state("page_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_input_matched(event, "cancel") or is_input_matched(event, "confirm"):
config.menu_state = validate_menu_state(config.previous_menu_state)
config.needs_redraw = True
else:
# Si pas de contenu, retourner au menu précédent
if is_input_matched(event, "cancel") or is_input_matched(event, "confirm"):
config.menu_state = validate_menu_state(config.previous_menu_state)
config.needs_redraw = True
# Visualiseur de fichiers texte
elif config.menu_state == "text_file_viewer":
content = getattr(config, 'text_file_content', '')
@@ -1856,6 +1858,7 @@ def handle_controls(event, sources, joystick, screen):
scroll_offset = getattr(config, 'text_file_scroll_offset', 0)
max_scroll = max(0, len(wrapped_lines) - visible_lines)
viewer_mode = getattr(config, 'text_file_mode', '')
if is_input_matched(event, "up"):
config.text_file_scroll_offset = max(0, scroll_offset - 1)
@@ -1885,7 +1888,11 @@ def handle_controls(event, sources, joystick, screen):
(event.axis, event.value) if event.type == pygame.JOYAXISMOTION else
event.value)
config.needs_redraw = True
elif is_input_matched(event, "cancel") or is_input_matched(event, "confirm"):
elif viewer_mode == "ota_update" and is_input_matched(event, "confirm"):
config.startup_update_confirmed = True
config.menu_state = "loading"
config.needs_redraw = True
elif viewer_mode != "ota_update" and (is_input_matched(event, "cancel") or is_input_matched(event, "confirm")):
config.menu_state = validate_menu_state(config.previous_menu_state)
config.needs_redraw = True
else:
@@ -2212,11 +2219,27 @@ def handle_controls(event, sources, joystick, screen):
# Sous-menu Display
elif config.menu_state == "pause_display_menu":
sel = getattr(config, 'pause_display_selection', 0)
# layout, font submenu, family, [monitor if multi], light, unknown, back
# layout, font submenu, family, [monitor if multi], [display mode on Windows], light, unknown, back
from rgsx_settings import get_available_monitors
monitors = get_available_monitors()
show_monitor = len(monitors) > 1
total = 7 if show_monitor else 6 # dynamic total based on monitor count
show_display_mode = getattr(config, 'OPERATING_SYSTEM', '') == "Windows"
monitor_index = 3 if show_monitor else None
display_mode_index = 4 if show_monitor else 3
if not show_display_mode:
display_mode_index = None
next_index = 3
if show_monitor:
next_index += 1
if show_display_mode:
next_index += 1
light_index = next_index
unknown_index = light_index + 1
back_index = unknown_index + 1
total = back_index + 1
if is_input_matched(event, "up"):
config.pause_display_selection = (sel - 1) % total
config.needs_redraw = True
@@ -2274,8 +2297,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur changement font family: {e}")
# 3 monitor selection (only if multiple monitors)
elif sel == 3 and show_monitor and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
# Monitor selection (only if multiple monitors)
elif monitor_index is not None and sel == monitor_index and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
try:
from rgsx_settings import get_display_monitor, set_display_monitor
current = get_display_monitor()
@@ -2286,8 +2309,19 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur changement moniteur: {e}")
# light mode toggle (index 4 if show_monitor, else 3)
elif ((sel == 4 and show_monitor) or (sel == 3 and not show_monitor)) and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
# Display mode toggle (Windows only)
elif display_mode_index is not None and sel == display_mode_index and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
try:
from rgsx_settings import get_display_fullscreen, set_display_fullscreen
current = get_display_fullscreen()
set_display_fullscreen(not current)
config.popup_message = _("display_mode_restart_required") if _ else "Restart required to apply screen mode"
config.popup_timer = 3000
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle fullscreen/windowed: {e}")
# Light mode toggle
elif sel == light_index and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
try:
from rgsx_settings import get_light_mode, set_light_mode
current = get_light_mode()
@@ -2297,8 +2331,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle light mode: {e}")
# allow unknown extensions (index 5 if show_monitor, else 4)
elif ((sel == 5 and show_monitor) or (sel == 4 and not show_monitor)) and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
# Allow unknown extensions
elif sel == unknown_index and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
try:
current = get_allow_unknown_extensions()
new_val = set_allow_unknown_extensions(not current)
@@ -2307,8 +2341,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle allow_unknown_extensions: {e}")
# back (index 6 if show_monitor, else 5)
elif ((sel == 6 and show_monitor) or (sel == 5 and not show_monitor)) and is_input_matched(event, "confirm"):
# Back
elif sel == back_index and is_input_matched(event, "confirm"):
config.menu_state = "pause_menu"
config.last_state_change_time = pygame.time.get_ticks()
config.needs_redraw = True
@@ -2916,6 +2950,25 @@ def handle_controls(event, sources, joystick, screen):
if config.folder_browser_selection >= config.folder_browser_scroll_offset + config.folder_browser_visible_items:
config.folder_browser_scroll_offset = config.folder_browser_selection - config.folder_browser_visible_items + 1
config.needs_redraw = True
elif is_input_matched(event, "page_up"):
jump_size = 10
if config.folder_browser_selection > 0:
config.folder_browser_selection = max(0, config.folder_browser_selection - jump_size)
config.folder_browser_scroll_offset = min(
config.folder_browser_scroll_offset,
config.folder_browser_selection
)
config.needs_redraw = True
elif is_input_matched(event, "page_down"):
jump_size = 10
if config.folder_browser_selection < len(config.folder_browser_items) - 1:
config.folder_browser_selection = min(
len(config.folder_browser_items) - 1,
config.folder_browser_selection + jump_size
)
if config.folder_browser_selection >= config.folder_browser_scroll_offset + config.folder_browser_visible_items:
config.folder_browser_scroll_offset = config.folder_browser_selection - config.folder_browser_visible_items + 1
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
if config.folder_browser_items:
selected_item = config.folder_browser_items[config.folder_browser_selection]
@@ -2969,6 +3022,34 @@ def handle_controls(event, sources, joystick, screen):
# Informer qu'un redémarrage est nécessaire
config.popup_message = _("roms_folder_set_restart").format(selected_path) if _ else f"ROMs folder set: {selected_path}\nRestart required!"
config.menu_state = "pause_settings_menu"
elif browser_mode == "history_move":
entry = config.history[config.current_history_item] if config.history and config.current_history_item < len(config.history) else None
actual_matches = getattr(config, 'history_actual_matches', None) or []
if not actual_matches and entry:
actual_matches = get_existing_history_matches(entry)
source_paths = [match_path for _, match_path in actual_matches]
if not source_paths:
actual_path = getattr(config, 'history_actual_path', None)
if actual_path:
source_paths = [actual_path]
success, moved_matches, error_message = move_files_to_directory(source_paths, selected_path)
if success:
config.history_actual_matches = moved_matches
if moved_matches:
config.history_actual_filename, config.history_actual_path = moved_matches[0]
if entry is not None:
entry["moved_paths"] = [path for _, path in moved_matches]
save_history(config.history)
config.popup_message = _("history_move_success").format(len(moved_matches), selected_path) if _ else f"Moved {len(moved_matches)} file(s) to {selected_path}"
config.popup_timer = 3000
logger.info(f"Déplacement historique terminé vers {selected_path}: {len(moved_matches)} fichier(s)")
else:
config.popup_message = _("history_move_error").format(error_message) if _ else f"Move error: {error_message}"
config.popup_timer = 4000
logger.error(f"Erreur déplacement historique vers {selected_path}: {error_message}")
config.menu_state = "history_show_folder"
else:
# Mode dossier plateforme
from rgsx_settings import set_platform_custom_path
@@ -2984,6 +3065,8 @@ def handle_controls(event, sources, joystick, screen):
browser_mode = getattr(config, 'folder_browser_mode', 'platform')
if browser_mode == "roms_root":
config.menu_state = "pause_settings_menu"
elif browser_mode == "history_move":
config.menu_state = "history_show_folder"
else:
config.menu_state = "platform_folder_config"
config.needs_redraw = True

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import json
import os
import logging
import re
import config
from datetime import datetime
@@ -168,7 +169,7 @@ IGNORED_ROM_SCAN_EXTENSIONS = {
def normalize_downloaded_game_name(game_name):
"""Normalise un nom de jeu pour les comparaisons en ignorant l'extension."""
"""Normalise un nom de jeu pour les comparaisons en ignorant extension et tags."""
if not isinstance(game_name, str):
return ""
@@ -176,7 +177,10 @@ def normalize_downloaded_game_name(game_name):
if not normalized:
return ""
return os.path.splitext(normalized)[0].strip().lower()
normalized = os.path.splitext(normalized)[0]
normalized = re.sub(r'\s*[\[(][^\])]*[\])]', '', normalized)
normalized = re.sub(r'\s+', ' ', normalized)
return normalized.strip().lower()
def _normalize_downloaded_games_dict(downloaded):

View File

@@ -307,6 +307,7 @@
"history_option_delete_game": "Spiel löschen",
"history_option_error_info": "Fehlerdetails",
"history_option_retry": "Download wiederholen",
"history_move_action": "Verschieben",
"history_option_back": "Zurück",
"history_folder_path_label": "Zielpfad:",
"history_scraper_not_implemented": "Scraper noch nicht implementiert",
@@ -316,6 +317,8 @@
"history_extracted": "Extrahiert",
"history_delete_success": "Spiel erfolgreich gelöscht",
"history_delete_error": "Fehler beim Löschen des Spiels: {0}",
"history_move_success": "{0} Datei(en) verschoben nach: {1}",
"history_move_error": "Fehler beim Verschieben: {0}",
"history_error_details_title": "Fehlerdetails",
"history_no_error_message": "Keine Fehlermeldung verfügbar",
"web_title": "RGSX Web-Oberfläche",
@@ -480,6 +483,7 @@
"platform_folder_set": "Ordner für {0} festgelegt: {1}",
"platform_folder_default_path": "Standard: {0}",
"folder_browser_title": "Ordner für {0} auswählen",
"folder_browser_title_history_move": "Zielordner auswählen",
"folder_browser_parent": "Übergeordneter Ordner",
"folder_browser_enter": "Öffnen",
"folder_browser_select": "Auswählen",

View File

@@ -306,6 +306,7 @@
"history_option_delete_game": "Delete game",
"history_option_error_info": "Error details",
"history_option_retry": "Retry download",
"history_move_action": "Move",
"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}",
@@ -318,6 +319,8 @@
"history_extracted": "Extracted",
"history_delete_success": "Game deleted successfully",
"history_delete_error": "Error deleting game: {0}",
"history_move_success": "Moved {0} file(s) to: {1}",
"history_move_error": "Error while moving files: {0}",
"history_error_details_title": "Error Details",
"history_no_error_message": "No error message available",
"web_title": "RGSX Web Interface",
@@ -480,6 +483,7 @@
"platform_folder_set": "Folder set for {0}: {1}",
"platform_folder_default_path": "Default: {0}",
"folder_browser_title": "Select folder for {0}",
"folder_browser_title_history_move": "Select destination folder",
"folder_browser_parent": "Parent folder",
"folder_browser_enter": "Enter",
"folder_browser_select": "Select",

View File

@@ -307,6 +307,7 @@
"history_option_delete_game": "Eliminar juego",
"history_option_error_info": "Detalles del error",
"history_option_retry": "Reintentar descarga",
"history_move_action": "Mover",
"history_option_back": "Volver",
"history_folder_path_label": "Ruta de destino:",
"history_scraper_not_implemented": "Scraper aún no implementado",
@@ -316,6 +317,8 @@
"history_extracted": "Extraído",
"history_delete_success": "Juego eliminado con éxito",
"history_delete_error": "Error al eliminar juego: {0}",
"history_move_success": "{0} archivo(s) movido(s) a: {1}",
"history_move_error": "Error al mover los archivos: {0}",
"history_error_details_title": "Detalles del error",
"history_no_error_message": "No hay mensaje de error disponible",
"web_title": "Interfaz Web RGSX",
@@ -478,6 +481,7 @@
"platform_folder_set": "Carpeta establecida para {0}: {1}",
"platform_folder_default_path": "Por defecto: {0}",
"folder_browser_title": "Seleccionar carpeta para {0}",
"folder_browser_title_history_move": "Seleccionar carpeta de destino",
"folder_browser_parent": "Carpeta superior",
"folder_browser_enter": "Entrar",
"folder_browser_select": "Seleccionar",

View File

@@ -306,6 +306,7 @@
"history_option_delete_game": "Supprimer le jeu",
"history_option_error_info": "Détails de l'erreur",
"history_option_retry": "Retenter le téléchargement",
"history_move_action": "Déplacer",
"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}",
@@ -318,6 +319,8 @@
"history_extracted": "Extrait",
"history_delete_success": "Jeu supprimé avec succès",
"history_delete_error": "Erreur lors de la suppression du jeu : {0}",
"history_move_success": "{0} fichier(s) déplacé(s) vers : {1}",
"history_move_error": "Erreur lors du déplacement : {0}",
"history_error_details_title": "Détails de l'erreur",
"history_no_error_message": "Aucun message d'erreur disponible",
"web_title": "Interface Web RGSX",
@@ -480,6 +483,7 @@
"platform_folder_set": "Dossier défini pour {0}: {1}",
"platform_folder_default_path": "Par défaut: {0}",
"folder_browser_title": "Sélectionner le dossier pour {0}",
"folder_browser_title_history_move": "Sélectionner le dossier de destination",
"folder_browser_parent": "Dossier parent",
"folder_browser_enter": "Entrer",
"folder_browser_select": "Valider",

View File

@@ -302,6 +302,7 @@
"history_option_delete_game": "Elimina gioco",
"history_option_error_info": "Dettagli errore",
"history_option_retry": "Riprova download",
"history_move_action": "Sposta",
"history_option_back": "Indietro",
"history_folder_path_label": "Percorso destinazione:",
"history_scraper_not_implemented": "Scraper non ancora implementato",
@@ -311,6 +312,8 @@
"history_extracted": "Estratto",
"history_delete_success": "Gioco eliminato con successo",
"history_delete_error": "Errore durante l'eliminazione del gioco: {0}",
"history_move_success": "{0} file spostato/i in: {1}",
"history_move_error": "Errore durante lo spostamento: {0}",
"history_error_details_title": "Dettagli errore",
"history_no_error_message": "Nessun messaggio di errore disponibile",
"web_title": "Interfaccia Web RGSX",
@@ -476,6 +479,7 @@
"platform_folder_set": "Cartella impostata per {0}: {1}",
"platform_folder_default_path": "Predefinito: {0}",
"folder_browser_title": "Seleziona cartella per {0}",
"folder_browser_title_history_move": "Seleziona cartella di destinazione",
"folder_browser_parent": "Cartella superiore",
"folder_browser_enter": "Entra",
"folder_browser_select": "Seleziona",

View File

@@ -308,6 +308,7 @@
"history_option_delete_game": "Excluir jogo",
"history_option_error_info": "Detalhes do erro",
"history_option_retry": "Tentar novamente",
"history_move_action": "Mover",
"history_option_back": "Voltar",
"history_folder_path_label": "Caminho de destino:",
"history_scraper_not_implemented": "Scraper ainda não implementado",
@@ -317,6 +318,8 @@
"history_extracted": "Extraído",
"history_delete_success": "Jogo excluído com sucesso",
"history_delete_error": "Erro ao excluir jogo: {0}",
"history_move_success": "{0} arquivo(s) movido(s) para: {1}",
"history_move_error": "Erro ao mover os arquivos: {0}",
"history_error_details_title": "Detalhes do erro",
"history_no_error_message": "Nenhuma mensagem de erro disponível",
"web_title": "Interface Web RGSX",
@@ -480,6 +483,7 @@
"platform_folder_set": "Pasta definida para {0}: {1}",
"platform_folder_default_path": "Padrão: {0}",
"folder_browser_title": "Selecionar pasta para {0}",
"folder_browser_title_history_move": "Selecionar pasta de destino",
"folder_browser_parent": "Pasta superior",
"folder_browser_enter": "Entrar",
"folder_browser_select": "Selecionar",

View File

@@ -15,7 +15,7 @@ try:
except Exception:
pygame = None # type: ignore
from config import OTA_VERSION_ENDPOINT,APP_FOLDER, UPDATE_FOLDER, OTA_UPDATE_ZIP
from utils import sanitize_filename, extract_zip, extract_rar, extract_7z, load_api_key_1fichier, load_api_key_alldebrid, normalize_platform_name, load_api_keys, load_archive_org_cookie
from utils import sanitize_filename, extract_zip, extract_rar, extract_7z, load_api_key_1fichier, load_api_key_alldebrid, normalize_platform_name, load_api_keys, load_archive_org_cookie, get_clean_display_name
from history import save_history
from display import show_toast
import logging
@@ -39,6 +39,21 @@ import urllib.parse
logger = logging.getLogger(__name__)
def _build_browser_download_headers(referer: str | None = None, accept: str = 'application/octet-stream,*/*;q=0.8') -> dict:
"""Build browser-like headers for file downloads that reject minimal clients."""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Accept': accept,
'Accept-Language': 'en-US,en;q=0.9,fr;q=0.8',
'Accept-Encoding': 'identity',
'Connection': 'keep-alive',
'DNT': '1',
}
if referer:
headers['Referer'] = referer
return headers
def _redact_headers(headers: dict) -> dict:
"""Return a copy of headers with sensitive fields redacted for logs."""
if not isinstance(headers, dict):
@@ -479,6 +494,211 @@ def test_internet():
return True
def _normalize_release_notes(raw_notes):
if not raw_notes:
return ""
notes = html_module.unescape(str(raw_notes))
notes = notes.replace("\r\n", "\n").replace("\r", "\n")
notes = re.sub(r"\n{3,}", "\n\n", notes)
return notes.strip()
def _extract_changelog_section(raw_text):
text = _normalize_release_notes(raw_text)
if not text:
return ""
lines = text.split("\n")
heading_re = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
changelog_start = None
changelog_level = None
for index, line in enumerate(lines):
match = heading_re.match(line.strip())
if not match:
continue
if "changelog" in match.group(2).lower():
changelog_start = index + 1
changelog_level = len(match.group(1))
break
if changelog_start is None:
return text
extracted = []
for line in lines[changelog_start:]:
stripped = line.strip()
if stripped == "---":
break
match = heading_re.match(stripped)
if match and len(match.group(1)) <= changelog_level:
break
extracted.append(line)
return _normalize_release_notes("\n".join(extracted))
def _fetch_recent_release_changelogs(limit=5):
repo = getattr(config, "GITHUB_REPO", "RetroGameSets/RGSX")
api_url = f"https://api.github.com/repos/{repo}/releases"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "RGSX",
}
response = requests.get(api_url, headers=headers, timeout=10)
response.raise_for_status()
releases = response.json()
if not isinstance(releases, list):
return []
changelogs = []
for release in releases:
if not isinstance(release, dict) or release.get("draft"):
continue
version_label = (
release.get("tag_name")
or release.get("name")
or release.get("published_at")
or "Unknown"
)
release_body = _extract_changelog_section(release.get("body", ""))
if not release_body:
release_body = "Changelog unavailable"
changelogs.append({
"version": str(version_label).strip(),
"body": release_body,
})
if len(changelogs) >= limit:
break
return changelogs
def _build_recent_changelog_text(latest_version, limit=5):
changelogs = _fetch_recent_release_changelogs(limit=limit)
title = _("network_update_available").format(latest_version) if _ else f"Update available: {latest_version}"
intro = f"{title}\n\nLast {len(changelogs) if changelogs else limit} changelogs:\n"
if not changelogs:
return f"{intro}\nChangelog unavailable"
blocks = []
for item in changelogs:
blocks.append(f"=== {item['version']} ===\n{item['body']}")
return intro + "\n\n".join(blocks) + "\n\nPress Confirm to install the update."
def _format_size(num_bytes):
value = float(max(0, num_bytes))
units = ["B", "KB", "MB", "GB"]
unit_index = 0
while value >= 1024 and unit_index < len(units) - 1:
value /= 1024.0
unit_index += 1
if unit_index == 0:
return f"{int(value)} {units[unit_index]}"
return f"{value:.1f} {units[unit_index]}"
def _set_loading_details(*lines):
config.loading_detail_lines = [str(line) for line in lines if line]
config.needs_redraw = True
def _safe_remove_file(file_path, retries=8, delay=0.25):
if not file_path or not os.path.exists(file_path):
return True
last_error = None
for _ in range(retries):
try:
os.remove(file_path)
return True
except PermissionError as error:
last_error = error
time.sleep(delay)
except FileNotFoundError:
return True
except Exception as error:
last_error = error
break
if last_error is not None:
logger.warning(f"Impossible de supprimer temporairement {file_path}: {last_error}")
return False
async def apply_pending_update(latest_version):
UPDATE_ZIP = OTA_UPDATE_ZIP
logger.debug(f"URL de mise à jour : {UPDATE_ZIP} (version {latest_version})")
config.current_loading_system = _("network_update_available").format(latest_version)
config.loading_progress = 10.0
_set_loading_details("Preparing update...")
logger.debug(f"Téléchargement du ZIP de mise à jour : {UPDATE_ZIP}")
os.makedirs(UPDATE_FOLDER, exist_ok=True)
update_zip_path = os.path.join(UPDATE_FOLDER, f"RGSX_update_v{latest_version}.zip")
logger.debug(f"Téléchargement de {UPDATE_ZIP} vers {update_zip_path}")
with requests.get(UPDATE_ZIP, stream=True, timeout=10) as r:
r.raise_for_status()
total_size = int(r.headers.get('content-length', 0))
downloaded = 0
start_time = time.time()
with open(update_zip_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
config.loading_progress = 10.0 + (40.0 * downloaded / total_size) if total_size > 0 else 10.0
elapsed = max(time.time() - start_time, 0.001)
speed = downloaded / elapsed
progress_line = f"Download: {_format_size(downloaded)} / {_format_size(total_size)}" if total_size > 0 else f"Download: {_format_size(downloaded)}"
_set_loading_details(progress_line, f"Speed: {_format_size(speed)}/s")
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {update_zip_path}")
config.current_loading_system = _("network_extracting_update")
config.loading_progress = 60.0
_set_loading_details(f"Archive: {_format_size(os.path.getsize(update_zip_path))}")
success, message = await asyncio.to_thread(extract_update, update_zip_path, APP_FOLDER, UPDATE_ZIP)
if not success:
logger.error(f"Échec de l'extraction : {message}")
return False, _("network_extraction_failed").format(message)
if _safe_remove_file(update_zip_path):
logger.debug(f"Fichier ZIP {update_zip_path} supprimé")
config.current_loading_system = _("network_update_completed")
config.loading_progress = 100.0
_set_loading_details("Update installed successfully")
logger.debug("Mise à jour terminée avec succès")
config.menu_state = "restart_popup"
config.update_result_message = _("network_update_success").format(latest_version)
config.popup_message = config.update_result_message
config.popup_timer = 2000
config.update_result_error = False
config.update_result_start_time = pygame.time.get_ticks() if pygame is not None else 0
config.needs_redraw = True
logger.debug("Affichage de la popup de mise à jour réussie, redémarrage imminent")
try:
from utils import restart_application
restart_application(2000)
except Exception as e:
logger.error(f"Erreur lors du redémarrage après mise à jour: {e}")
return True, _("network_update_success_message")
async def check_for_updates():
try:
logger.debug("Vérification de la version disponible sur le serveur")
@@ -614,75 +834,27 @@ async def check_for_updates():
logger.info("Version distante inférieure ou égale skip mise à jour (anti-downgrade)")
return True, _("network_no_update_available") if _ else "No update (local >= remote)"
# À ce stade latest_version est strictement > version locale
# Utiliser l'URL RGSX_latest.zip qui pointe toujours vers la dernière version sur GitHub
UPDATE_ZIP = OTA_UPDATE_ZIP
logger.debug(f"URL de mise à jour : {UPDATE_ZIP} (version {latest_version})")
if latest_version != config.app_version:
config.current_loading_system = _("network_update_available").format(latest_version)
config.loading_progress = 10.0
config.needs_redraw = True
logger.debug(f"Téléchargement du ZIP de mise à jour : {UPDATE_ZIP}")
# Créer le dossier UPDATE_FOLDER s'il n'existe pas
os.makedirs(UPDATE_FOLDER, exist_ok=True)
update_zip_path = os.path.join(UPDATE_FOLDER, f"RGSX_update_v{latest_version}.zip")
logger.debug(f"Téléchargement de {UPDATE_ZIP} vers {update_zip_path}")
# Télécharger le ZIP
with requests.get(UPDATE_ZIP, stream=True, timeout=10) as r:
r.raise_for_status()
total_size = int(r.headers.get('content-length', 0))
downloaded = 0
with open(update_zip_path, "wb") as f:
for chunk in r.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
config.loading_progress = 10.0 + (40.0 * downloaded / total_size) if total_size > 0 else 10.0
config.needs_redraw = True
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {update_zip_path}")
# Extraire le contenu du ZIP dans APP_FOLDER
config.current_loading_system = _("network_extracting_update")
config.loading_progress = 60.0
config.needs_redraw = True
success, message = extract_update(update_zip_path, APP_FOLDER, UPDATE_ZIP)
if not success:
logger.error(f"Échec de l'extraction : {message}")
return False, _("network_extraction_failed").format(message)
# Supprimer le fichier ZIP après extraction
if os.path.exists(update_zip_path):
os.remove(update_zip_path)
logger.debug(f"Fichier ZIP {update_zip_path} supprimé")
config.current_loading_system = _("network_update_completed")
config.loading_progress = 100.0
config.needs_redraw = True
logger.debug("Mise à jour terminée avec succès")
# Configurer la popup puis redémarrer automatiquement
config.menu_state = "restart_popup"
config.update_result_message = _("network_update_success").format(latest_version)
config.popup_message = config.update_result_message
config.popup_timer = 2000
config.update_result_error = False
config.update_result_start_time = pygame.time.get_ticks() if pygame is not None else 0
config.needs_redraw = True
logger.debug(f"Affichage de la popup de mise à jour réussie, redémarrage imminent")
try:
from utils import restart_application
restart_application(2000)
except Exception as e:
logger.error(f"Erreur lors du redémarrage après mise à jour: {e}")
changelog_text = _build_recent_changelog_text(latest_version, limit=5)
except Exception as changelog_error:
logger.warning(f"Impossible de récupérer les changelogs récents: {changelog_error}")
changelog_text = "Changelog unavailable"
return True, _("network_update_success_message")
config.pending_update_version = latest_version
config.startup_update_confirmed = False
config.text_file_name = f"RGSX {latest_version}"
config.text_file_content = changelog_text
config.text_file_scroll_offset = 0
config.text_file_mode = "ota_update"
config.previous_menu_state = "loading"
config.menu_state = "text_file_viewer"
config.update_checked = True
config.needs_redraw = True
return True, _("network_update_available").format(latest_version)
else:
logger.debug("Aucune mise à jour disponible")
config.update_checked = True
return True, _("network_no_update_available")
except Exception as e:
@@ -704,9 +876,20 @@ def extract_update(zip_path, dest_dir, source_url):
# Extraire le ZIP
skipped_files = []
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_infos = [info for info in zip_ref.infolist() if not info.is_dir()]
total_bytes = sum(max(0, info.file_size) for info in file_infos)
extracted_bytes = 0
for file_info in zip_ref.infolist():
try:
zip_ref.extract(file_info, dest_dir)
if not file_info.is_dir():
extracted_bytes += max(0, file_info.file_size)
if total_bytes > 0:
config.loading_progress = 60.0 + (40.0 * extracted_bytes / total_bytes)
_set_loading_details(
f"Extracting: {_format_size(extracted_bytes)} / {_format_size(total_bytes)}" if total_bytes > 0 else f"Extracting: {file_info.filename}",
file_info.filename
)
except PermissionError as e:
logger.warning(f"Impossible d'extraire {file_info.filename}: {str(e)}")
skipped_files.append(file_info.filename)
@@ -874,6 +1057,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
entry["downloaded_size"] = 0
entry["platform"] = platform
entry["game_name"] = game_name
entry["display_name"] = get_clean_display_name(game_name, platform)
entry["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry["task_id"] = task_id
break
@@ -883,6 +1067,7 @@ async def download_rom(url, platform, game_name, is_zip_non_supported=False, tas
config.history.append({
"platform": platform,
"game_name": game_name,
"display_name": get_clean_display_name(game_name, platform),
"url": url,
"status": "Downloading",
"progress": 0,
@@ -1743,6 +1928,25 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
logger.debug(f"Thread téléchargement 1fichier démarré pour {url}, task_id={task_id}")
# Assurer l'accès à provider_used dans cette closure (lecture/écriture)
nonlocal provider_used
def _refresh_alldebrid_final_url(current_link):
"""Request a fresh AllDebrid unlock URL after transient download failures."""
ad_key = getattr(config, 'API_KEY_ALLDEBRID', '')
if not ad_key:
return None, None
params = {'agent': 'RGSX', 'apikey': ad_key, 'link': current_link}
refresh_resp = requests.get("https://api.alldebrid.com/v4/link/unlock", params=params, timeout=30)
refresh_resp.raise_for_status()
refresh_json = refresh_resp.json()
if refresh_json.get('status') != 'success':
logger.warning(f"AllDebrid refresh status != success: {refresh_json}")
return None, None
refresh_data = refresh_json.get('data', {})
return (
refresh_data.get('link') or refresh_data.get('download') or refresh_data.get('streamingLink'),
refresh_data.get('filename'),
)
try:
cancel_ev = cancel_events.get(task_id)
link = url.split('&af=')[0]
@@ -1766,6 +1970,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
entry["downloaded_size"] = 0
entry["platform"] = platform
entry["game_name"] = game_name
entry["display_name"] = get_clean_display_name(game_name, platform)
entry["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry["task_id"] = task_id
break
@@ -1775,6 +1980,7 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
config.history.append({
"platform": platform,
"game_name": game_name,
"display_name": get_clean_display_name(game_name, platform),
"url": url,
"status": "Downloading",
"progress": 0,
@@ -2380,17 +2586,23 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
filename = game_name
sanitized_filename = sanitize_filename(filename)
dest_path = os.path.join(dest_dir, sanitized_filename)
provider_download_session = requests.Session()
provider_download_headers = _build_browser_download_headers()
provider_download_session.headers.update(provider_download_headers)
# Essayer de récupérer la taille du serveur via HEAD request
remote_size = None
try:
if final_url:
head_response = requests.head(final_url, timeout=10, allow_redirects=True)
if final_url and provider_used not in {'AD', 'DL', 'RD'}:
head_response = provider_download_session.head(final_url, timeout=10, allow_redirects=True)
if head_response.status_code == 200:
content_length = head_response.headers.get('content-length')
if content_length:
remote_size = int(content_length)
logger.debug(f"Taille du fichier serveur (AllDebrid/Debrid-Link/RealDebrid): {remote_size} octets")
elif final_url:
logger.debug(f"Saut du HEAD préliminaire pour provider {provider_used}: URL temporaire potentiellement sensible ({final_url})")
except Exception as e:
logger.debug(f"Impossible de vérifier la taille serveur (AllDebrid/Debrid-Link/RealDebrid): {e}")
@@ -2478,13 +2690,39 @@ async def download_from_1fichier(url, platform, game_name, is_zip_non_supported=
lock = threading.Lock()
retries = 10
retry_delay = 10
download_header_variants = [
provider_download_headers,
_build_browser_download_headers(accept='*/*'),
{
'User-Agent': 'curl/8.4.0',
'Accept': '*/*',
'Accept-Encoding': 'identity',
'Connection': 'keep-alive',
},
]
logger.debug(f"Initialisation progression avec taille inconnue pour task_id={task_id}")
progress_queues[task_id].put((task_id, 0, 0)) # Taille initiale inconnue
for attempt in range(retries):
logger.debug(f"Début tentative {attempt + 1} pour télécharger {final_url}")
try:
with requests.get(final_url, stream=True, headers={'User-Agent': 'Mozilla/5.0'}, timeout=30) as response:
attempt_headers = download_header_variants[min(attempt, len(download_header_variants) - 1)]
logger.debug(f"Headers tentative {attempt + 1}: {_redact_headers(attempt_headers)}")
with provider_download_session.get(final_url, stream=True, headers=attempt_headers, timeout=(30, 120), allow_redirects=True) as response:
logger.debug(f"Réponse GET reçue, code: {response.status_code}")
if response.status_code == 503 and provider_used == 'AD' and attempt < retries - 1:
logger.warning("AllDebrid a renvoyé 503 sur l'URL débridée, tentative de régénération du lien")
try:
refreshed_url, refreshed_filename = _refresh_alldebrid_final_url(link)
if refreshed_url:
if refreshed_url != final_url:
logger.debug(f"Nouvelle URL AllDebrid obtenue: {refreshed_url}")
final_url = refreshed_url
if refreshed_filename:
filename = refreshed_filename
sanitized_filename = sanitize_filename(filename)
dest_path = os.path.join(dest_dir, sanitized_filename)
except Exception as refresh_error:
logger.warning(f"Impossible de régénérer le lien AllDebrid après 503: {refresh_error}")
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale: {total_size} octets")

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
import config
from history import load_history, save_history
from utils import load_sources, load_games, extract_data
from utils import load_sources, load_games, extract_data, get_clean_display_name
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
@@ -1243,6 +1243,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
queue_history_entry = {
'platform': platform,
'game_name': game_name,
'display_name': get_clean_display_name(game_name, platform),
'status': 'Queued',
'url': game_url,
'progress': 0,
@@ -1280,6 +1281,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
download_history_entry = {
'platform': platform,
'game_name': game_name,
'display_name': get_clean_display_name(game_name, platform),
'status': 'Downloading',
'url': game_url,
'progress': 0,

View File

@@ -33,6 +33,36 @@ import tempfile
logger = logging.getLogger(__name__)
# Désactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP
def get_clean_display_name(raw_name, platform_id=None):
"""Return a user-facing game title from a raw file/path entry."""
text = str(raw_name or "").strip()
if not text:
return ""
normalized = text.replace("\\", "/")
leaf_name = normalized.rsplit("/", 1)[-1]
display_name = Path(leaf_name).stem.strip()
prefixes = []
if platform_id:
prefixes.append(str(platform_id).strip())
platform_label = getattr(config, "platform_names", {}).get(platform_id)
if platform_label:
prefixes.append(str(platform_label).strip())
for prefix in prefixes:
if not prefix:
continue
pattern = rf"^{re.escape(prefix)}[\s\-_:]+"
updated_name = re.sub(pattern, "", display_name, flags=re.IGNORECASE).strip()
if updated_name:
display_name = updated_name
return display_name.strip(" -_/")
_games_cache = {}
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
@@ -1195,9 +1225,15 @@ def load_games(platform_id:str) -> list[Game]:
game_file = c
break
if not game_file:
_games_cache.pop(platform_id, None)
logger.warning(f"Aucun fichier de jeux trouvé pour {platform_id} (candidats: {candidates})")
return []
game_mtime_ns = os.stat(game_file).st_mtime_ns
cached_entry = _games_cache.get(platform_id)
if cached_entry and cached_entry.get("path") == game_file and cached_entry.get("mtime_ns") == game_mtime_ns:
return cached_entry["games"]
with open(game_file, 'r', encoding='utf-8') as f:
data = json.load(f)
@@ -1239,11 +1275,17 @@ def load_games(platform_id:str) -> list[Game]:
games_list: list[Game] = []
for name, url, size in normalized:
display_name = Path(name).stem
display_name = display_name.replace(platform_id, "")
display_name = get_clean_display_name(name, platform_id)
games_list.append(Game(name=name, url=url, size=size, display_name=display_name))
_games_cache[platform_id] = {
"path": game_file,
"mtime_ns": game_mtime_ns,
"games": games_list,
}
return games_list
except Exception as e:
_games_cache.pop(platform_id, None)
logger.error(f"Erreur lors du chargement des jeux pour {platform_id}: {e}")
return []
@@ -3020,26 +3062,150 @@ def normalize_platform_name(platform):
return platform.lower().replace(" ", "")
def find_matching_files(base_path, filename):
"""Return all matching files for a requested download name within a ROM folder."""
if not base_path or not os.path.exists(base_path):
return []
candidate_name = Path(str(filename or "")).name
requested_stem, requested_ext = os.path.splitext(candidate_name)
requested_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', requested_stem)).strip().lower()
archive_exts = {'.zip', '.7z', '.rar', '.tar', '.gz', '.xz', '.bz2'}
matches = []
seen_paths = set()
full_path = os.path.join(base_path, candidate_name)
if os.path.exists(full_path) and os.path.isfile(full_path):
seen_paths.add(os.path.normcase(full_path))
matches.append((1000, candidate_name, full_path))
for existing_file in os.listdir(base_path):
existing_path = os.path.join(base_path, existing_file)
if not os.path.isfile(existing_path):
continue
normalized_path = os.path.normcase(existing_path)
if normalized_path in seen_paths:
continue
existing_stem, existing_ext = os.path.splitext(existing_file)
score = None
if requested_stem and existing_stem == requested_stem:
score = 900
else:
existing_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', existing_stem)).strip().lower()
if requested_normalized and existing_normalized and existing_normalized == requested_normalized:
score = 0
if requested_ext and existing_ext.lower() == requested_ext.lower():
score += 4
if existing_ext.lower() not in archive_exts:
score += 3
score -= abs(len(existing_stem) - len(requested_stem))
if score is not None:
seen_paths.add(normalized_path)
matches.append((score, existing_file, existing_path))
matches.sort(key=lambda item: item[0], reverse=True)
return [(actual_filename, actual_path) for _, actual_filename, actual_path in matches]
def get_existing_history_matches(entry):
"""Return persisted moved paths that still exist for a history entry."""
if not isinstance(entry, dict):
return []
moved_paths = entry.get("moved_paths", []) or []
matches = []
seen_paths = set()
for raw_path in moved_paths:
if not raw_path:
continue
actual_path = os.path.abspath(str(raw_path))
normalized_path = os.path.normcase(actual_path)
if normalized_path in seen_paths or not os.path.isfile(actual_path):
continue
seen_paths.add(normalized_path)
matches.append((os.path.basename(actual_path), actual_path))
return matches
def move_files_to_directory(file_paths, destination_dir):
"""Move files to a destination directory, avoiding name collisions."""
if not destination_dir:
return False, [], "Destination directory is empty"
if not any(file_paths or []):
return False, [], "No files to move"
try:
os.makedirs(destination_dir, exist_ok=True)
except Exception as e:
logger.error(f"Impossible de créer le dossier de destination {destination_dir}: {e}")
return False, [], str(e)
moved_matches = []
seen_sources = set()
reserved_targets = set()
for raw_source in file_paths:
if not raw_source:
continue
source_path = os.path.abspath(str(raw_source))
normalized_source = os.path.normcase(source_path)
if normalized_source in seen_sources:
continue
seen_sources.add(normalized_source)
if not os.path.isfile(source_path):
error_message = f"File not found: {source_path}"
logger.warning(error_message)
return False, moved_matches, error_message
source_name = os.path.basename(source_path)
target_path = os.path.join(destination_dir, source_name)
target_root, target_ext = os.path.splitext(target_path)
suffix = 1
while os.path.normcase(target_path) in reserved_targets or (
os.path.exists(target_path)
and os.path.normcase(target_path) != os.path.normcase(source_path)
):
target_path = f"{target_root} ({suffix}){target_ext}"
suffix += 1
reserved_targets.add(os.path.normcase(target_path))
try:
if os.path.normcase(source_path) != os.path.normcase(target_path):
shutil.move(source_path, target_path)
logger.info(f"Fichier déplacé: {source_path} -> {target_path}")
else:
logger.debug(f"Déplacement ignoré, même chemin source/destination: {source_path}")
moved_matches.append((os.path.basename(target_path), target_path))
except Exception as e:
logger.error(f"Erreur lors du déplacement de {source_path} vers {target_path}: {e}")
return False, moved_matches, str(e)
return True, moved_matches, None
def find_file_with_or_without_extension(base_path, filename):
"""
Cherche un fichier, avec son extension ou sans (cherche jeuxxx.* si jeuxxx.zip n'existe pas).
Retourne (file_exists, actual_filename, actual_path).
"""
# 1. Tester d'abord le fichier tel quel
full_path = os.path.join(base_path, filename)
if os.path.exists(full_path):
return True, filename, full_path
# 2. Si pas trouvé et que le fichier a une extension, chercher sans extension
name_without_ext, ext = os.path.splitext(filename)
if ext: # Si le fichier a une extension
# Chercher tous les fichiers commençant par le nom sans extension
if os.path.exists(base_path):
for existing_file in os.listdir(base_path):
existing_name, _ = os.path.splitext(existing_file)
if existing_name == name_without_ext:
found_path = os.path.join(base_path, existing_file)
return True, existing_file, found_path
# 3. Fichier non trouvé
return False, filename, full_path
candidate_name = Path(str(filename or "")).name
full_path = os.path.join(base_path, candidate_name)
matches = find_matching_files(base_path, candidate_name)
if matches:
actual_filename, actual_path = matches[0]
return True, actual_filename, actual_path
return False, candidate_name, full_path

View File

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