Compare commits

...

17 Commits

Author SHA1 Message Date
skymike03
92a8d8567d ##v2.6.3.6 (2026.04.24)
- correct history bug writting permissions / workaround

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:46:43 +02:00
skymike03
3e9aefc149 ## v2.6.3.5 (2026.05.22)
- Enhance download management and UI updates for torrent handling. Torrent download is now available for testing
- Add new aria2c Linux binary to assets and Update aria2c Linux path in config
- Improve download status display in history with active, completed, error, and canceled states
2026-04-22 21:03:24 +02:00
skymike03
d7449073d2 ## v2.6.3.4 (2026.04.10)
- Fix ps3 handle with uncompressed folder (rename folder to .ps3)
- show and save real name of vimm's file when downloading
2026-04-10 23:18:20 +02:00
skymike03
e0d34304d5 Replace timeout with ping for delay before exit in RGSX Retrobat 2026-04-10 18:02:39 +02:00
skymike03
65584e411a Improve logging for application closure in RGSX Retrobat 2026-04-10 17:43:46 +02:00
skymike03
142fffcfb1 ## v2.6.3.3 (2026.04.10)
- add vimms support
- fix scraper api
2026-04-10 02:06:24 +02:00
skymike03
ffd186f69b remove file 2026-04-08 18:25:52 +02:00
skymike03
fd3695f78d - Fix scraper API bug 2026-04-07 21:13:01 +02:00
skymike03
579d0a1c28 ## v2.6.3.2 (2026.04.07)
- Add little badge on platform icon to identify the source of games (add icons files)
- Fix sorting size bug with commas
- Fix history locate/extract/delete game options not showing after some downloads
2026-04-07 18:52:28 +02:00
skymike03
60ca7bc375 - Fix history locate/extract/delete game options not showing after some downloads 2026-04-07 00:03:05 +02:00
skymike03
c6a76f56d5 ## v2.6.3.1 (2026.04.06)
- Correct games sorting by size and filtering bugs
- Add persistant sorting option for all systems
- Correct a crash when saving some filter option
- Fix file not deleted after cancel download bug
- Fix slow download start on lolroms source
- Correct PS3 converting bug with ISO
2026-04-06 23:09:10 +02:00
skymike03
f835d00886 ## v2.6.3.0 (2026.04.06)
- Torrents are STILL DISABLED FOR NOW  , download is ready but sources are not fast (other sources are available for almost all platforms)
- Update RGSX support zip gen text to show correct button to close the message
- Update resolution detection on windows with dpi scale suppport to avoid using a bad screen resolution
- fix history write errors on the LOG
- fix torrents refreshing every rgsx start and takes few minutes. now it will do after a gamelist refresh only.
- ask to update game list automatically on boot only if it detect a new update on server
- add new filter global menu and sort by name or size
- loads games count and torrent cache local only to speed up app start
- add cache for fastest search (included in game data zip)
- speed up the gamelist data zip download
- add ability to extract zip/rar/7z for a downloaded game if auto extract disable for example or archive was a supported extension.
2026-04-06 20:43:11 +02:00
skymike03
e84c7ae167 ## v2.6.2.0
- add verbose info for platform loading at app start
- speed up app start by loading torrent files only when open a plateform
- add a torrent cache to speedup access next time opening the app (deleted and recreated after each rgsx games update)
- add support to new sources (ie lolroms)  to have a new alternative. Torrent is not implanted for the moment
- correct 7z archive extracting error on batocera (arm)
- fix status not showing extract message for 7z and rar
- correct missing download size/progress on some links
2026-04-04 01:34:22 +02:00
skymike03
ce39722351 ## v2.6.1.7 (2026.01.02)
- correct version heading check to changelog extraction logic
2026-04-02 22:56:16 +02:00
skymike03
a7dad84108 ## v2.6.1.6.1 (2026.01.02)
- fix update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 22:47:29 +02:00
skymike03
c9f48d20dd ## v2.6.1.6 (2026.01.02)
- update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 21:47:38 +02:00
skymike03
cd7795f70e v2.6.1.5.1 (2026.01.02)
- fix some torrent  handling for minerva FUTURE using. Games are available, but download torrent through RGSX is not available for now. So don't ask about dl ps2/ps3/gc/ds games , and if you try to download ,you will have a "maintenance" message
2026-04-02 18:44:55 +02:00
26 changed files with 5167 additions and 1030 deletions

View File

@@ -2,6 +2,53 @@ import os
import platform
import warnings
def _enable_windows_dpi_awareness_early():
"""Enable DPI awareness before importing pygame so SDL sees physical monitor sizes."""
if platform.system() != "Windows":
return
try:
os.environ.setdefault("SDL_WINDOWS_DPI_AWARENESS", "permonitorv2")
except Exception:
pass
try:
import ctypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
if hasattr(user32, "SetProcessDpiAwarenessContext"):
for awareness in (-4, -3):
try:
if user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(awareness)):
return
except Exception:
continue
except Exception:
pass
try:
import ctypes
shcore = ctypes.WinDLL("shcore", use_last_error=True)
if hasattr(shcore, "SetProcessDpiAwareness"):
shcore.SetProcessDpiAwareness(2)
return
except Exception:
pass
try:
import ctypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
if hasattr(user32, "SetProcessDPIAware"):
user32.SetProcessDPIAware()
except Exception:
pass
_enable_windows_dpi_awareness_early()
# Ignorer le warning de deprecation de pkg_resources dans pygame
warnings.filterwarnings("ignore", category=UserWarning, module="pygame.pkgdata")
warnings.filterwarnings("ignore", message="pkg_resources is deprecated")
@@ -19,6 +66,7 @@ import logging
import requests
import queue
import datetime
from datetime import timezone
import subprocess
import sys
import threading
@@ -28,7 +76,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_global_search_list, draw_global_sort_menu,
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,
@@ -41,9 +89,9 @@ from controls_mapper import map_controls, draw_controls_mapping, get_actions
from controls import load_controls_config
from utils import (
load_sources, check_extension_before_download, extract_data,
play_random_music, load_music_config, load_api_keys
play_random_music, load_music_config, load_api_keys, _refresh_loading_feedback, _format_size_bytes
)
from history import load_history, save_history, load_downloaded_games
from history import load_history, save_history, load_downloaded_games, check_history_write_access, get_history_write_status
from config import OTA_data_ZIP
from rgsx_settings import get_sources_mode, get_custom_sources_url, get_sources_zip_url, get_display_fullscreen
from accessibility import load_accessibility_settings
@@ -99,6 +147,7 @@ _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.gamelist_refreshed_this_session = False
config.pending_update_version = ""
config.startup_update_confirmed = False
config.text_file_mode = ""
@@ -286,6 +335,23 @@ config.current_music = current_music # Met à jour la musique en cours dans con
config.history = load_history()
logger.debug(f"Historique de téléchargement : {len(config.history)} entrées")
# Vérifier explicitement la capacité d'écriture de history.json
history_write_ok, history_write_error_probe = check_history_write_access(force=True)
if not history_write_ok:
history_write_status = get_history_write_status() or {}
history_write_message = history_write_status.get("message") or (
"Erreur ecriture history.json. "
"Le telechargement continue sans historique temps reel."
)
logger.error(history_write_message)
config.popup_message = history_write_message
config.popup_timer = max(int(getattr(config, 'popup_timer', 0) or 0), 5000)
config.needs_redraw = True
try:
show_toast(history_write_message, duration=5000)
except Exception:
pass
# Chargement des jeux téléchargés
config.downloaded_games = load_downloaded_games()
@@ -439,7 +505,8 @@ async def main():
# Charger les filtres de jeux sauvegardés
try:
from game_filters import GameFilters
from rgsx_settings import load_game_filters
from rgsx_settings import get_global_sort_option, load_game_filters
config.global_sort_option = get_global_sort_option()
config.game_filter_obj = GameFilters()
filter_dict = load_game_filters()
if filter_dict:
@@ -447,6 +514,7 @@ async def main():
if config.game_filter_obj.is_active():
config.filter_active = True
logger.info("Filtres de jeux chargés et actifs")
logger.info(f"Tri global chargé: {config.global_sort_option}")
except Exception as e:
logger.error(f"Erreur lors du chargement des filtres: {e}")
config.game_filter_obj = None
@@ -760,6 +828,7 @@ async def main():
"filter_menu_choice",
"filter_advanced",
"filter_priority_config",
"global_sort_menu",
"platform_search",
}
if config.menu_state in SIMPLE_HANDLE_STATES:
@@ -1199,6 +1268,8 @@ async def main():
draw_filter_platforms_menu(screen)
elif config.menu_state == "filter_menu_choice":
draw_filter_menu_choice(screen)
elif config.menu_state == "global_sort_menu":
draw_global_sort_menu(screen)
elif config.menu_state == "filter_advanced":
draw_filter_advanced(screen)
elif config.menu_state == "filter_priority_config":
@@ -1440,6 +1511,10 @@ async def main():
try:
success, message = extract_data(local_zip, dest_dir, local_zip)
if success:
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
config.gamelist_refreshed_this_session = True
logger.debug(f"Extraction locale réussie : {message}")
config.loading_progress = 70.0
config.needs_redraw = True
@@ -1460,17 +1535,28 @@ async def main():
config.popup_timer = 5000
else:
try:
_refresh_loading_feedback(
current_system=_("loading_download_data"),
progress=config.loading_progress,
force=True,
)
with requests.get(sources_zip_url, stream=True, headers=headers, timeout=30) as response:
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale du ZIP : {total_size} octets")
downloaded = 0
download_started_at = time.time()
last_loading_refresh = 0.0
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
for chunk in response.iter_content(chunk_size=262144):
if chunk:
f.write(chunk)
downloaded += len(chunk)
elapsed = max(0.001, time.time() - download_started_at)
speed_bytes_per_second = downloaded / elapsed
progress_detail = f"{_format_size_bytes(downloaded)} / {_format_size_bytes(total_size)}" if total_size > 0 else _format_size_bytes(downloaded)
speed_detail = f"{speed_bytes_per_second / (1024 * 1024):.1f} MB/s"
config.download_progress[sources_zip_url] = {
"downloaded_size": downloaded,
"total_size": total_size,
@@ -1479,7 +1565,16 @@ async def main():
}
config.loading_progress = 15.0 + (35.0 * downloaded / total_size) if total_size > 0 else 15.0
config.needs_redraw = True
await asyncio.sleep(0)
now = time.time()
should_refresh = (now - last_loading_refresh) >= 0.12 or (total_size > 0 and downloaded >= total_size)
if should_refresh:
last_loading_refresh = now
_refresh_loading_feedback(
current_system=_("loading_download_data"),
progress=config.loading_progress,
detail_lines=[progress_detail, speed_detail],
)
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {zip_path}")
config.current_loading_system = _("loading_extracting_data")
@@ -1488,6 +1583,11 @@ async def main():
dest_dir = config.SAVE_FOLDER
success, message = extract_data(zip_path, dest_dir, sources_zip_url)
if success:
from rgsx_settings import get_remote_gamelist_timestamp, set_last_gamelist_update
remote_update_dt = get_remote_gamelist_timestamp(sources_zip_url)
set_last_gamelist_update(remote_update_dt)
config.gamelist_refreshed_this_session = True
logger.debug(f"Extraction réussie : {message}")
config.loading_progress = 70.0
config.needs_redraw = True
@@ -1526,34 +1626,76 @@ async def main():
continue # Passer immédiatement à load_sources
elif loading_step == "load_sources":
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
sources = load_sources()
sources = load_sources(allow_torrent_manifest_fetch=True)
config.loading_progress = 100.0
config.current_loading_system = ""
config.loading_detail_lines = []
# Vérifier si une mise à jour de la liste des jeux est nécessaire (seulement si pas déjà demandé)
if not config.gamelist_update_prompted:
from rgsx_settings import get_last_gamelist_update
from config import GAMELIST_UPDATE_DAYS
from datetime import datetime, timedelta
if getattr(config, "gamelist_refreshed_this_session", False):
logger.info("Liste des jeux déjà téléchargée/extraites pendant ce lancement, aucun prompt de mise à jour supplémentaire")
config.menu_state = "platform"
logger.debug(f"Fin chargement, passage à platform, progress={config.loading_progress}")
continue
from rgsx_settings import (
get_last_gamelist_update,
get_last_gamelist_prompt_remote_update,
parse_gamelist_update_timestamp,
format_gamelist_update_display,
get_remote_gamelist_timestamp,
)
last_update = get_last_gamelist_update()
last_prompted_remote_update = get_last_gamelist_prompt_remote_update()
last_update_dt = parse_gamelist_update_timestamp(last_update)
last_prompted_remote_update_dt = parse_gamelist_update_timestamp(last_prompted_remote_update)
remote_sources_url = get_sources_zip_url(OTA_data_ZIP)
remote_update_dt = get_remote_gamelist_timestamp(remote_sources_url)
config.gamelist_local_update_display = format_gamelist_update_display(last_update)
config.gamelist_remote_update_display = format_gamelist_update_display(remote_update_dt)
config.gamelist_remote_update_timestamp = (
remote_update_dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if remote_update_dt is not None else None
)
should_prompt_update = False
if last_update is None:
# Première utilisation, proposer la mise à jour
logger.info("Première utilisation détectée, proposition de mise à jour de la liste des jeux")
should_prompt_update = True
else:
try:
last_update_date = datetime.strptime(last_update, "%Y-%m-%d")
days_since_update = (datetime.now() - last_update_date).days
logger.info(f"Dernière mise à jour de la liste des jeux: {last_update} ({days_since_update} jours)")
if days_since_update >= GAMELIST_UPDATE_DAYS:
logger.info(f"Mise à jour de la liste des jeux recommandée (>{GAMELIST_UPDATE_DAYS} jours)")
try:
logger.info(
f"Dernière mise à jour locale de la liste des jeux: {config.gamelist_local_update_display or last_update}"
)
if last_prompted_remote_update_dt is not None:
logger.info(
"Dernière date distante déjà proposée pour la liste des jeux: "
f"{last_prompted_remote_update_dt.isoformat()}"
)
if remote_update_dt is not None:
logger.info(
f"Date distante détectée pour la liste des jeux: {config.gamelist_remote_update_display}"
)
latest_seen_update_dt = None
for candidate_dt in (last_update_dt, last_prompted_remote_update_dt):
if candidate_dt is None:
continue
if latest_seen_update_dt is None or candidate_dt > latest_seen_update_dt:
latest_seen_update_dt = candidate_dt
if remote_update_dt is not None:
if latest_seen_update_dt is None:
logger.info("Première vérification distante détectée, proposition de mise à jour de la liste des jeux")
should_prompt_update = True
except Exception as e:
logger.error(f"Erreur lors de la vérification de la date de mise à jour: {e}")
elif remote_update_dt > latest_seen_update_dt:
logger.info("Mise à jour de la liste des jeux recommandée (fichier distant plus récent)")
should_prompt_update = True
else:
logger.info("Même version distante déjà appliquée ou déjà proposée, aucun prompt affiché")
elif last_update is None and last_prompted_remote_update is None:
logger.info("Première utilisation détectée sans date distante exploitable, proposition de mise à jour de la liste des jeux")
should_prompt_update = True
except Exception as e:
logger.error(f"Erreur lors de la vérification de la date de mise à jour: {e}")
if should_prompt_update:
config.menu_state = "gamelist_update_prompt"

View File

@@ -0,0 +1 @@
ea388d0d46cd18c3606b1abdba68790b6d7f66ee19ce3bb4f99a26fadafcc77a

View File

@@ -0,0 +1 @@
<svg height='86' viewBox='0 0 76 86' width='76' xmlns='http://www.w3.org/2000/svg'><path d='m76 82v4h-76l.00080851-4zm-3-6v5h-70v-5zm-62.6696277-54 .8344146.4217275.4176066 6.7436084.4176065 10.9576581v10.5383496l-.4176065 13.1364492-.0694681 8.8498268-1.1825531.3523804h-4.17367003l-1.25202116-.3523804-.48627608-8.8498268-.41840503-13.0662957v-10.5375432l.41840503-11.028618.38167482-6.7798947.87034634-.3854412zm60.0004653 0 .8353798.4217275.4168913 6.7436084.4168913 10.9576581v10.5383496l-.4168913 13.1364492-.0686832 8.8498268-1.1835879.3523804h-4.1737047l-1.2522712-.3523804-.4879704-8.8498268-.4168913-13.0662957v-10.5375432l.4168913-11.028618.3833483-6.7798947.8697215-.3854412zm-42.000632 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2529447-.3523804-.4863246-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8688361-.3854412zm23 0 .8344979.4217275.4176483 6.7436084.4176482 10.9576581v10.5383496l-.4176482 13.1364492-.0686764 8.8498268-1.1834698.3523804h-4.1740866l-1.2521462-.3523804-.4871231-8.8498268-.4168497-13.0662957v-10.5375432l.4168497-11.028618.38331-6.7798947.8696347-.3854412zm21.6697944-9v7h-70v-7zm-35.7200748-13 36.7200748 8.4088317-1.4720205 2.5911683h-70.32799254l-2.19998696-2.10140371z' fill='#2c2c2c' fill-rule='evenodd'/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,18 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title desc" role="img">
<title id="title">LOL</title>
<desc id="desc">Texte LOL style gaming retro</desc>
<!-- Lettre L (gauche) - rose/violet -->
<path d="M35 45 L35 155 L65 155 L65 135 L50 135 L50 45 Z" fill="#ff00ff"/>
<!-- Lettre O (milieu) - cyan -->
<ellipse cx="105" cy="100" rx="29" ry="48" fill="none" stroke="#00ffff" stroke-width="19"/>
<!-- Lettre L (droite) - rose/violet -->
<path d="M145 45 L145 155 L175 155 L175 135 L160 135 L160 45 Z" fill="#ff00ff"/>
<!-- Effet néon plus prononcé -->
<path d="M35 45 L35 155 L65 155 L65 135 L50 135 L50 45 Z" fill="none" stroke="#ff00ff" stroke-width="9" opacity="0.85"/>
<ellipse cx="105" cy="100" rx="29" ry="48" fill="none" stroke="#00ffff" stroke-width="9" opacity="0.85"/>
<path d="M145 45 L145 155 L175 155 L175 135 L160 135 L160 45 Z" fill="none" stroke="#ff00ff" stroke-width="9" opacity="0.85"/>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="500"
height="498"
viewBox="0 0 500 497.99999"
version="1.1"
id="svg20"
sodipodi:docname="μTorrent logo with title.svg"
inkscape:version="1.1.2 (b8e25be833, 2022-02-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs24" />
<sodipodi:namedview
id="namedview22"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="0.40844424"
inkscape:cx="634.11348"
inkscape:cy="73.449437"
inkscape:window-width="1920"
inkscape:window-height="974"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg20" />
<!-- Generator: Sketch 56.3 (81716) - https://sketch.com -->
<title
id="title2">Group</title>
<desc
id="desc4">Created with Sketch.</desc>
<g
id="g186"
transform="scale(2.0742981)">
<path
d="m 137.38383,239.99418 c 44.64832,-6.2824 81.43169,-37.04615 96.37094,-78.28306 -1.17843,0.43717 -2.61663,0.95348 -4.33344,1.46978 -20.0859,5.93191 -34.17804,-4.68447 -37.02056,-6.51981 -2.84253,-1.81651 -5.88835,-4.82769 -6.45686,-4.6694 -1.83729,11.38519 -12.22849,27.19481 -35.94756,34.36661 -12.98901,3.93827 -26.97196,4.02872 -36.94903,-1.56777 l 3.29055,8.26095 c 1.31773,3.29382 3.52774,8.69811 4.89817,11.97687 0,0 8.61793,20.53931 16.14779,34.96583"
id="Fill-18"
style="fill:#76b83f;fill-rule:evenodd;stroke:none;stroke-width:5.02241" />
<path
d="M 27.337163,71.292153 62.88564,64.663041 c 3.237841,-0.591683 6.82582,1.247434 7.962829,4.085251 l 24.423106,61.011188 c 3.324434,6.36154 4.002122,7.76725 6.166955,10.48071 0,0 16.86313,24.09695 42.57008,18.21027 17.33374,-3.96466 25.42834,-16.98922 25.97802,-26.25641 0.5685,-2.97349 -0.32002,-6.71579 -1.86364,-10.20936 L 138.86947,55.784027 c -1.17465,-2.668226 0.21837,-5.28746 3.07219,-5.833919 l 29.64506,-5.528656 c 2.71828,-0.48616 5.90718,1.202209 7.09313,3.783756 l 32.401,69.437962 c 1.30643,2.77752 3.95318,7.15673 5.89964,9.70812 0,0 6.73923,9.68928 17.53705,7.85017 2.66934,0 5.7114,-1.44718 5.7114,-1.44718 0.47438,-4.32267 0.72663,-8.70565 0.72663,-13.16023 C 240.95557,53.986366 187.01165,0 120.46273,0 53.932634,0 0,53.986366 0,120.59405 c 0,53.44744 34.739017,98.7583 82.851068,114.57923 -3.249135,-6.69318 -6.53592,-14.0911 -9.227845,-21.31567 L 23.067731,77.872271 c -1.110654,-2.984796 0.835815,-5.935674 4.269432,-6.580118"
id="Fill-19"
style="fill:#76b83f;fill-rule:evenodd;stroke:none;stroke-width:5.02241" />
</g>
<metadata
id="metadata26">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Group</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,29 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Vimm button</title>
<desc id="desc">Circular Vimm-inspired badge with dark blue background, white ring, and red V outlined in blue.</desc>
<defs>
<radialGradient id="bg" cx="38%" cy="30%" r="70%">
<stop offset="0%" stop-color="#10217a"/>
<stop offset="58%" stop-color="#08114f"/>
<stop offset="100%" stop-color="#030726"/>
</radialGradient>
<filter id="buttonShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1.2" stdDeviation="1.4" flood-color="#000000" flood-opacity="0.45"/>
</filter>
<filter id="vShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0.8" dy="1.1" stdDeviation="0.8" flood-color="#000000" flood-opacity="0.35"/>
</filter>
</defs>
<circle cx="50" cy="50" r="47" fill="#3f3f3f" opacity="0.5"/>
<g filter="url(#buttonShadow)">
<circle cx="50" cy="50" r="45.5" fill="url(#bg)" stroke="#ffffff" stroke-width="4.5"/>
<circle cx="50" cy="50" r="44" fill="none" stroke="#9aa0b5" stroke-width="0.9" opacity="0.55"/>
<ellipse cx="38" cy="23" rx="17" ry="9" fill="#ffffff" opacity="0.08"/>
</g>
<g filter="url(#vShadow)">
<path d="M26 18 L44 80 L69 18" fill="none" stroke="#0a2fd8" stroke-width="14" stroke-linecap="square" stroke-linejoin="miter" stroke-miterlimit="10"/>
<path d="M26 18 L44 80 L69 18" fill="none" stroke="#ff1b1b" stroke-width="8.5" stroke-linecap="square" stroke-linejoin="miter" stroke-miterlimit="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1,388 @@
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from pathlib import Path
_TORRENT_DOWNLOAD_SCHEME = "rgsx+torrent"
def _format_size_bytes(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
if size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def build_torrent_download_url(source_url: str, file_index: int, relative_path: str, size_bytes: int | None = None) -> str:
params = {
"source": source_url,
"index": str(max(1, int(file_index))),
"path": relative_path,
}
if isinstance(size_bytes, int) and size_bytes > 0:
params["size"] = str(size_bytes)
return f"{_TORRENT_DOWNLOAD_SCHEME}://download?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
def get_clean_display_name(raw_name, platform_id=None):
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())
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(" -_/")
def _decode_bencode_text(value) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
if isinstance(value, str):
return value
return str(value or "")
def _bdecode(data: bytes, index: int = 0):
token = data[index:index + 1]
if token == b"i":
end = data.index(b"e", index)
return int(data[index + 1:end]), end + 1
if token == b"l":
items = []
index += 1
while data[index:index + 1] != b"e":
value, index = _bdecode(data, index)
items.append(value)
return items, index + 1
if token == b"d":
values = {}
index += 1
while data[index:index + 1] != b"e":
key, index = _bdecode(data, index)
value, index = _bdecode(data, index)
values[key] = value
return values, index + 1
if token.isdigit():
sep = data.index(b":", index)
length = int(data[index:sep])
start = sep + 1
end = start + length
return data[start:end], end
raise ValueError(f"Invalid bencode token at offset {index}: {token!r}")
def is_torrent_manifest_url(url: str | None) -> bool:
if not url or not isinstance(url, str):
return False
try:
parsed = urllib.parse.urlparse(url.strip())
except Exception:
return False
return (parsed.path or "").lower().endswith(".torrent")
def _extract_torrent_source(item) -> tuple[str, str] | None:
if isinstance(item, (list, tuple)):
if len(item) < 2:
return None
source_name = str(item[0] or "").strip()
source_url = item[1] if isinstance(item[1], str) else None
if source_url and is_torrent_manifest_url(source_url):
return source_name, source_url.strip()
return None
if isinstance(item, dict):
source_url = item.get("torrent_url") or item.get("url") or item.get("download") or item.get("link")
if not isinstance(source_url, str) or not source_url.strip():
return None
source_type = str(item.get("type") or item.get("source_type") or item.get("source") or "").strip().lower()
if source_type == "torrent" or is_torrent_manifest_url(source_url):
source_name = item.get("game_name") or item.get("name") or item.get("title") or item.get("game") or item.get("label")
if not source_name:
parsed = urllib.parse.urlparse(source_url)
source_name = urllib.parse.unquote(Path(parsed.path).name)
return str(source_name or "").strip(), source_url.strip()
return None
def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list[dict[str, str | int]]:
torrent_data, _next_index = _bdecode(payload)
if not isinstance(torrent_data, dict):
raise ValueError("Torrent root metadata is not a dictionary")
info = torrent_data.get(b"info")
if not isinstance(info, dict):
raise ValueError("Torrent metadata does not contain an info dictionary")
entries: list[dict[str, str | int]] = []
files = info.get(b"files")
root_name = _decode_bencode_text(info.get(b"name.utf-8") or info.get(b"name") or "").strip()
if isinstance(files, list):
for file_index, file_entry in enumerate(files, start=1):
if not isinstance(file_entry, dict):
continue
path_parts = file_entry.get(b"path.utf-8") or file_entry.get(b"path") or []
if not isinstance(path_parts, list):
continue
parts = [_decode_bencode_text(part).strip() for part in path_parts]
parts = [part for part in parts if part]
if not parts:
continue
full_path = "/".join(parts)
download_path = "/".join([part for part in [root_name, full_path] if part])
entries.append(
{
"name": parts[-1],
"path": full_path,
"download_path": download_path or full_path,
"index": file_index,
"size_bytes": int(file_entry.get(b"length") or 0),
"source_url": source_url,
}
)
else:
if root_name:
entries.append(
{
"name": root_name,
"path": root_name,
"download_path": root_name,
"index": 1,
"size_bytes": int(info.get(b"length") or 0),
"source_url": source_url,
}
)
duplicate_names: dict[str, int] = {}
for entry in entries:
name = str(entry["name"])
duplicate_names[name] = duplicate_names.get(name, 0) + 1
for entry in entries:
if duplicate_names.get(str(entry["name"]), 0) > 1:
entry["name"] = str(entry["path"])
return entries
def _fetch_torrent_entries(source_url: str) -> list[dict[str, str | int]]:
request = urllib.request.Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept": "*/*",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
payload = response.read()
return _extract_torrent_entries_from_bytes(payload, source_url)
def _iter_game_rows(data):
if isinstance(data, dict) and "games" in data:
data = data["games"]
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
return []
def _build_platform_search_entries(
rows,
torrent_manifest_cache: dict[str, list[dict[str, str | int]]],
warnings: list[str],
platform_id: str,
) -> list[dict[str, str | int]]:
indexed_entries: list[dict[str, str | int]] = []
for item in rows:
torrent_source = _extract_torrent_source(item)
if torrent_source is not None:
source_name, source_url = torrent_source
entries = torrent_manifest_cache.get(source_url)
if entries is None:
try:
entries = _fetch_torrent_entries(source_url)
torrent_manifest_cache[source_url] = entries
except Exception as exc:
warnings.append(f"{platform_id}: failed to build torrent cache for {source_name or source_url}: {exc}")
entries = []
for entry in entries:
game_name = str(entry.get("name") or "").strip()
if not game_name:
continue
size_bytes = int(entry.get("size_bytes") or 0)
file_index = int(entry.get("index") or 1)
relative_path = str(entry.get("download_path") or entry.get("path") or game_name)
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": game_name,
"display_name": get_clean_display_name(game_name, platform_id),
"url": build_torrent_download_url(source_url, file_index, relative_path, size_bytes),
"size": _format_size_bytes(size_bytes) if size_bytes > 0 else "",
"size_bytes": size_bytes,
}
)
continue
if isinstance(item, dict):
name = item.get("game_name") or item.get("name") or item.get("title") or item.get("game")
if name:
size = item.get("size") or item.get("filesize") or item.get("length") or ""
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": str(name),
"display_name": get_clean_display_name(name, platform_id),
"url": str(item.get("url") or item.get("download") or item.get("link") or item.get("href") or ""),
"size": str(size) if size else "",
"size_bytes": 0,
}
)
continue
if isinstance(item, (list, tuple)):
if len(item) > 0 and str(item[0] or "").strip():
size = item[2] if len(item) > 2 and item[2] is not None else ""
url = item[1] if len(item) > 1 and isinstance(item[1], str) else ""
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": str(item[0]),
"display_name": get_clean_display_name(item[0], platform_id),
"url": url,
"size": str(size) if size else "",
"size_bytes": 0,
}
)
continue
if isinstance(item, str) and item.strip():
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": item.strip(),
"display_name": get_clean_display_name(item, platform_id),
"url": "",
"size": "",
"size_bytes": 0,
}
)
continue
if item is not None:
item_text = str(item)
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": item_text,
"display_name": get_clean_display_name(item_text, platform_id),
"url": "",
"size": "",
"size_bytes": 0,
}
)
return indexed_entries
def build_caches(games_dir: Path) -> tuple[dict[str, list[dict[str, str | int]]], dict[str, dict[str, str | int]], list[dict[str, str | int]], list[str]]:
torrent_manifest_cache: dict[str, list[dict[str, str | int]]] = {}
platform_count_cache: dict[str, dict[str, str | int]] = {}
global_search_index: list[dict[str, str | int]] = []
warnings: list[str] = []
for game_file in sorted(games_dir.glob("*.json")):
with game_file.open("r", encoding="utf-8") as handle:
data = json.load(handle)
rows = _iter_game_rows(data)
platform_id = game_file.stem
platform_entries = _build_platform_search_entries(rows, torrent_manifest_cache, warnings, platform_id)
count = len(platform_entries)
platform_count_cache[platform_id] = {
"path": "",
"mtime_ns": 0,
"file_name": game_file.name,
"size_bytes": game_file.stat().st_size,
"count": int(count),
}
global_search_index.extend(platform_entries)
return torrent_manifest_cache, platform_count_cache, global_search_index, warnings
def main() -> int:
parser = argparse.ArgumentParser(description="Build portable RGSX cache files from exported games JSONs.")
parser.add_argument("--games-dir", required=True, help="Directory containing exported games/*.json files")
parser.add_argument("--output-dir", required=True, help="Directory where cache JSON files will be written")
args = parser.parse_args()
games_dir = Path(args.games_dir)
output_dir = Path(args.output_dir)
if not games_dir.is_dir():
print(json.dumps({"ok": False, "error": f"games directory not found: {games_dir}"}), file=sys.stderr)
return 2
output_dir.mkdir(parents=True, exist_ok=True)
try:
torrent_manifest_cache, platform_count_cache, global_search_index, warnings = build_caches(games_dir)
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
return 1
torrent_cache_path = output_dir / "torrent_manifest_cache.json"
platform_count_cache_path = output_dir / "platform_games_count_cache.json"
global_search_index_path = output_dir / "global_search_index.json"
torrent_cache_payload = {"version": 1, "entries": torrent_manifest_cache}
platform_count_payload = {"version": 2, "entries": platform_count_cache}
global_search_payload = {"version": 1, "entries": global_search_index}
torrent_cache_path.write_text(json.dumps(torrent_cache_payload, ensure_ascii=False, indent=2), encoding="utf-8")
platform_count_cache_path.write_text(json.dumps(platform_count_payload, ensure_ascii=False, indent=2), encoding="utf-8")
global_search_index_path.write_text(json.dumps(global_search_payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"torrent_manifest_count": len(torrent_manifest_cache),
"platform_count_entries": len(platform_count_cache),
"global_search_entries": len(global_search_index),
"torrent_cache_path": str(torrent_cache_path),
"platform_count_cache_path": str(platform_count_cache_path),
"global_search_index_path": str(global_search_index_path),
"warnings": warnings,
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -27,7 +27,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.1.5"
app_version = "2.6.3.6"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -195,12 +195,17 @@ PRECONF_CONTROLS_PATH = os.path.join(APP_FOLDER, "assets", "controls")
CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json")
HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json")
DOWNLOADED_GAMES_PATH = os.path.join(SAVE_FOLDER, "downloaded_games.json")
TORRENT_MANIFEST_CACHE_PATH = os.path.join(SAVE_FOLDER, "torrent_manifest_cache.json")
PLATFORM_GAME_COUNT_CACHE_PATH = os.path.join(SAVE_FOLDER, "platform_games_count_cache.json")
GLOBAL_SEARCH_INDEX_CACHE_PATH = os.path.join(SAVE_FOLDER, "global_search_index.json")
PENDING_TORRENT_REFRESH_MARKER_PATH = os.path.join(SAVE_FOLDER, "pending_torrent_refresh.marker")
RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json")
API_KEY_1FICHIER_PATH = os.path.join(SAVE_FOLDER, "1FichierAPI.txt")
API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt")
API_KEY_DEBRIDLINK_PATH = os.path.join(SAVE_FOLDER, "DebridLinkAPI.txt")
API_KEY_REALDEBRID_PATH = os.path.join(SAVE_FOLDER, "RealDebridAPI.txt")
ARCHIVE_ORG_COOKIE_PATH = os.path.join(APP_FOLDER, "assets", "ArchiveOrgCookie.txt")
THEGAMESDB_API_KEY_PATH = os.path.join(APP_FOLDER, "assets", "TheGamesDBAPI.txt")
@@ -226,7 +231,7 @@ PS3DEC_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "ps3dec_linux")
SEVEN_Z_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "7zz")
SEVEN_Z_EXE = os.path.join(APP_FOLDER,"assets", "progs", "7z.exe")
ARIA2C_EXE = os.path.join(APP_FOLDER,"assets", "progs", "aria2c.exe")
ARIA2C_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "aria2c")
ARIA2C_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "aria2c_linux")
# Détection du système d'exploitation (une seule fois au démarrage)
OPERATING_SYSTEM = platform.system()
@@ -441,11 +446,20 @@ 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
global_search_allow_empty = False # True pour afficher tous les resultats sans requete (filtre/tri globaux)
global_search_title_override = "" # Titre alternatif pour la vue globale
global_search_return_state = "platform" # Etat de retour depuis la vue globale
global_sort_option = "name_asc" # Tri global courant
# Variables pour le filtrage avancé
selected_filter_choice = 0 # Index dans le menu de choix de filtrage (recherche / avancé)
selected_filter_option = 0 # Index dans le menu de filtrage avancé
game_filter_obj = None # Objet GameFilters pour le filtrage avancé
filter_menu_context = "platform" # Contexte d'ouverture du menu filtre unifie
filter_menu_entries = [] # Entrees du menu filtre unifie
filter_menu_return_state = "platform" # Etat de retour depuis le menu filtre unifie
filter_target_scope = "local" # Portee du filtrage avance (local/global)
global_sort_selected = 0 # Index selectionne dans le menu de tri global
# Gestion des états du menu
needs_redraw = False # Indicateur si l'écran doit être redessiné
@@ -459,6 +473,11 @@ history = [] # Liste des entrées d'historique avec platform, game_name, status
pending_download = None # Objet de téléchargement en attente
download_progress = {} # Dictionnaire de progression des téléchargements actifs
download_tasks = {} # Dictionnaire pour les tâches de téléchargement
history_write_ok = True # Etat courant de l'ecriture de history.json
history_write_error = "" # Message explicite si l'ecriture de history.json echoue
history_write_failure_count = 0 # Nombre d'echecs consecutifs d'ecriture
history_write_last_failure_ts = 0.0 # Timestamp du dernier echec d'ecriture
history_write_last_toast_at = 0.0 # Anti-spam pour les toasts d'erreur d'ecriture
download_result_message = "" # Message de résultat du dernier téléchargement
download_result_error = False # Indicateur d'erreur pour le résultat de téléchargement
download_result_start_time = 0 # Timestamp de début du résultat affiché

View File

@@ -15,11 +15,17 @@ 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, find_matching_files, toggle_web_service_at_boot, check_web_service_status,
extract_zip, extract_rar, extract_7z, 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, get_clean_display_name, get_existing_history_matches,
move_files_to_directory
start_connection_status_check, get_clean_display_name, get_existing_history_matches, remember_history_local_match,
clear_torrent_manifest_cache,
request_torrent_manifest_refresh,
clear_platform_game_count_cache,
move_files_to_directory, parse_torrent_download_url,
_refresh_loading_feedback,
parse_game_size_to_bytes,
sort_games_list,
)
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
@@ -27,7 +33,9 @@ from rgsx_settings import (
get_allow_unknown_extensions, set_display_grid, get_font_family, set_font_family,
get_show_unsupported_platforms, set_show_unsupported_platforms,
set_allow_unknown_extensions, get_hide_premium_systems, set_hide_premium_systems,
get_sources_mode, set_sources_mode, set_symlink_option, get_symlink_option, load_rgsx_settings, save_rgsx_settings
get_sources_mode, set_sources_mode, set_symlink_option, get_symlink_option,
get_global_sort_option, set_global_sort_option,
load_rgsx_settings, save_rgsx_settings
)
from accessibility import save_accessibility_settings
from scraper import get_game_metadata, download_image_to_surface
@@ -39,22 +47,37 @@ logger = logging.getLogger(__name__)
# Extensions d'archives pour lesquelles on ignore l'avertissement d'extension non supportée
ARCHIVE_EXTENSIONS = {'.zip', '.7z', '.rar', '.tar', '.gz', '.xz', '.bz2'}
GLOBAL_SORT_OPTIONS = [
("name_asc", lambda: _("web_sort_name_asc") or "A-Z (Name)"),
("name_desc", lambda: _("web_sort_name_desc") or "Z-A (Name)"),
("size_asc", lambda: _("web_sort_size_asc") or "Size -+ (Small first)"),
("size_desc", lambda: _("web_sort_size_desc") or "Size +- (Large first)"),
]
def _notify_torrent_in_maintenance(game_name: str | None = None) -> None:
try:
message = _("popup_torrent_in_maintenance")
except Exception:
message = "torrent in maintence"
show_toast(message, 3000)
logger.info(f"Source torrent non telechargeable pour le moment: {game_name or 'unknown game'}")
# Fonction devenue inutile, ne fait plus rien
pass
def _has_download_url(url, game_name: str | None = None) -> bool:
if isinstance(url, str) and url.strip():
torrent_meta = parse_torrent_download_url(url)
if torrent_meta is not None:
# Lancer le téléchargement torrent
# On suppose que les autres paramètres sont accessibles ou à adapter selon le contexte d'appel
# Ici, il faudrait passer platform, game_name, etc. selon l'appelant
# Exemple minimal :
try:
# platform doit être passé ou déterminé selon le contexte réel
platform = None
download_rom(url, platform, game_name)
except Exception as e:
logger.error(f"Erreur lors du lancement du téléchargement torrent: {e}")
config.needs_redraw = True
return True
return True
_notify_torrent_in_maintenance(game_name)
config.needs_redraw = True
return False
@@ -65,6 +88,113 @@ def _wrap_index(current_index: int, delta: int, item_count: int) -> int:
return (current_index + delta) % item_count
def _sort_global_items(items: list[dict]) -> list[dict]:
option = getattr(config, 'global_sort_option', 'name_asc') or 'name_asc'
reverse = option in ('name_desc', 'size_desc')
if option.startswith('size_'):
return sorted(
items,
key=lambda item: (
int(item.get('size_bytes') or 0),
str(item.get('display_name') or '').lower(),
str(item.get('platform_label') or '').lower(),
),
reverse=reverse,
)
return sorted(
items,
key=lambda item: (
str(item.get('display_name') or '').lower(),
str(item.get('platform_label') or '').lower(),
int(item.get('size_bytes') or 0),
),
reverse=reverse,
)
def _get_global_sort_index(option: str | None = None) -> int:
target = option or getattr(config, 'global_sort_option', 'name_asc')
for index, (key, _) in enumerate(GLOBAL_SORT_OPTIONS):
if key == target:
return index
return 0
def _sort_local_games(items: list[Game]) -> list[Game]:
option = getattr(config, 'global_sort_option', 'name_asc')
return sort_games_list(items, option)
def _apply_sorted_active_filters() -> list[Game]:
if hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
return _sort_local_games(config.game_filter_obj.apply_filters(config.games))
return config.games
def _build_filter_menu_entries(context: str) -> list[dict[str, str]]:
global_search_label = 'Recherche globale' if (_ is None or _("global_search_title") == "global_search_title") else _("global_search_title").format("").replace(" : ", "").rstrip(': ')
platform_search_label = 'Recherche sur cette plateforme' if (_ is None or _("platform_search_title") == "platform_search_title") else _("platform_search_title")
advanced_filter_label = 'Filtrer' if (_ is None or _("filter_advanced") == "filter_advanced") else _("filter_advanced")
sort_label = 'Trier' if (_ is None or _("web_sort") == "web_sort") else _("web_sort")
back_label = 'Retour' if (_ is None or _("menu_back") == "menu_back") else _("menu_back")
entries = []
if context == 'game':
entries.extend([
{
'key': 'platform_search',
'label': platform_search_label,
},
{
'key': 'global_sort',
'label': sort_label,
},
{
'key': 'global_search',
'label': global_search_label,
},
{
'key': 'global_filter',
'label': advanced_filter_label,
},
])
else:
entries.extend([
{
'key': 'global_search',
'label': global_search_label,
},
{
'key': 'global_filter',
'label': advanced_filter_label,
},
{
'key': 'global_sort',
'label': sort_label,
},
])
entries.append({
'key': 'back',
'label': back_label,
})
return entries
def open_unified_filter_menu(source_state: str) -> None:
context = 'game' if source_state == 'game' else 'global'
config.filter_menu_context = context
config.filter_menu_entries = _build_filter_menu_entries(context)
config.filter_menu_return_state = validate_menu_state(source_state)
config.selected_filter_choice = 0
config.previous_menu_state = source_state
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug(f"Ouverture du menu filtre unifie depuis {source_state}")
# Variables globales pour la répétition
key_states = {} # Dictionnaire pour suivre l'état des touches
@@ -98,6 +228,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
"global_sort_menu", # menu de tri global
"platform_search", # recherche globale inter-plateformes
"platform_folder_config", # configuration du dossier personnalisé pour une plateforme
"folder_browser", # navigateur de dossiers intégré
@@ -442,8 +573,8 @@ def filter_games_by_search_query() -> list[Game]:
game_name = game.display_name
if config.search_query.lower() in game_name.lower():
filtered_games.append(game)
return filtered_games
return _sort_local_games(filtered_games)
GLOBAL_SEARCH_KEYBOARD_LAYOUT = [
@@ -462,11 +593,33 @@ def _get_platform_label(platform_id: str) -> str:
return config.platform_names.get(platform_id, platform_id)
def _build_global_search_loading_title() -> str:
fallback = "Loading..."
if _ is None:
return fallback
try:
text = _("global_search_title").format("").replace(" : ", "").rstrip(': ')
except Exception:
text = ""
return text or fallback
def build_global_search_index() -> list[dict]:
indexed_games = []
total_platforms = max(1, len(config.platforms))
for platform_index, platform in enumerate(config.platforms):
platform_id = _get_platform_id(platform)
platform_label = _get_platform_label(platform_id)
_refresh_loading_feedback(
current_system=_build_global_search_loading_title(),
progress=((platform_index / total_platforms) * 100.0),
detail_lines=[
_("loading_platform_counter").format(platform_index + 1, total_platforms) if _ else f"Platform {platform_index + 1}/{total_platforms}",
_("loading_platform_name").format(platform_label) if _ else f"Platform: {platform_label}",
_("loading_read_games_resolve_sources") if _ else "Reading games and resolving sources...",
],
force=True,
)
for game in load_games(platform_id):
display_name = game.display_name or Path(game.name).stem
indexed_games.append({
@@ -478,21 +631,135 @@ def build_global_search_index() -> list[dict]:
"search_name": display_name.lower(),
"url": game.url,
"size": game.size,
"size_bytes": parse_game_size_to_bytes(game.size),
"game_obj": game,
})
indexed_games.sort(key=lambda item: (item["platform_label"].lower(), item["display_name"].lower()))
return indexed_games
_refresh_loading_feedback(
current_system=_build_global_search_loading_title(),
progress=100.0,
detail_lines=[
_("loading_platform_counter").format(total_platforms, total_platforms) if _ else f"Platform {total_platforms}/{total_platforms}",
],
force=True,
)
return _sort_global_items(indexed_games)
def _load_embedded_global_search_index() -> list[dict] | None:
cache_path = getattr(config, 'GLOBAL_SEARCH_INDEX_CACHE_PATH', '')
if not cache_path or not os.path.exists(cache_path):
return None
try:
with open(cache_path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning(f"Impossible de charger l'index global embarque: {exc}")
return None
raw_entries = payload.get('entries') if isinstance(payload, dict) else None
if not isinstance(raw_entries, list):
return None
platform_order: dict[str, int] = {}
for index, platform in enumerate(config.platforms):
platform_order[_get_platform_id(platform)] = index
indexed_games = []
for raw_entry in raw_entries:
if not isinstance(raw_entry, dict):
continue
platform_id = str(raw_entry.get('platform_id') or '').strip()
if not platform_id or platform_id not in platform_order:
continue
game_name = str(raw_entry.get('game_name') or '').strip()
if not game_name:
continue
display_name = str(raw_entry.get('display_name') or '').strip() or Path(game_name).stem
url = str(raw_entry.get('url') or '').strip() or None
size = str(raw_entry.get('size') or '').strip() or None
try:
size_bytes = int(raw_entry.get('size_bytes') or 0)
except (TypeError, ValueError):
size_bytes = 0
game_obj = Game(name=game_name, url=url, size=size, display_name=display_name)
indexed_games.append({
'platform_id': platform_id,
'platform_label': _get_platform_label(platform_id),
'platform_index': platform_order[platform_id],
'game_name': game_name,
'display_name': display_name,
'search_name': display_name.lower(),
'url': url,
'size': size,
'size_bytes': size_bytes,
'game_obj': game_obj,
})
if indexed_games:
logger.info(f"Index global charge depuis le cache embarque: {len(indexed_games)} jeux")
return _sort_global_items(indexed_games)
return None
def _ensure_global_search_index(operation_title: str | None = None) -> None:
index_signature = tuple(config.platforms)
if getattr(config, 'global_search_index', None) and getattr(config, 'global_search_index_signature', None) == index_signature:
return
embedded_index = _load_embedded_global_search_index()
if embedded_index is not None:
config.global_search_index = embedded_index
config.global_search_index_signature = index_signature
return
previous_menu_state = getattr(config, 'menu_state', 'platform')
previous_loading_system = getattr(config, 'current_loading_system', '')
previous_loading_progress = getattr(config, 'loading_progress', 0.0)
previous_loading_detail_lines = list(getattr(config, 'loading_detail_lines', []) or [])
config.menu_state = "loading"
config.current_loading_system = operation_title or _build_global_search_loading_title()
config.loading_progress = 0.0
config.loading_detail_lines = [config.current_loading_system]
config.needs_redraw = True
_refresh_loading_feedback(force=True)
try:
config.global_search_index = build_global_search_index()
config.global_search_index_signature = index_signature
finally:
config.menu_state = previous_menu_state
config.current_loading_system = previous_loading_system
config.loading_progress = previous_loading_progress
config.loading_detail_lines = previous_loading_detail_lines
config.needs_redraw = True
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
items = list(getattr(config, 'global_search_index', []) or [])
filter_obj = getattr(config, 'game_filter_obj', None)
if filter_obj and filter_obj.is_active():
item_by_game = {id(item.get('game_obj')): item for item in items}
filtered_games = filter_obj.apply_filters([item.get('game_obj') for item in items if item.get('game_obj') is not None])
items = [item_by_game[id(game)] for game in filtered_games if id(game) in item_by_game]
if query:
items = [
item for item in items
if query in item.get("search_name", item["display_name"].lower())
]
elif not getattr(config, 'global_search_allow_empty', False):
items = []
config.global_search_results = _sort_global_items(items)
if reset_selection:
config.global_search_selected = 0
@@ -504,30 +771,58 @@ def refresh_global_search_results(reset_selection: bool = True) -> None:
def enter_global_search() -> None:
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
_ensure_global_search_index(_build_global_search_loading_title())
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.global_search_allow_empty = False
config.global_search_title_override = _("global_search_title").format("").replace(" : ", "").rstrip(': ') if _ else 'Recherche globale'
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 enter_global_filtered_results() -> None:
_ensure_global_search_index(_("filter_advanced") if _ else "Loading...")
config.global_search_query = ""
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.global_search_allow_empty = True
config.global_search_title_override = _("filter_advanced") if _ else 'Filtrer'
refresh_global_search_results(reset_selection=True)
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug(f"Affichage des resultats globaux filtres: {len(config.global_search_results)}")
def enter_global_sorted_results() -> None:
_ensure_global_search_index(_("web_sort") if _ else "Loading...")
config.global_search_query = ""
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.global_search_allow_empty = True
config.global_search_title_override = _("web_sort") if _ else 'Trier'
refresh_global_search_results(reset_selection=True)
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug(f"Affichage des resultats globaux tries ({config.global_sort_option}): {len(config.global_search_results)}")
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.global_search_allow_empty = False
config.global_search_title_override = ""
config.selected_key = (0, 0)
config.menu_state = "platform"
config.menu_state = validate_menu_state(getattr(config, 'global_search_return_state', None) or getattr(config, 'previous_menu_state', None))
config.needs_redraw = True
@@ -828,7 +1123,7 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug("Ouverture history depuis platform")
elif is_input_matched(event, "filter"):
enter_global_search()
open_unified_filter_menu("platform")
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
@@ -1043,7 +1338,7 @@ def handle_controls(event, sources, joystick, screen):
config.selected_key = (0, 0)
# Restaurer les jeux filtrés par les filtres avancés si actifs
if hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = _apply_sorted_active_filters()
config.filter_active = True
else:
config.filtered_games = config.games
@@ -1071,7 +1366,7 @@ def handle_controls(event, sources, joystick, screen):
config.search_query = ""
# Restaurer les jeux filtrés par les filtres avancés si actifs
if hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = _apply_sorted_active_filters()
config.filter_active = True
else:
config.filtered_games = config.games
@@ -1148,12 +1443,7 @@ def handle_controls(event, sources, joystick, screen):
event.value)
config.needs_redraw = True
elif is_input_matched(event, "filter"):
# Afficher le menu de choix entre recherche et filtrage avancé
config.menu_state = "filter_menu_choice"
config.selected_filter_choice = 0
config.previous_menu_state = "game"
config.needs_redraw = True
logger.debug("Ouverture du menu de filtrage")
open_unified_filter_menu("game")
elif is_input_matched(event, "history"):
config.history_origin = "game"
config.menu_state = "history"
@@ -1495,7 +1785,7 @@ def handle_controls(event, sources, joystick, screen):
# Dialogue fichier de support
elif config.menu_state == "support_dialog":
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel") or is_input_matched(event, "start"):
# Retour au menu pause
config.menu_state = "pause_menu"
config.needs_redraw = True
@@ -1522,11 +1812,29 @@ def handle_controls(event, sources, joystick, screen):
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)
local_path = entry.get("local_path")
local_filename = entry.get("local_filename")
if not file_exists and local_path and os.path.isfile(local_path):
actual_filename = os.path.basename(local_path)
actual_path = local_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS] direct local_path match used: %s", actual_path)
elif not file_exists and local_filename:
local_filename_path = os.path.join(base_path, str(local_filename))
if os.path.isfile(local_filename_path):
actual_filename = os.path.basename(local_filename_path)
actual_path = local_filename_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS] direct local_filename match used: %s", actual_path)
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
if actual_matches:
actual_filename, actual_path = actual_matches[0]
file_exists = True
if file_exists and actual_path:
remember_history_local_match(entry, actual_filename, actual_path)
config.history_actual_matches = actual_matches
# Stocker les informations pour les autres handlers
@@ -1551,7 +1859,7 @@ def handle_controls(event, sources, joystick, screen):
# Vérifier si c'est une archive ET si le fichier existe
if actual_filename and file_exists:
ext = os.path.splitext(actual_filename)[1].lower()
if ext in ['.zip', '.rar']:
if ext in ['.zip', '.rar', '.7z']:
options.append("extract_archive")
elif ext == '.txt':
options.append("open_file")
@@ -1566,6 +1874,31 @@ def handle_controls(event, sources, joystick, screen):
# Option commune: retour
options.append("back")
diagnostics_signature = (
entry.get("url", ""),
status,
file_exists,
actual_filename or "",
actual_path or "",
tuple(options),
)
if getattr(config, 'history_options_diagnostics_signature', None) != diagnostics_signature:
config.history_options_diagnostics_signature = diagnostics_signature
logger.debug(
"[HISTORY_OPTIONS] platform=%s game=%s status=%s dest_folder=%s base_path=%s file_exists=%s actual_filename=%s actual_path=%s local_path=%s moved_paths=%s options=%s",
platform,
game_name,
status,
dest_folder,
base_path,
file_exists,
actual_filename,
actual_path,
entry.get("local_path"),
entry.get("moved_paths"),
options,
)
total_options = len(options)
sel = getattr(config, 'history_game_option_selection', 0)
@@ -1762,6 +2095,9 @@ def handle_controls(event, sources, joystick, screen):
is_zip_non_supported = pending_download[3] if len(pending_download) > 3 else False
if is_1fichier_url(url):
ensure_download_provider_keys(False)
if missing_all_provider_keys():
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported, task_id))
else:
task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id))
@@ -2028,6 +2364,8 @@ def handle_controls(event, sources, joystick, screen):
success, msg = extract_zip(file_path, dest_dir, url)
elif ext == '.rar':
success, msg = extract_rar(file_path, dest_dir, url)
elif ext == '.7z':
success, msg = extract_7z(file_path, dest_dir, url)
else:
success, msg = False, "Not an archive"
@@ -2873,9 +3211,16 @@ def handle_controls(event, sources, joystick, screen):
shutil.rmtree(config.GAMES_FOLDER)
if os.path.exists(config.IMAGES_FOLDER):
shutil.rmtree(config.IMAGES_FOLDER)
# Mettre à jour la date
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
clear_torrent_manifest_cache()
clear_platform_game_count_cache()
request_torrent_manifest_refresh()
# Mettre à jour la date et mémoriser la version distante déjà proposée
from rgsx_settings import (
set_last_gamelist_prompt_remote_update,
set_last_gamelist_update,
)
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "restart_popup"
config.popup_message = _("popup_gamelist_updating") if _ else "Updating game list... Restarting..."
config.popup_timer = 2000
@@ -2886,18 +3231,25 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "loading"
config.needs_redraw = True
else:
# Pas de cache existant, juste mettre à jour la date et continuer
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
# Pas de cache existant, juste mettre à jour la date et mémoriser la version distante déjà proposée
from rgsx_settings import (
set_last_gamelist_prompt_remote_update,
set_last_gamelist_update,
)
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "loading"
config.needs_redraw = True
else: # Non
logger.info("Utilisateur a refusé la mise à jour de la liste des jeux")
# Ne pas mettre à jour la date pour redemander plus tard
from rgsx_settings import set_last_gamelist_prompt_remote_update
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "platform"
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
logger.info("Utilisateur a annulé le prompt de mise à jour")
from rgsx_settings import set_last_gamelist_prompt_remote_update
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "platform"
config.needs_redraw = True
@@ -3218,9 +3570,12 @@ def handle_controls(event, sources, joystick, screen):
if os.path.exists(config.IMAGES_FOLDER):
shutil.rmtree(config.IMAGES_FOLDER)
logger.debug("Dossier images supprimé avec succès")
clear_torrent_manifest_cache()
clear_platform_game_count_cache()
request_torrent_manifest_refresh()
# Mettre à jour la date de dernière mise à jour
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "restart_popup"
config.popup_message = _("popup_redownload_success")
config.popup_timer = 2000 # bref message
@@ -3302,20 +3657,26 @@ def handle_controls(event, sources, joystick, screen):
# Menu de choix filtrage
elif config.menu_state == "filter_menu_choice":
entries = getattr(config, 'filter_menu_entries', []) or _build_filter_menu_entries(getattr(config, 'filter_menu_context', 'global'))
return_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
total_entries = max(1, len(entries))
if is_input_matched(event, "up"):
config.selected_filter_choice = (config.selected_filter_choice - 1) % 2
config.selected_filter_choice = (config.selected_filter_choice - 1) % total_entries
config.needs_redraw = True
elif is_input_matched(event, "down"):
config.selected_filter_choice = (config.selected_filter_choice + 1) % 2
config.selected_filter_choice = (config.selected_filter_choice + 1) % total_entries
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
if config.selected_filter_choice == 0:
# Recherche par nom (mode existant)
selected_entry = entries[config.selected_filter_choice] if entries else {'key': 'back'}
selected_key = selected_entry.get('key')
if selected_key == 'global_search':
config.global_search_return_state = return_state
enter_global_search()
elif selected_key == 'platform_search':
config.search_mode = True
config.search_query = ""
# Initialiser avec les jeux déjà filtrés par les filtres avancés si actifs
if hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = _apply_sorted_active_filters()
else:
config.filtered_games = config.games
config.current_game = 0
@@ -3323,27 +3684,74 @@ def handle_controls(event, sources, joystick, screen):
config.selected_key = (0, 0)
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Entrée en mode recherche par nom")
else:
# Filtrage avancé
logger.debug("Entrée en mode recherche sur cette plateforme")
elif selected_key == 'global_filter':
from game_filters import GameFilters
from rgsx_settings import load_game_filters
# Initialiser le filtre
if not hasattr(config, 'game_filter_obj'):
config.game_filter_obj = GameFilters()
filter_dict = load_game_filters()
if filter_dict:
config.game_filter_obj.load_from_dict(filter_dict)
config.filter_target_scope = 'local' if getattr(config, 'filter_menu_context', 'global') == 'game' else 'saved'
config.global_search_return_state = return_state
config.previous_menu_state = 'filter_menu_choice'
config.menu_state = "filter_advanced"
config.selected_filter_option = 0
config.needs_redraw = True
logger.debug("Entrée en filtrage avancé")
logger.debug("Entrée en filtrage avancé global")
elif selected_key == 'global_sort':
config.global_search_return_state = return_state
config.global_sort_selected = _get_global_sort_index()
config.menu_state = 'global_sort_menu'
config.previous_menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug("Ouverture du menu de tri global")
else:
config.menu_state = return_state
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
config.menu_state = "game"
config.menu_state = return_state
config.needs_redraw = True
logger.debug(f"Retour depuis menu filtre vers {config.menu_state}")
elif config.menu_state == 'global_sort_menu':
total_items = len(GLOBAL_SORT_OPTIONS) + 1
if is_input_matched(event, 'up'):
config.global_sort_selected = (config.global_sort_selected - 1) % total_items
config.needs_redraw = True
elif is_input_matched(event, 'down'):
config.global_sort_selected = (config.global_sort_selected + 1) % total_items
config.needs_redraw = True
elif is_input_matched(event, 'confirm'):
if config.global_sort_selected < len(GLOBAL_SORT_OPTIONS):
config.global_sort_option = set_global_sort_option(GLOBAL_SORT_OPTIONS[config.global_sort_selected][0])
if getattr(config, 'filter_menu_context', 'global') == 'game':
config.games = _sort_local_games(config.games)
if config.search_query:
config.filtered_games = filter_games_by_search_query()
config.filter_active = True
elif hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = _sort_local_games(config.game_filter_obj.apply_filters(config.games))
config.filter_active = True
else:
config.filtered_games = config.games
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
config.needs_redraw = True
logger.debug(f"Tri local applique sur la liste courante ({config.global_sort_option})")
else:
enter_global_sorted_results()
else:
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
elif is_input_matched(event, 'cancel'):
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug("Retour à la liste des jeux")
# Filtrage avancé
elif config.menu_state == "filter_advanced":
@@ -3475,38 +3883,60 @@ def handle_controls(event, sources, joystick, screen):
if button_idx == 0:
# Apply
save_game_filters(config.game_filter_obj.to_dict())
# Appliquer aux jeux actuels
if config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filter_active = True
if getattr(config, 'filter_target_scope', 'local') == 'global':
enter_global_filtered_results()
elif getattr(config, 'filter_target_scope', 'local') == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
config.needs_redraw = True
else:
config.filtered_games = config.games
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = "game"
config.needs_redraw = True
if config.game_filter_obj.is_active():
config.filtered_games = _apply_sorted_active_filters()
config.filter_active = True
else:
config.filtered_games = config.games
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Filtres appliqués")
elif button_idx == 1:
# Reset
config.game_filter_obj.reset()
save_game_filters(config.game_filter_obj.to_dict())
config.filtered_games = config.games
config.filter_active = False
config.needs_redraw = True
if getattr(config, 'filter_target_scope', 'local') == 'global':
config.needs_redraw = True
elif getattr(config, 'filter_target_scope', 'local') == 'saved':
config.needs_redraw = True
else:
config.filtered_games = config.games
config.filter_active = False
config.needs_redraw = True
logger.debug("Filtres réinitialisés")
elif button_idx == 2:
# Back
config.menu_state = "game"
scope = getattr(config, 'filter_target_scope', 'local')
if scope == 'global':
config.menu_state = "filter_menu_choice"
elif scope == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
else:
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Retour sans appliquer les filtres")
elif is_input_matched(event, "cancel"):
config.menu_state = "game"
scope = getattr(config, 'filter_target_scope', 'local')
if scope == 'global':
config.menu_state = "filter_menu_choice"
elif scope == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
else:
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Annulation du filtrage avancé")
@@ -3809,7 +4239,7 @@ def handle_controls(event, sources, joystick, screen):
# Apply saved filters automatically if any
if config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filtered_games = _apply_sorted_active_filters()
config.filter_active = True
else:
config.filtered_games = config.games

View File

@@ -12,7 +12,8 @@ from utils import (truncate_text_middle, wrap_text, load_system_image, truncate_
check_web_service_status, check_custom_dns_status, load_api_keys,
_get_dest_folder_name, find_file_with_or_without_extension, find_matching_files,
get_connection_status_targets, get_connection_status_snapshot,
get_clean_display_name, get_existing_history_matches)
get_clean_display_name, get_existing_history_matches, remember_history_local_match,
sort_games_list, get_platform_source_badge_key, get_platform_source_badge_surface)
import logging
import math
from history import load_history, is_game_downloaded
@@ -28,6 +29,93 @@ from pathlib import Path
from typing import Dict, Any
import urllib.request
def _get_windows_monitor_physical_sizes() -> list[tuple[int, int]]:
"""Return physical monitor resolutions from Win32, bypassing DPI-scaled SDL values."""
if platform.system() != "Windows":
return []
try:
import ctypes
from ctypes import wintypes
CCHDEVICENAME = 32
ENUM_CURRENT_SETTINGS = -1
class MONITORINFOEXW(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
("szDevice", wintypes.WCHAR * CCHDEVICENAME),
]
class DEVMODEW(ctypes.Structure):
_fields_ = [
("dmDeviceName", wintypes.WCHAR * CCHDEVICENAME),
("dmSpecVersion", wintypes.WORD),
("dmDriverVersion", wintypes.WORD),
("dmSize", wintypes.WORD),
("dmDriverExtra", wintypes.WORD),
("dmFields", wintypes.DWORD),
("dmPositionX", wintypes.LONG),
("dmPositionY", wintypes.LONG),
("dmDisplayOrientation", wintypes.DWORD),
("dmDisplayFixedOutput", wintypes.DWORD),
("dmColor", wintypes.SHORT),
("dmDuplex", wintypes.SHORT),
("dmYResolution", wintypes.SHORT),
("dmTTOption", wintypes.SHORT),
("dmCollate", wintypes.SHORT),
("dmFormName", wintypes.WCHAR * 32),
("dmLogPixels", wintypes.WORD),
("dmBitsPerPel", wintypes.DWORD),
("dmPelsWidth", wintypes.DWORD),
("dmPelsHeight", wintypes.DWORD),
("dmDisplayFlags", wintypes.DWORD),
("dmDisplayFrequency", wintypes.DWORD),
("dmICMMethod", wintypes.DWORD),
("dmICMIntent", wintypes.DWORD),
("dmMediaType", wintypes.DWORD),
("dmDitherType", wintypes.DWORD),
("dmReserved1", wintypes.DWORD),
("dmReserved2", wintypes.DWORD),
("dmPanningWidth", wintypes.DWORD),
("dmPanningHeight", wintypes.DWORD),
]
user32 = ctypes.WinDLL("user32", use_last_error=True)
monitors: list[tuple[int, int]] = []
monitor_enum_proc = ctypes.WINFUNCTYPE(
wintypes.BOOL,
wintypes.HMONITOR,
wintypes.HDC,
ctypes.POINTER(wintypes.RECT),
wintypes.LPARAM,
)
def _callback(hmonitor, hdc, lprect, lparam):
monitor_info = MONITORINFOEXW()
monitor_info.cbSize = ctypes.sizeof(MONITORINFOEXW)
if user32.GetMonitorInfoW(hmonitor, ctypes.byref(monitor_info)):
devmode = DEVMODEW()
devmode.dmSize = ctypes.sizeof(DEVMODEW)
if user32.EnumDisplaySettingsW(monitor_info.szDevice, ENUM_CURRENT_SETTINGS, ctypes.byref(devmode)):
width = int(devmode.dmPelsWidth or 0)
height = int(devmode.dmPelsHeight or 0)
if width > 0 and height > 0:
monitors.append((width, height))
return True
return True
user32.EnumDisplayMonitors(0, 0, monitor_enum_proc(_callback), 0)
return monitors
except Exception as e:
logger.debug(f"Résolution physique Win32 indisponible: {e}")
return []
logger = logging.getLogger(__name__)
OVERLAY = None # Initialisé dans init_display()
@@ -470,7 +558,11 @@ def init_display():
# Obtenir la résolution du moniteur cible
try:
if hasattr(pygame.display, 'get_desktop_sizes') and num_displays > 1:
win32_sizes = _get_windows_monitor_physical_sizes()
if target_monitor < len(win32_sizes):
screen_width, screen_height = win32_sizes[target_monitor]
logger.debug(f"Résolution moniteur via Win32: {screen_width}x{screen_height} (monitor={target_monitor})")
elif hasattr(pygame.display, 'get_desktop_sizes') and num_displays > 1:
desktop_sizes = pygame.display.get_desktop_sizes()
if target_monitor < len(desktop_sizes):
screen_width, screen_height = desktop_sizes[target_monitor]
@@ -821,7 +913,7 @@ def draw_loading_screen(screen):
for detail_line in detail_lines:
if not detail_line:
continue
rendered_lines.extend(wrap_text(str(detail_line), config.small_font, max_detail_width))
rendered_lines.append(truncate_text_middle(str(detail_line), config.small_font, max_detail_width, is_filename=False))
for index, detail_line in enumerate(rendered_lines[:3]):
detail_surface = config.small_font.render(detail_line, True, THEME_COLORS["title_text"])
@@ -1225,6 +1317,22 @@ def get_display_resolution_line():
return ""
def draw_platform_source_badge(screen, platform_name, container_rect):
source_key = get_platform_source_badge_key(platform_name)
if not source_key:
return
badge_size = max(20, min(int(min(container_rect.width, container_rect.height) * 0.24), 44))
badge_surface = get_platform_source_badge_surface(source_key, badge_size)
if badge_surface is None:
return
inset = max(5, badge_size // 6)
badge_x = container_rect.right - badge_size - inset
badge_y = container_rect.top + inset
screen.blit(badge_surface, (badge_x, badge_y))
def draw_platform_header_info(screen, light_mode=False, badge_x=None, max_badge_width=None, include_details=True):
"""Affiche version, controleur connecte et IP reseau dans un cartouche en haut a droite."""
lines = get_platform_header_info_lines(max_badge_width, include_details=include_details)
@@ -1305,10 +1413,10 @@ def draw_platform_grid(screen):
try:
if hasattr(config, 'games_count') and isinstance(config.games_count, dict):
game_count = config.games_count.get(platform_name, 0)
# Fallback dynamique si pas dans le cache (ex: plateformes modifiées à chaud)
# Fallback local sans fetch réseau pour éviter un chargement implicite pendant la navigation.
if game_count == 0 and hasattr(config, 'platform_dict_by_name'):
from utils import load_games # import local pour éviter import circulaire global
game_count = len(load_games(platform_name))
from utils import get_platform_game_count # import local pour éviter import circulaire global
game_count = get_platform_game_count(platform_name, allow_torrent_manifest_fetch=False)
except Exception:
game_count = 0
title_text = f"{platform_name} ({game_count})" if game_count > 0 else f"{platform_name}"
@@ -1704,6 +1812,8 @@ def draw_platform_grid(screen):
screen.blit(temp_image, centered_image_rect)
else:
screen.blit(scaled_image, centered_image_rect)
draw_platform_source_badge(screen, display_name, border_rect)
# Nettoyer le cache périodiquement (garder seulement les images utilisées récemment)
if len(platform_images_cache) > 50: # Limite arbitraire pour éviter une croissance excessive
@@ -1781,7 +1891,10 @@ def draw_game_list(screen):
...
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)
config.filtered_games = sort_games_list(
config.game_filter_obj.apply_filters(config.games),
getattr(config, 'global_sort_option', 'name_asc'),
)
games = config.filtered_games if config.filter_active or config.search_mode else config.games
game_count = len(games)
@@ -2120,15 +2233,20 @@ def get_display_extension(file_name):
def draw_global_search_list(screen):
"""Affiche la recherche globale par nom sur toutes les plateformes."""
"""Affiche la vue globale unifiée (recherche, filtre, tri)."""
query = getattr(config, 'global_search_query', '') or ''
results = getattr(config, 'global_search_results', []) or []
keyboard_active = bool(getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False))
allow_empty = bool(getattr(config, 'global_search_allow_empty', False))
custom_title = (getattr(config, 'global_search_title_override', '') or '').strip()
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 custom_title:
title_text = custom_title if not title_query else f"{custom_title} : {title_query}"
else:
title_text = _("global_search_title").format(title_query)
if results:
title_text += f" ({len(results)})"
@@ -2167,7 +2285,7 @@ def draw_global_search_list(screen):
message_zone_top = title_rect_inflated.bottom + 24
message_zone_bottom = max(message_zone_top + 80, reserved_bottom)
if not query.strip():
if not query.strip() and not allow_empty:
message = _("global_search_empty_query")
lines = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
@@ -2359,17 +2477,36 @@ def draw_history_list(screen):
else:
current_history_item_inverted = 0
# Cherche une entrée en cours de téléchargement pour afficher la vitesse
speed_str = ""
for entry in history:
if entry.get("status") in ["Téléchargement", "Downloading"]:
speed = entry.get("speed", 0.0)
if speed and speed > 0:
speed_str = f" - {speed:.2f} {get_speed_unit()}"
break
active_statuses = {"Téléchargement", "Downloading", "Extracting", "Converting", "Connecting", "Queued"}
completed_statuses = {"Download_OK", "Completed"}
error_statuses = {"Erreur", "Error"}
canceled_statuses = {"Canceled", "Cancelled", "Annulé", "Annule"}
selected_entry = history[current_history_item_inverted] if history and 0 <= current_history_item_inverted < len(history) else None
selected_status = str((selected_entry or {}).get("status") or "")
if selected_entry and selected_status in active_statuses:
downloaded_size = int(selected_entry.get("downloaded_size", 0) or 0)
size_text = format_size(downloaded_size)
try:
selected_speed = float(selected_entry.get("speed", 0.0) or 0.0)
except Exception:
selected_speed = 0.0
speed_text = f"{selected_speed:.2f} {get_speed_unit()}"
title_text = _("history_title_downloading_active").format(size_text, speed_text)
elif selected_entry and selected_status in completed_statuses:
completed_count = sum(1 for item in history if str(item.get("status") or "") in completed_statuses)
title_text = _("history_title_completed_count").format(completed_count)
elif selected_entry and selected_status in error_statuses:
error_count = sum(1 for item in history if str(item.get("status") or "") in error_statuses)
title_text = _("history_title_error_count").format(error_count)
elif selected_entry and selected_status in canceled_statuses:
canceled_count = sum(1 for item in history if str(item.get("status") or "") in canceled_statuses)
title_text = _("history_title_canceled_count").format(canceled_count)
else:
title_text = _("history_title").format(history_count)
screen.blit(OVERLAY, (0, 0))
title_text = _("history_title").format(history_count) + speed_str
title_surface = config.title_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)
@@ -2410,17 +2547,6 @@ def draw_history_list(screen):
else:
current_history_item_inverted = 0
speed = 0.0
if history and history[current_history_item_inverted].get("status") in ["Téléchargement", "Downloading"]:
speed = history[current_history_item_inverted].get("speed", 0.0)
if speed > 0:
speed_str = f"{speed:.2f} {get_speed_unit()}"
title_text = _("history_title").format(history_count) + f" {speed_str}"
else:
title_text = _("history_title").format(history_count)
title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"])
if not history:
logger.debug("Aucun historique disponible")
message = _("history_empty")
@@ -2497,14 +2623,16 @@ def draw_history_list(screen):
folder_text = _get_dest_folder_name(platform)
# Correction du calcul de la taille
size = entry.get("total_size", 0)
color = THEME_COLORS["fond_lignes"] if i == current_history_item_inverted else THEME_COLORS["text"]
size_text = format_size(size)
status = entry.get("status", "Inconnu")
progress = entry.get("progress", 0)
progress = max(0, min(100, progress)) # Clamp progress between 0 and 100
size = entry.get("total_size", 0)
if (not size or int(size or 0) <= 0) and status in ["Téléchargement", "Downloading"]:
size = entry.get("downloaded_size", 0)
color = THEME_COLORS["fond_lignes"] if i == current_history_item_inverted else THEME_COLORS["text"]
size_text = format_size(size)
# Precompute provider prefix once
provider_prefix = entry.get("provider_prefix") or (entry.get("provider") + ":" if entry.get("provider") else "")
@@ -2512,13 +2640,22 @@ def draw_history_list(screen):
if status in ["Téléchargement", "Downloading"]:
# Vérifier si un message personnalisé existe (ex: mode gratuit avec attente)
custom_message = entry.get('message', '')
total_size_value = int(entry.get("total_size", 0) or 0)
downloaded_size_value = int(entry.get("downloaded_size", 0) or 0)
seeds_value = int(entry.get("seeds", 0) or 0)
connections_value = int(entry.get("connections", 0) or 0)
# Détecter les messages du mode gratuit (commencent par '[' dans toutes les langues)
if custom_message and custom_message.strip().startswith('['):
# Utiliser le message personnalisé pour le mode gratuit
status_text = custom_message
elif total_size_value <= 0 and downloaded_size_value > 0:
status_text = str(status)
else:
# Comportement normal: afficher le pourcentage
status_text = _("history_status_downloading").format(progress)
display_connections = connections_value if connections_value > 0 else seeds_value
if display_connections > 0:
status_text = f"{status_text} CN:{display_connections}"
# Coerce to string and prefix provider when relevant
status_text = str(status_text or "")
if provider_prefix and not status_text.startswith(provider_prefix):
@@ -2949,6 +3086,9 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
"pause_connection_status": [
("cancel", _("controls_cancel_back")),
],
"support_dialog": [
("start", _("controls_cancel_back")),
],
}
# Cas spécial : pause_settings_menu avec option roms_folder sélectionnée
@@ -3807,7 +3947,7 @@ def draw_pause_games_menu(screen, selected_index):
hide_premium_txt = f"{hide_premium_label}: < {status_hide_premium} >"
# Filter platforms
filter_txt = _("submenu_display_filter_platforms") if _ else "Filter Platforms"
filter_txt = _("submenu_display_filter_platforms") if _ else "Show/Hide Platforms"
back_txt = _("menu_back") if _ else "Back"
options = [update_txt, scan_txt, history_txt, source_txt, unsupported_txt, hide_premium_txt, filter_txt, back_txt]
@@ -4582,12 +4722,15 @@ def draw_gamelist_update_prompt(screen):
screen.blit(OVERLAY, (0, 0))
from config import GAMELIST_UPDATE_DAYS
from rgsx_settings import get_last_gamelist_update
from rgsx_settings import get_last_gamelist_update, format_gamelist_update_display
last_update = get_last_gamelist_update()
if last_update:
message = _("gamelist_update_prompt_with_date").format(GAMELIST_UPDATE_DAYS, last_update) if _ else f"Game list hasn't been updated for more than {GAMELIST_UPDATE_DAYS} days (last update: {last_update}). Download the latest version?"
remote_update = getattr(config, 'gamelist_remote_update_display', '') or ''
local_update = getattr(config, 'gamelist_local_update_display', '') or format_gamelist_update_display(last_update)
if last_update and remote_update:
message = _("gamelist_update_prompt_remote_newer").format(local_update, remote_update) if _ else f"A newer online game list is available (local: {local_update}, online: {remote_update}). Download the latest version?"
elif last_update:
message = _("gamelist_update_prompt_with_date").format(local_update) if _ else f"Local game list last update: {local_update}. Download the latest version?"
else:
message = _("gamelist_update_prompt_first_time") if _ else "Would you like to download the latest game list?"
@@ -4885,22 +5028,17 @@ def draw_support_dialog(screen):
screen.blit(OVERLAY, (0, 0))
# Récupérer le nom du bouton "cancel/back" depuis la configuration des contrôles
cancel_key = "SELECT"
try:
from controls_mapper import get_mapped_button
cancel_key = get_mapped_button("cancel") or "SELECT"
except Exception:
pass
# Cet écran se ferme via l'action Start dans la navigation actuelle.
return_key = get_control_display("start", "Start")
# Déterminer le message à afficher (succès ou erreur)
if hasattr(config, 'support_zip_error') and config.support_zip_error:
title = _("support_dialog_title")
message = _("support_dialog_error").format(config.support_zip_error, cancel_key)
message = _("support_dialog_error").format(config.support_zip_error, return_key)
else:
title = _("support_dialog_title")
zip_path = getattr(config, 'support_zip_path', 'rgsx_support.zip')
message = _("support_dialog_message").format(zip_path, cancel_key)
message = _("support_dialog_message").format(zip_path, return_key)
# Diviser le message par les retours à la ligne puis wrapper chaque segment
raw_segments = message.split('\n') if message else []
@@ -5085,11 +5223,29 @@ def draw_history_game_options(screen):
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)
local_path = entry.get("local_path")
local_filename = entry.get("local_filename")
if not file_exists and local_path and os.path.isfile(local_path):
actual_filename = os.path.basename(local_path)
actual_path = local_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS_RENDER] direct local_path match used: %s", actual_path)
elif not file_exists and local_filename:
local_filename_path = os.path.join(base_path, str(local_filename))
if os.path.isfile(local_filename_path):
actual_filename = os.path.basename(local_filename_path)
actual_path = local_filename_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS_RENDER] direct local_filename match used: %s", actual_path)
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
if actual_matches:
actual_filename, actual_path = actual_matches[0]
file_exists = True
if file_exists and actual_path:
remember_history_local_match(entry, actual_filename, actual_path)
# Déterminer les options disponibles selon le statut
options = []
@@ -5119,7 +5275,7 @@ def draw_history_game_options(screen):
# Vérifier si c'est une archive ET si le fichier existe
if actual_filename and file_exists:
ext = os.path.splitext(actual_filename)[1].lower()
if ext in ['.zip', '.rar']:
if ext in ['.zip', '.rar', '.7z']:
options.append("extract_archive")
option_labels.append(_("history_option_extract_archive"))
elif ext == '.txt':
@@ -5139,6 +5295,31 @@ def draw_history_game_options(screen):
option_labels.append(_("history_option_delete_game"))
options.append("back")
option_labels.append(_("history_option_back"))
diagnostics_signature = (
entry.get("url", ""),
status,
file_exists,
actual_filename or "",
actual_path or "",
tuple(options),
)
if getattr(config, 'history_options_render_signature', None) != diagnostics_signature:
config.history_options_render_signature = diagnostics_signature
logger.debug(
"[HISTORY_OPTIONS_RENDER] platform=%s game=%s status=%s dest_folder=%s base_path=%s file_exists=%s actual_filename=%s actual_path=%s local_path=%s moved_paths=%s options=%s",
platform,
game_name,
status,
dest_folder,
base_path,
file_exists,
actual_filename,
actual_path,
entry.get("local_path"),
entry.get("moved_paths"),
options,
)
# Calculer dimensions
title = _("history_game_options_title")
@@ -5376,7 +5557,7 @@ def draw_history_confirm_delete(screen):
def draw_history_extract_archive(screen):
"""Affiche la confirmation d'extraction d'archive."""
"""Affiche la confirmation d'extraction forcée d'archive."""
screen.blit(OVERLAY, (0, 0))
if not config.history or config.current_history_item >= len(config.history):
@@ -5385,7 +5566,8 @@ def draw_history_extract_archive(screen):
entry = config.history[config.current_history_item]
game_name = entry.get("game_name", "Unknown")
message = f"Extract archive: {game_name}?"
prompt = _("history_extract_archive_confirm") if _ else "Force extract archive"
message = f"{prompt}: {game_name}?"
wrapped_message = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
text_height = len(wrapped_message) * line_height
@@ -5681,7 +5863,7 @@ def draw_scraper_screen(screen):
def draw_filter_menu_choice(screen):
"""Affiche le menu de choix entre recherche par nom et filtrage avancé"""
"""Affiche le menu filtre unifie."""
screen.blit(OVERLAY, (0, 0))
# Titre
@@ -5691,10 +5873,8 @@ def draw_filter_menu_choice(screen):
screen.blit(title_surface, title_rect)
# Options
options = [
_("filter_search_by_name"),
_("filter_advanced")
]
entries = getattr(config, 'filter_menu_entries', []) or []
options = [entry.get('label', '') for entry in entries]
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
@@ -5746,6 +5926,49 @@ def draw_filter_menu_choice(screen):
screen.blit(text_surface, text_rect)
def draw_global_sort_menu(screen):
screen.blit(OVERLAY, (0, 0))
title = _("web_sort") if _ else "Trier"
title_surface = config.title_font.render(title, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, 60))
screen.blit(title_surface, title_rect)
options = [
_("web_sort_name_asc") if _ else "A-Z (Nom)",
_("web_sort_name_desc") if _ else "Z-A (Nom)",
_("web_sort_size_asc") if _ else "Taille -+ (Petit d'abord)",
_("web_sort_size_desc") if _ else "Taille +- (Grand d'abord)",
_("menu_back") if _ else "Retour",
]
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(60, font_height + 30)
max_text_width = 0
for option in options:
text_surface = config.font.render(option, True, THEME_COLORS["text"])
max_text_width = max(max_text_width, text_surface.get_width())
button_width = max(460, max_text_width + 80)
menu_y = 150
button_spacing = 20
for i, option in enumerate(options):
y = menu_y + i * (button_height + button_spacing)
x = (config.screen_width - button_width) // 2
if i == getattr(config, 'global_sort_selected', 0):
color = THEME_COLORS["button_selected"]
border_color = THEME_COLORS["border_selected"]
else:
color = THEME_COLORS["button_idle"]
border_color = THEME_COLORS["border"]
pygame.draw.rect(screen, color, (x, y, button_width, button_height), border_radius=12)
pygame.draw.rect(screen, border_color, (x, y, button_width, button_height), 3, border_radius=12)
text_surface = config.font.render(option, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(config.screen_width // 2, y + button_height // 2))
screen.blit(text_surface, text_rect)
def draw_filter_advanced(screen):
"""Affiche l'écran de filtrage avancé"""

View File

@@ -2,11 +2,166 @@ import json
import os
import logging
import re
import threading
import time
import config
from datetime import datetime
logger = logging.getLogger(__name__)
_history_write_state_lock = threading.Lock()
_history_write_failures = 0
_history_write_last_error = ""
_history_write_last_failure_ts = 0.0
_history_write_last_log_ts = 0.0
_history_write_last_probe_ts = 0.0
_history_write_ok = True
_HISTORY_WRITE_FAILURE_COOLDOWN_SEC = 1.5
_HISTORY_WRITE_PROBE_CACHE_SEC = 8.0
def _set_history_write_status(ok, error_message=""):
global _history_write_ok, _history_write_last_error
with _history_write_state_lock:
_history_write_ok = bool(ok)
_history_write_last_error = error_message or ""
failures = _history_write_failures
last_failure_ts = _history_write_last_failure_ts
setattr(config, "history_write_ok", bool(ok))
setattr(config, "history_write_error", error_message or "")
setattr(config, "history_write_failure_count", failures)
setattr(config, "history_write_last_failure_ts", last_failure_ts)
def _register_history_write_failure(exc):
global _history_write_failures, _history_write_last_failure_ts, _history_write_last_log_ts
now = time.time()
raw_error = str(exc)
message = (
f"Erreur ecriture history.json: {raw_error}. "
"Le telechargement continue sans sauvegarde temps reel."
)
with _history_write_state_lock:
_history_write_failures += 1
_history_write_last_failure_ts = now
_set_history_write_status(False, message)
should_log = False
with _history_write_state_lock:
if _history_write_failures in (1, 5, 20):
should_log = True
elif now - _history_write_last_log_ts >= 5.0:
should_log = True
if should_log:
_history_write_last_log_ts = now
if should_log:
logger.error(message)
def _register_history_write_success():
global _history_write_failures
recovered = False
with _history_write_state_lock:
if _history_write_failures > 0:
recovered = True
_history_write_failures = 0
_set_history_write_status(True, "")
if recovered:
logger.info("Ecriture history.json retablie")
def get_history_write_status():
with _history_write_state_lock:
return {
"ok": _history_write_ok,
"message": _history_write_last_error,
"failures": _history_write_failures,
"last_failure_ts": _history_write_last_failure_ts,
"last_probe_ts": _history_write_last_probe_ts,
}
def check_history_write_access(force=False):
global _history_write_last_probe_ts
history_path = getattr(config, 'HISTORY_PATH')
now = time.time()
with _history_write_state_lock:
use_cached_probe = (
not force
and (now - _history_write_last_probe_ts) < _HISTORY_WRITE_PROBE_CACHE_SEC
)
if use_cached_probe:
return _history_write_ok, _history_write_last_error
probe_a = f"{history_path}.probe.{os.getpid()}.{threading.get_ident()}.a"
probe_b = f"{history_path}.probe.{os.getpid()}.{threading.get_ident()}.b"
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
with open(probe_a, "w", encoding="utf-8") as handle:
handle.write("[]")
handle.flush()
os.fsync(handle.fileno())
os.replace(probe_a, probe_b)
os.remove(probe_b)
with _history_write_state_lock:
_history_write_last_probe_ts = now
_register_history_write_success()
return True, ""
except Exception as exc:
with _history_write_state_lock:
_history_write_last_probe_ts = now
_register_history_write_failure(exc)
return False, get_history_write_status().get("message", "")
finally:
for temp_path in (probe_a, probe_b):
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
def _atomic_write_json(target_path, payload):
temp_path = f"{target_path}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
last_error = None
for attempt in range(5):
try:
os.replace(temp_path, target_path)
last_error = None
break
except (PermissionError, OSError) as e:
last_error = e
time.sleep(0.15 * (attempt + 1))
if last_error is not None:
raise last_error
finally:
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
# Chemin par défaut pour history.json
def init_history():
@@ -23,6 +178,7 @@ def init_history():
logger.error(f"Erreur lors de la création du fichier d'historique : {e}")
else:
logger.info(f"Fichier d'historique trouvé : {history_path}")
check_history_write_access(force=True)
return history_path
def load_history():
@@ -72,29 +228,27 @@ def load_history():
logger.error(f"Erreur inattendue lors de la lecture de {history_path} : {e}")
return []
def save_history(history):
"""Sauvegarde l'historique dans history.json de manière atomique."""
def save_history(history, force=False):
"""Sauvegarde l'historique dans history.json de manière atomique (mode non-bloquant en cas d'erreur)."""
history_path = getattr(config, 'HISTORY_PATH')
now = time.time()
state = get_history_write_status()
if (
not force
and not state.get("ok", True)
and (now - float(state.get("last_failure_ts", 0.0) or 0.0)) < _HISTORY_WRITE_FAILURE_COOLDOWN_SEC
):
return False
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
# Écriture atomique : écrire dans un fichier temporaire puis renommer
temp_path = history_path + '.tmp'
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(history, f, indent=2, ensure_ascii=False)
f.flush() # Forcer l'écriture sur disque
os.fsync(f.fileno()) # Synchroniser avec le système de fichiers
# Renommer atomiquement (remplace l'ancien fichier)
os.replace(temp_path, history_path)
_atomic_write_json(history_path, history)
_register_history_write_success()
return True
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {history_path} : {e}")
# Nettoyer le fichier temporaire en cas d'erreur
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except:
pass
_register_history_write_failure(e)
return False
def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None):
"""Ajoute une entrée à l'historique."""
@@ -314,23 +468,10 @@ def save_downloaded_games(downloaded_games_dict):
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(normalized_downloaded, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, downloaded_path)
_atomic_write_json(downloaded_path, normalized_downloaded)
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:
if os.path.exists(temp_path):
os.remove(temp_path)
except:
pass
def mark_game_as_downloaded(platform_name, game_name, file_size=None):

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Spiele und Bilder werden heruntergeladen...",
"loading_extracting_data": "Der initiale Datenordner wird entpackt...",
"loading_load_systems": "Systeme werden geladen...",
"loading_platform_counter": "Plattform {0}/{1}",
"loading_platform_name": "Plattform: {0}",
"loading_game_entries_progress": "Spiele gescannt: {0}/{1}",
"loading_read_games_resolve_sources": "Spiele werden gelesen und Quellen aufgelost...",
"loading_torrent_manifest_analysis": "Torrent-Manifest wird analysiert: {0}",
"loading_torrent_files_progress": "Torrent-Dateien: {0}/{1}",
"error_extract_data_failed": "Herunterladen oder Entpacken des initialen Datenordners fehlgeschlagen.",
"error_no_internet": "Keine Internetverbindung. Überprüfe dein Netzwerk.",
"error_api_key": "Achtung, du musst deinen API-Schlüssel (nur Premium) in der Datei {0} eingeben",
@@ -26,12 +32,17 @@
"game_filter": "Aktiver Filter: {0}",
"game_search": "Filtern: {0}",
"global_search_title": "Globale Suche: {0}",
"platform_search_title": "Diese Plattform durchsuchen",
"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_title_downloading_active": "Download - {0} - {1}",
"history_title_completed_count": "Abgeschlossene Downloads ({0})",
"history_title_error_count": "Fehlgeschlagene Downloads ({0})",
"history_title_canceled_count": "Abgebrochene Downloads ({0})",
"history_empty": "Keine Downloads im Verlauf",
"history_column_system": "System",
"history_column_game": "Spielname",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Kostenloser Modus] Formular wird gesendet...",
"free_mode_link_found": "[Kostenloser Modus] Link gefunden: {0}...",
"free_mode_completed": "[Kostenloser Modus] Abgeschlossen: {0}",
"free_mode_guest_slots_unavailable": "1fichier: Der kostenlose Gast-Download ist vorübergehend nicht verfügbar (alle Slots sind belegt). Bitte versuchen Sie es später erneut.",
"free_mode_unavailable_in_app": "1fichier: Dieser Download ist derzeit in der Anwendung nicht verfügbar. Bitte versuchen Sie es später erneut.",
"free_mode_premium_advice": "Für unbegrenzte Downloads jederzeit und mit voller Geschwindigkeit benötigen Sie ein Premium-Konto oder einen Debrid-Dienst und müssen dessen API-Schlüssel in RGSX eintragen.",
"download_status": "{0}: {1}",
"download_canceled": "Download vom Benutzer abgebrochen.",
"download_removed_from_queue": "Aus der Download-Warteschlange entfernt",
@@ -57,7 +71,8 @@
"confirm_exit_with_downloads": "Achtung: {0} Download(s) laufen. Trotzdem beenden?",
"confirm_clear_history": "Verlauf löschen?",
"confirm_redownload_cache": "Spieleliste aktualisieren?",
"gamelist_update_prompt_with_date": "Die Spieleliste wurde seit mehr als {0} Tagen nicht aktualisiert (letzte Aktualisierung: {1}). Die neueste Version herunterladen?",
"gamelist_update_prompt_with_date": "Letzte lokale Aktualisierung der Spieleliste: {0}. Neueste Version herunterladen?",
"gamelist_update_prompt_remote_newer": "Online ist eine neuere Spieleliste verfügbar (lokal: {0}, online: {1}). Neueste Version herunterladen?",
"gamelist_update_prompt_first_time": "Möchten Sie die neueste Spieleliste herunterladen?",
"popup_redownload_success": "Cache gelöscht, bitte die Anwendung neu starten",
"popup_no_cache": "Kein Cache gefunden.\nBitte starte die Anwendung neu, um die Spiele zu laden.",
@@ -298,7 +313,7 @@
"footer_joystick": "Joystick: {0}",
"history_game_options_title": "Spiel Optionen",
"history_option_download_folder": "Datei lokalisieren",
"history_option_extract_archive": "Archiv extrahieren",
"history_option_extract_archive": "Archivsextraktion erzwingen",
"history_option_open_file": "Datei öffnen",
"history_option_scraper": "Metadaten abrufen",
"history_option_remove_from_queue": "Aus Warteschlange entfernen",
@@ -314,6 +329,7 @@
"history_scraper_not_implemented": "Scraper noch nicht implementiert",
"history_confirm_delete": "Dieses Spiel von der Festplatte löschen?",
"history_file_not_found": "Datei nicht gefunden",
"history_extract_archive_confirm": "Archivsextraktion erzwingen",
"history_extracting": "Extrahieren...",
"history_extracted": "Extrahiert",
"history_delete_success": "Spiel erfolgreich gelöscht",
@@ -427,8 +443,8 @@
"web_sort": "Sortieren nach",
"web_sort_name_asc": "A-Z (Name)",
"web_sort_name_desc": "Z-A (Name)",
"web_sort_size_asc": "Größe +- (Klein zuerst)",
"web_sort_size_desc": "Größe -+ (Groß zuerst)",
"web_sort_size_asc": "Größe -+ (Klein zuerst)",
"web_sort_size_desc": "Größe +- (Groß zuerst)",
"download_already_present": " (bereits vorhanden)",
"network_download_ok": "Download erfolgreich: {0}",
"web_filter_region": "Region",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Downloading games and images...",
"loading_extracting_data": "Extracting initial Data folder...",
"loading_load_systems": "Loading systems...",
"loading_platform_counter": "Platform {0}/{1}",
"loading_platform_name": "Platform: {0}",
"loading_game_entries_progress": "Games scanned: {0}/{1}",
"loading_read_games_resolve_sources": "Reading games and resolving sources...",
"loading_torrent_manifest_analysis": "Analyzing torrent manifest: {0}",
"loading_torrent_files_progress": "Torrent files: {0}/{1}",
"error_extract_data_failed": "Failed to download or extract the initial Data folder.",
"error_no_internet": "No Internet connection. Check your network.",
"error_api_key": "Please enter your API key (premium only) in the file {0}",
@@ -26,12 +32,17 @@
"game_filter": "Active filter: {0}",
"game_search": "Filter: {0}",
"global_search_title": "Global search: {0}",
"platform_search_title": "Search this platform",
"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_title_downloading_active": "Downloading - {0} - {1}",
"history_title_completed_count": "Completed downloads ({0})",
"history_title_error_count": "Failed downloads ({0})",
"history_title_canceled_count": "Canceled downloads ({0})",
"history_empty": "No downloads in history",
"history_column_system": "System",
"history_column_game": "Game name",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Free mode] Submitting form...",
"free_mode_link_found": "[Free mode] Link found: {0}...",
"free_mode_completed": "[Free mode] Completed: {0}",
"free_mode_guest_slots_unavailable": "1fichier: free guest download is temporarily unavailable (all slots are currently in use). Please try again later.",
"free_mode_unavailable_in_app": "1fichier: this download is not available in the application right now. Please try again later.",
"free_mode_premium_advice": "For unlimited, on-demand, full-speed downloads, you need a premium account or debrid service and must enter its API key in RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download canceled by user.",
"download_removed_from_queue": "Removed from download queue",
@@ -57,7 +71,8 @@
"confirm_exit_with_downloads": "Attention: {0} download(s) in progress. Quit anyway?",
"confirm_clear_history": "Clear history?",
"confirm_redownload_cache": "Update games list?",
"gamelist_update_prompt_with_date": "Game list hasn't been updated for more than {0} days (last update: {1}). Download the latest version?",
"gamelist_update_prompt_with_date": "Local game list last update: {0}. Download the latest version?",
"gamelist_update_prompt_remote_newer": "A newer game list is available online (local: {0}, online: {1}). Download the latest version?",
"gamelist_update_prompt_first_time": "Would you like to download the latest game list?",
"popup_redownload_success": "Cache cleared, please restart the application",
"popup_no_cache": "No cache found.\nPlease restart the application to load games.",
@@ -190,7 +205,7 @@
"submenu_display_font_size": "Font Size",
"submenu_display_show_unsupported": "Show unsupported systems: {status}",
"submenu_display_allow_unknown_ext": "Hide unknown ext warn: {status}",
"submenu_display_filter_platforms": "Filter systems",
"submenu_display_filter_platforms": "Show/Hide Platforms",
"status_on": "On",
"status_off": "Off",
"status_present": "Present",
@@ -297,7 +312,7 @@
"footer_joystick": "Joystick: {0}",
"history_game_options_title": "Game Options",
"history_option_download_folder": "Locate file",
"history_option_extract_archive": "Extract archive",
"history_option_extract_archive": "Force extract archive",
"history_option_open_file": "Open file",
"history_option_scraper": "Scrape metadata",
"history_option_remove_from_queue": "Remove from queue",
@@ -316,6 +331,7 @@
"history_scraper_not_implemented": "Scraper not yet implemented",
"history_confirm_delete": "Delete this game from disk?",
"history_file_not_found": "File not found",
"history_extract_archive_confirm": "Force extract archive",
"history_extracting": "Extracting...",
"history_extracted": "Extracted",
"history_delete_success": "Game deleted successfully",
@@ -429,8 +445,8 @@
"web_sort": "Sort by",
"web_sort_name_asc": "A-Z (Name)",
"web_sort_name_desc": "Z-A (Name)",
"web_sort_size_asc": "Size +- (Small first)",
"web_sort_size_desc": "Size -+ (Large first)",
"web_sort_size_asc": "Size -+ (Small first)",
"web_sort_size_desc": "Size +- (Large first)",
"web_filter_region": "Region",
"web_filter_hide_non_release": "Hide Demos/Betas/Protos",
"web_filter_regex_mode": "Enable Regex Search",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Descargando juegos e imágenes...",
"loading_extracting_data": "Extrayendo la carpeta de datos inicial...",
"loading_load_systems": "Cargando sistemas...",
"loading_platform_counter": "Plataforma {0}/{1}",
"loading_platform_name": "Plataforma: {0}",
"loading_game_entries_progress": "Juegos analizados: {0}/{1}",
"loading_read_games_resolve_sources": "Leyendo juegos y resolviendo fuentes...",
"loading_torrent_manifest_analysis": "Analizando el manifiesto torrent: {0}",
"loading_torrent_files_progress": "Archivos del torrent: {0}/{1}",
"error_extract_data_failed": "Error al descargar o extraer la carpeta de datos inicial.",
"error_no_internet": "Sin conexión a Internet. Verifica tu red.",
"error_api_key": "Atención, debes ingresar tu clave API (solo premium) en el archivo {0}",
@@ -26,12 +32,17 @@
"game_filter": "Filtro activo: {0}",
"game_search": "Filtrar: {0}",
"global_search_title": "Busqueda global: {0}",
"platform_search_title": "Buscar en esta plataforma",
"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_title_downloading_active": "Descargando - {0} - {1}",
"history_title_completed_count": "Descargas completadas ({0})",
"history_title_error_count": "Descargas con error ({0})",
"history_title_canceled_count": "Descargas canceladas ({0})",
"history_empty": "No hay descargas en el historial",
"history_column_system": "Sistema",
"history_column_game": "Nombre del juego",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Modo gratuito] Enviando formulario...",
"free_mode_link_found": "[Modo gratuito] Enlace encontrado: {0}...",
"free_mode_completed": "[Modo gratuito] Completado: {0}",
"free_mode_guest_slots_unavailable": "1fichier: la descarga gratuita como invitado no está disponible temporalmente (todos los cupos están ocupados). Inténtelo de nuevo más tarde.",
"free_mode_unavailable_in_app": "1fichier: esta descarga no está disponible en la aplicación en este momento. Inténtelo de nuevo más tarde.",
"free_mode_premium_advice": "Para descargar de forma ilimitada, cuando quiera y a máxima velocidad, necesita una cuenta premium o un desbridizador y debe introducir su clave API en RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Descarga cancelada por el usuario.",
"download_removed_from_queue": "Eliminado de la cola de descarga",
@@ -56,7 +70,8 @@
"confirm_exit": "¿Salir de la aplicación?",
"confirm_exit_with_downloads": "Atención: {0} descarga(s) en curso. ¿Salir de todas formas?",
"confirm_clear_history": "¿Vaciar el historial?",
"confirm_redownload_cache": "¿Actualizar la lista de juegos?", "gamelist_update_prompt_with_date": "La lista de juegos no se ha actualizado durante más de {0} días (última actualización: {1}). ¿Descargar la última versión?",
"confirm_redownload_cache": "¿Actualizar la lista de juegos?", "gamelist_update_prompt_with_date": "Última actualización local de la lista de juegos: {0}. ¿Descargar la última versión?",
"gamelist_update_prompt_remote_newer": "Hay una lista de juegos más reciente disponible en línea (local: {0}, en línea: {1}). ¿Descargar la última versión?",
"gamelist_update_prompt_first_time": "¿Desea descargar la última lista de juegos?", "popup_redownload_success": "Caché borrada, por favor reinicia la aplicación",
"popup_no_cache": "No se encontró caché.\nPor favor, reinicia la aplicación para cargar los juegos.",
"popup_countdown": "Este mensaje se cerrará en {0} segundo{1}",
@@ -298,7 +313,7 @@
"footer_joystick": "Joystick: {0}",
"history_game_options_title": "Opciones del juego",
"history_option_download_folder": "Localizar archivo",
"history_option_extract_archive": "Extraer archivo",
"history_option_extract_archive": "Forzar extraccion del archivo",
"history_option_open_file": "Abrir archivo",
"history_option_scraper": "Obtener metadatos",
"history_option_remove_from_queue": "Quitar de la cola",
@@ -314,6 +329,7 @@
"history_scraper_not_implemented": "Scraper aún no implementado",
"history_confirm_delete": "¿Eliminar este juego del disco?",
"history_file_not_found": "Archivo no encontrado",
"history_extract_archive_confirm": "Forzar extraccion del archivo",
"history_extracting": "Extrayendo...",
"history_extracted": "Extraído",
"history_delete_success": "Juego eliminado con éxito",
@@ -427,8 +443,8 @@
"web_sort": "Ordenar por",
"web_sort_name_asc": "A-Z (Nombre)",
"web_sort_name_desc": "Z-A (Nombre)",
"web_sort_size_asc": "Tamaño +- (Menor primero)",
"web_sort_size_desc": "Tamaño -+ (Mayor primero)",
"web_sort_size_asc": "Tamaño -+ (Menor primero)",
"web_sort_size_desc": "Tamaño +- (Mayor primero)",
"web_filter_region": "Región",
"web_filter_hide_non_release": "Ocultar Demos/Betas/Protos",
"web_filter_regex_mode": "Activar búsqueda Regex",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Téléchargement des jeux et images...",
"loading_extracting_data": "Extraction du dossier de données initial...",
"loading_load_systems": "Chargement des systèmes...",
"loading_platform_counter": "Plateforme {0}/{1}",
"loading_platform_name": "Plateforme : {0}",
"loading_game_entries_progress": "Jeux analyses : {0}/{1}",
"loading_read_games_resolve_sources": "Lecture des jeux et résolution des sources...",
"loading_torrent_manifest_analysis": "Analyse du manifest torrent : {0}",
"loading_torrent_files_progress": "Fichiers du torrent : {0}/{1}",
"error_extract_data_failed": "Échec du téléchargement ou de l'extraction du dossier de données initial.",
"error_no_internet": "Pas de connexion Internet. Vérifiez votre réseau.",
"error_api_key": "Attention il faut renseigner sa clé API (premium only) dans le fichier {0}",
@@ -26,12 +32,17 @@
"game_filter": "Filtre actif : {0}",
"game_search": "Filtrer : {0}",
"global_search_title": "Recherche globale : {0}",
"platform_search_title": "Recherche sur cette plateforme",
"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_title_downloading_active": "Téléchargement - {0} - {1}",
"history_title_completed_count": "Téléchargements terminés ({0})",
"history_title_error_count": "Téléchargements en erreur ({0})",
"history_title_canceled_count": "Téléchargements annulés ({0})",
"history_empty": "Aucun téléchargement dans l'historique",
"history_column_system": "Système",
"history_column_game": "Nom du jeu",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Mode gratuit] Soumission formulaire...",
"free_mode_link_found": "[Mode gratuit] Lien trouvé: {0}...",
"free_mode_completed": "[Mode gratuit] Terminé: {0}",
"free_mode_guest_slots_unavailable": "1fichier : le téléchargement gratuit invité est temporairement indisponible (tous les créneaux sont occupés). Réessayez plus tard.",
"free_mode_unavailable_in_app": "1fichier : ce téléchargement n'est pas disponible dans l'application pour le moment. Réessayez plus tard.",
"free_mode_premium_advice": "Pour télécharger de manière illimitée, quand vous voulez et à pleine vitesse, vous devez obtenir un compte premium ou un débrideur et entrer votre clé API dans RGSX.",
"download_status": "{0} : {1}",
"download_canceled": "Téléchargement annulé par l'utilisateur.",
"download_removed_from_queue": "Retiré de la file de téléchargement",
@@ -57,7 +71,8 @@
"confirm_exit_with_downloads": "Attention : {0} téléchargement(s) en cours. Quitter quand même ?",
"confirm_clear_history": "Vider l'historique ?",
"confirm_redownload_cache": "Mettre à jour la liste des jeux ?",
"gamelist_update_prompt_with_date": "La liste des jeux n'a pas été mise à jour depuis plus de {0} jours (dernière mise à jour : {1}). Télécharger la dernière version ?",
"gamelist_update_prompt_with_date": "Dernière mise à jour locale de la liste des jeux : {0}. Télécharger la dernière version ?",
"gamelist_update_prompt_remote_newer": "Une liste des jeux plus récente est disponible en ligne (locale : {0}, en ligne : {1}). Télécharger la dernière version ?",
"gamelist_update_prompt_first_time": "Souhaitez-vous télécharger la dernière liste des jeux ?",
"popup_redownload_success": "Le cache a été effacé, merci de relancer l'application",
"popup_no_cache": "Aucun cache trouvé.\nVeuillez redémarrer l'application pour charger les jeux.",
@@ -187,7 +202,7 @@
"submenu_display_font_size": "Taille Police",
"submenu_display_show_unsupported": "Afficher systèmes non supportés : {status}",
"submenu_display_allow_unknown_ext": "Masquer avert. ext inconnue : {status}",
"submenu_display_filter_platforms": "Filtrer systèmes",
"submenu_display_filter_platforms": "Afficher/Masquer plateformes",
"status_on": "Oui",
"status_off": "Non",
"status_present": "Présente",
@@ -297,7 +312,7 @@
"footer_joystick": "Joystick : {0}",
"history_game_options_title": "Options du jeu",
"history_option_download_folder": "Localiser le fichier",
"history_option_extract_archive": "Extraire l'archive",
"history_option_extract_archive": "Forcer l'extraction",
"history_option_open_file": "Ouvrir le fichier",
"history_option_scraper": "Récupérer métadonnées",
"history_option_remove_from_queue": "Retirer de la file d'attente",
@@ -316,6 +331,7 @@
"history_scraper_not_implemented": "Scraper pas encore implémenté",
"history_confirm_delete": "Supprimer ce jeu du disque ?",
"history_file_not_found": "Fichier introuvable",
"history_extract_archive_confirm": "Forcer l'extraction de l'archive",
"history_extracting": "Extraction en cours...",
"history_extracted": "Extrait",
"history_delete_success": "Jeu supprimé avec succès",
@@ -429,8 +445,8 @@
"web_sort": "Trier par",
"web_sort_name_asc": "A-Z (Nom)",
"web_sort_name_desc": "Z-A (Nom)",
"web_sort_size_asc": "Taille +- (Petit d'abord)",
"web_sort_size_desc": "Taille -+ (Grand d'abord)",
"web_sort_size_asc": "Taille -+ (Petit d'abord)",
"web_sort_size_desc": "Taille +- (Grand d'abord)",
"web_filter_region": "Région",
"web_filter_hide_non_release": "Masquer Démos/Betas/Protos",
"web_filter_regex_mode": "Activer recherche Regex",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Download giochi e immagini...",
"loading_extracting_data": "Estrazione cartella Dati iniziale...",
"loading_load_systems": "Caricamento sistemi...",
"loading_platform_counter": "Piattaforma {0}/{1}",
"loading_platform_name": "Piattaforma: {0}",
"loading_game_entries_progress": "Giochi analizzati: {0}/{1}",
"loading_read_games_resolve_sources": "Lettura dei giochi e risoluzione delle sorgenti...",
"loading_torrent_manifest_analysis": "Analisi del manifest torrent: {0}",
"loading_torrent_files_progress": "File del torrent: {0}/{1}",
"error_extract_data_failed": "Errore nel download o nell'estrazione della cartella Dati iniziale.",
"error_no_internet": "Nessuna connessione Internet. Controlla la rete.",
"error_api_key": "Inserisci la tua API key (solo premium) nel file {0}",
@@ -26,12 +32,17 @@
"game_filter": "Filtro attivo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Ricerca globale: {0}",
"platform_search_title": "Cerca in questa piattaforma",
"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_title_downloading_active": "Download in corso - {0} - {1}",
"history_title_completed_count": "Download completati ({0})",
"history_title_error_count": "Download con errore ({0})",
"history_title_canceled_count": "Download annullati ({0})",
"history_empty": "Nessun download nella cronologia",
"history_column_system": "Sistema",
"history_column_game": "Nome del gioco",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Modalità gratuita] Invio modulo...",
"free_mode_link_found": "[Modalità gratuita] Link trovato: {0}...",
"free_mode_completed": "[Modalità gratuita] Completato: {0}",
"free_mode_guest_slots_unavailable": "1fichier: il download gratuito come ospite non è temporaneamente disponibile (tutti gli slot sono occupati). Riprova più tardi.",
"free_mode_unavailable_in_app": "1fichier: questo download non è disponibile nell'applicazione in questo momento. Riprova più tardi.",
"free_mode_premium_advice": "Per scaricare senza limiti, quando vuoi e alla massima velocità, hai bisogno di un account premium o di un servizio debrid e devi inserire la sua chiave API in RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download annullato dall'utente.",
"download_removed_from_queue": "Rimosso dalla coda di download",
@@ -56,7 +70,8 @@
"confirm_exit": "Uscire dall'applicazione?",
"confirm_exit_with_downloads": "Attenzione: {0} download in corso. Uscire comunque?",
"confirm_clear_history": "Cancellare la cronologia?",
"confirm_redownload_cache": "Aggiornare l'elenco dei giochi?", "gamelist_update_prompt_with_date": "L'elenco dei giochi non è stato aggiornato da più di {0} giorni (ultimo aggiornamento: {1}). Scaricare l'ultima versione?",
"confirm_redownload_cache": "Aggiornare l'elenco dei giochi?", "gamelist_update_prompt_with_date": "Ultimo aggiornamento locale dell'elenco dei giochi: {0}. Scaricare l'ultima versione?",
"gamelist_update_prompt_remote_newer": "È disponibile online un elenco dei giochi più recente (locale: {0}, online: {1}). Scaricare l'ultima versione?",
"gamelist_update_prompt_first_time": "Vuoi scaricare l'ultimo elenco dei giochi?", "popup_redownload_success": "Cache pulita, riavvia l'applicazione",
"popup_no_cache": "Nessuna cache trovata.\nRiavvia l'applicazione per caricare i giochi.",
"popup_countdown": "Questo messaggio si chiuderà tra {0} secondo{1}",
@@ -293,7 +308,7 @@
"footer_joystick": "Joystick: {0}",
"history_game_options_title": "Opzioni gioco",
"history_option_download_folder": "Localizza file",
"history_option_extract_archive": "Estrai archivio",
"history_option_extract_archive": "Forza estrazione archivio",
"history_option_open_file": "Apri file",
"history_option_scraper": "Scraper metadati",
"history_option_remove_from_queue": "Rimuovi dalla coda",
@@ -309,6 +324,7 @@
"history_scraper_not_implemented": "Scraper non ancora implementato",
"history_confirm_delete": "Eliminare questo gioco dal disco?",
"history_file_not_found": "File non trovato",
"history_extract_archive_confirm": "Forza estrazione archivio",
"history_extracting": "Estrazione in corso...",
"history_extracted": "Estratto",
"history_delete_success": "Gioco eliminato con successo",
@@ -422,8 +438,8 @@
"web_sort": "Ordina per",
"web_sort_name_asc": "A-Z (Nome)",
"web_sort_name_desc": "Z-A (Nome)",
"web_sort_size_asc": "Dimensione +- (Piccolo primo)",
"web_sort_size_desc": "Dimensione -+ (Grande primo)",
"web_sort_size_asc": "Dimensione -+ (Piccolo primo)",
"web_sort_size_desc": "Dimensione +- (Grande primo)",
"accessibility_font_size": "Dimensione carattere: {0}",
"confirm_cancel_download": "Annullare il download corrente?",
"controls_help_title": "Guida ai controlli",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Baixando jogos e imagens...",
"loading_extracting_data": "Extraindo pasta inicial Data...",
"loading_load_systems": "Carregando sistemas...",
"loading_platform_counter": "Plataforma {0}/{1}",
"loading_platform_name": "Plataforma: {0}",
"loading_game_entries_progress": "Jogos analisados: {0}/{1}",
"loading_read_games_resolve_sources": "Lendo jogos e resolvendo fontes...",
"loading_torrent_manifest_analysis": "Analisando o manifesto torrent: {0}",
"loading_torrent_files_progress": "Arquivos do torrent: {0}/{1}",
"error_extract_data_failed": "Falha ao baixar ou extrair a pasta inicial Data.",
"error_no_internet": "Sem conexão com a Internet. Verifique sua rede.",
"error_api_key": "Insira sua chave API (somente premium) no arquivo {0}",
@@ -26,12 +32,17 @@
"game_filter": "Filtro ativo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Busca global: {0}",
"platform_search_title": "Pesquisar nesta plataforma",
"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_title_downloading_active": "Baixando - {0} - {1}",
"history_title_completed_count": "Downloads concluídos ({0})",
"history_title_error_count": "Downloads com erro ({0})",
"history_title_canceled_count": "Downloads cancelados ({0})",
"history_empty": "Nenhum download no histórico",
"history_column_system": "Sistema",
"history_column_game": "Nome do jogo",
@@ -47,6 +58,9 @@
"free_mode_submitting": "[Modo gratuito] Enviando formulário...",
"free_mode_link_found": "[Modo gratuito] Link encontrado: {0}...",
"free_mode_completed": "[Modo gratuito] Concluído: {0}",
"free_mode_guest_slots_unavailable": "1fichier: o download gratuito como convidado está temporariamente indisponível (todos os slots estão ocupados). Tente novamente mais tarde.",
"free_mode_unavailable_in_app": "1fichier: este download não está disponível no aplicativo no momento. Tente novamente mais tarde.",
"free_mode_premium_advice": "Para baixar sem limites, quando quiser e em velocidade máxima, você precisa de uma conta premium ou de um serviço debrid e deve inserir a chave API no RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download cancelado pelo usuário.",
"download_removed_from_queue": "Removido da fila de download",
@@ -57,7 +71,8 @@
"confirm_exit_with_downloads": "Atenção: {0} download(s) em andamento. Sair mesmo assim?",
"confirm_clear_history": "Limpar histórico?",
"confirm_redownload_cache": "Atualizar lista de jogos?",
"gamelist_update_prompt_with_date": "A lista de jogos não foi atualizada há mais de {0} dias (última atualização: {1}). Baixar a versão mais recente?",
"gamelist_update_prompt_with_date": "Última atualização local da lista de jogos: {0}. Baixar a versão mais recente?",
"gamelist_update_prompt_remote_newer": "Há uma lista de jogos mais recente disponível online (local: {0}, online: {1}). Baixar a versão mais recente?",
"gamelist_update_prompt_first_time": "Gostaria de baixar a última lista de jogos?",
"popup_redownload_success": "Cache limpo, reinicie a aplicação",
"popup_no_cache": "Nenhum cache encontrado.\nReinicie a aplicação para carregar os jogos.",
@@ -299,7 +314,7 @@
"footer_joystick": "Joystick: {0}",
"history_game_options_title": "Opções do jogo",
"history_option_download_folder": "Localizar arquivo",
"history_option_extract_archive": "Extrair arquivo",
"history_option_extract_archive": "Forcar extracao do arquivo",
"history_option_open_file": "Abrir arquivo",
"history_option_scraper": "Obter metadados",
"history_option_remove_from_queue": "Remover da fila",
@@ -315,6 +330,7 @@
"history_scraper_not_implemented": "Scraper ainda não implementado",
"history_confirm_delete": "Excluir este jogo do disco?",
"history_file_not_found": "Arquivo não encontrado",
"history_extract_archive_confirm": "Forcar extracao do arquivo",
"history_extracting": "Extraindo...",
"history_extracted": "Extraído",
"history_delete_success": "Jogo excluído com sucesso",
@@ -428,8 +444,8 @@
"web_sort": "Ordenar por",
"web_sort_name_asc": "A-Z (Nome)",
"web_sort_name_desc": "Z-A (Nome)",
"web_sort_size_asc": "Tamanho +- (Menor primeiro)",
"web_sort_size_desc": "Tamanho -+ (Maior primeiro)",
"web_sort_size_asc": "Tamanho -+ (Menor primeiro)",
"web_sort_size_desc": "Tamanho +- (Maior primeiro)",
"accessibility_font_size": "Tamanho da fonte: {0}",
"web_filter_region": "Região",
"web_filter_hide_non_release": "Ocultar Demos/Betas/Protos",

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,11 @@
import json
import os
import logging
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import requests
import config
logger = logging.getLogger(__name__)
@@ -79,6 +84,8 @@ def load_rgsx_settings():
"roms_folder": "",
"web_service_at_boot": False,
"last_gamelist_update": None,
"last_gamelist_prompt_remote_update": None,
"global_sort_option": "name_asc",
"platform_custom_paths": {} # Chemins personnalisés par plateforme
}
@@ -120,18 +127,117 @@ def get_last_gamelist_update(settings=None):
return settings.get("last_gamelist_update", None)
def get_last_gamelist_prompt_remote_update(settings=None):
"""Récupère la dernière date distante déjà proposée pour la liste des jeux."""
if settings is None:
settings = load_rgsx_settings()
return settings.get("last_gamelist_prompt_remote_update", None)
def parse_gamelist_update_timestamp(value):
"""Parse legacy dates, ISO timestamps, or HTTP dates into UTC datetimes."""
if value is None:
return None
if isinstance(value, datetime):
return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc)
text = str(value).strip()
if not text:
return None
try:
normalized = text.replace("Z", "+00:00") if text.endswith("Z") else text
dt = datetime.fromisoformat(normalized)
return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
pass
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
except Exception:
pass
try:
dt = parsedate_to_datetime(text)
return dt.astimezone(timezone.utc) if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
return None
def format_gamelist_update_display(value):
"""Return a stable YYYY-MM-DD string for UI display."""
dt = parse_gamelist_update_timestamp(value)
if dt is not None:
return dt.strftime("%Y-%m-%d")
return str(value).strip() if value is not None else ""
def get_remote_gamelist_timestamp(url, timeout=15):
"""Fetch the remote last modification date of the gamelist archive."""
if not url:
return None
headers = {
"User-Agent": "RGSX/1.0",
"Accept": "*/*",
}
try:
with requests.Session() as session:
response = session.head(url, allow_redirects=True, timeout=timeout, headers=headers)
if response.status_code >= 400 or not response.headers.get("Last-Modified"):
response.close()
response = session.get(url, stream=True, allow_redirects=True, timeout=timeout, headers=headers)
header_value = response.headers.get("Last-Modified")
remote_dt = parse_gamelist_update_timestamp(header_value)
response.close()
if remote_dt is not None:
logger.info(f"Date distante gamelist détectée: {remote_dt.isoformat()} ({url})")
else:
logger.warning(f"Impossible de lire l'en-tête Last-Modified de la gamelist pour {url}")
return remote_dt
except Exception as e:
logger.warning(f"Erreur détection date distante gamelist pour {url}: {e}")
return None
def set_last_gamelist_update(date_string=None):
"""Définit la date de dernière mise à jour de la liste des jeux.
Si date_string est None, utilise la date actuelle.
"""
from datetime import datetime
settings = load_rgsx_settings()
if date_string is None:
date_string = datetime.now().strftime("%Y-%m-%d")
settings["last_gamelist_update"] = date_string
parsed_value = parse_gamelist_update_timestamp(date_string)
if parsed_value is None:
parsed_value = datetime.now(timezone.utc)
normalized = parsed_value.isoformat().replace("+00:00", "Z")
settings["last_gamelist_update"] = normalized
save_rgsx_settings(settings)
logger.info(f"Date de dernière mise à jour de la liste des jeux: {date_string}")
return date_string
logger.info(f"Date de dernière mise à jour de la liste des jeux: {normalized}")
return normalized
def set_last_gamelist_prompt_remote_update(date_string=None):
"""Mémorise la dernière date distante déjà proposée pour la liste des jeux.
Si date_string est None ou invalide, efface la valeur mémorisée.
"""
settings = load_rgsx_settings()
parsed_value = parse_gamelist_update_timestamp(date_string)
normalized = (
parsed_value.isoformat().replace("+00:00", "Z")
if parsed_value is not None else None
)
settings["last_gamelist_prompt_remote_update"] = normalized
save_rgsx_settings(settings)
if normalized is not None:
logger.info(f"Dernière date distante déjà proposée pour la liste des jeux: {normalized}")
else:
logger.info("Réinitialisation de la dernière date distante déjà proposée pour la liste des jeux")
return normalized
@@ -499,6 +605,29 @@ def save_game_filters(filters_dict):
return False
def get_global_sort_option(settings=None):
"""Retourne l'option de tri globale sauvegardée."""
allowed = {"name_asc", "name_desc", "size_asc", "size_desc"}
if settings is None:
settings = load_rgsx_settings()
value = str(settings.get("global_sort_option", "name_asc") or "name_asc")
return value if value in allowed else "name_asc"
def set_global_sort_option(option):
"""Sauvegarde l'option de tri globale."""
allowed = {"name_asc", "name_desc", "size_asc", "size_desc"}
normalized = str(option or "name_asc")
if normalized not in allowed:
normalized = "name_asc"
settings = load_rgsx_settings()
settings["global_sort_option"] = normalized
save_rgsx_settings(settings)
logger.info(f"Option de tri globale sauvegardée: {normalized}")
return normalized
def get_platform_custom_path(platform_name):
"""Récupère le chemin personnalisé pour une plateforme."""
try:

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, get_clean_display_name
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url, request_torrent_manifest_refresh, _resolve_platform_image_path
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
@@ -464,8 +464,12 @@ class RGSXHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError):
pass
self._set_headers('application/json', status, etag=etag, last_modified=cached_dt)
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
try:
self._set_headers('application/json', status, etag=etag, last_modified=cached_dt)
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
except (ConnectionAbortedError, BrokenPipeError) as e:
logger.debug(f"Connexion fermée par le client pendant l'envoi JSON: {e}")
return
def _send_html(self, html, status=200, etag=None, last_modified=None):
"""Envoie une réponse HTML"""
@@ -791,6 +795,8 @@ class RGSXHandler(BaseHTTPRequestHandler):
'status': status,
'progress_percent': entry.get('progress', 0),
'speed': entry.get('speed', 0),
'seeds': entry.get('seeds', 0),
'connections': entry.get('connections', 0),
'game_name': entry.get('game_name', ''),
'platform': entry.get('platform', ''),
'timestamp': entry.get('timestamp', '')
@@ -972,13 +978,18 @@ class RGSXHandler(BaseHTTPRequestHandler):
os.remove(zip_path)
if success:
from rgsx_settings import get_remote_gamelist_timestamp, set_last_gamelist_update
remote_update_dt = get_remote_gamelist_timestamp(games_zip_url)
set_last_gamelist_update(remote_update_dt)
logger.info(f"✅ Extraction réussie: {message}")
deleted.append(f'extracted: {message}')
# Maintenant charger les sources
invalidate_all_caches(reason='update-cache refresh')
logger.info("🔄 Chargement des plateformes...")
refreshed_sources = load_sources()
request_torrent_manifest_refresh()
refreshed_sources = load_sources(allow_torrent_manifest_fetch=True)
if refreshed_sources is not None:
with cache_lock:
source_cache.update({
@@ -1167,6 +1178,8 @@ class RGSXHandler(BaseHTTPRequestHandler):
'error': torrent_message
}, status=400)
return
# Suppression du blocage torrent : on laisse passer les URLs rgsx+torrent
# Vérifier l'extension et déterminer si extraction nécessaire
from utils import check_extension_before_download
@@ -1769,86 +1782,28 @@ DO NOT share this file publicly as it may contain sensitive information.
platform_dict = pd
break
# Dossiers où chercher les images
image_folders = [
config.IMAGES_FOLDER, # Dossier utilisateur (saves/ports/rgsx/images)
os.path.join(config.APP_FOLDER, 'assets', 'images') # Dossier app
]
# Extensions possibles
extensions = ['.png', '.jpg', '.jpeg', '.gif']
# Construire la liste des noms de fichiers à chercher (ordre de priorité)
candidates = []
if platform_dict:
# 1. platform_image explicite (priorité max)
platform_image_field = (platform_dict.get('platform_image') or '').strip()
if platform_image_field:
candidates.append(platform_image_field)
# 2. platform_name.png
candidates.append(platform_name)
# 3. folder.png si disponible
folder_name = platform_dict.get('folder')
if folder_name:
candidates.append(folder_name)
else:
# Pas de platform_dict trouvé, juste essayer le nom
candidates.append(platform_name)
# Chercher le fichier image
image_path = None
for candidate in candidates:
# Retirer l'extension si déjà présente
candidate_base = os.path.splitext(candidate)[0]
for folder in image_folders:
if not os.path.exists(folder):
continue
# Essayer avec chaque extension
for ext in extensions:
test_path = os.path.join(folder, candidate_base + ext)
if os.path.exists(test_path):
image_path = test_path
break
if image_path:
break
if image_path:
break
# Si pas trouvé, chercher default.png
if not image_path:
for folder in image_folders:
default_path = os.path.join(folder, 'default.png')
if os.path.exists(default_path):
image_path = default_path
break
# Envoyer l'image
payload_platform = platform_dict or {'platform_name': platform_name}
image_path = _resolve_platform_image_path(payload_platform)
if image_path and os.path.exists(image_path):
# Déterminer le type MIME
ext = os.path.splitext(image_path)[1].lower()
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif'
'.gif': 'image/gif',
'.webp': 'image/webp',
}
content_type = mime_types.get(ext, 'image/png')
# Lire et envoyer l'image avec headers de cache
with open(image_path, 'rb') as f:
image_data = f.read()
# Ajouter les headers de cache (1 heure)
self.send_response(200)
self.send_header('Content-type', content_type)
self.send_header('Cache-Control', 'public, max-age=3600') # 1 heure
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(image_data)
@@ -1857,7 +1812,9 @@ DO NOT share this file publicly as it may contain sensitive information.
logger.warning(f"Aucune image trouvée pour {platform_name}, envoi PNG transparent")
self.send_response(404)
self.send_header('Content-type', 'image/png')
self.send_header('Cache-Control', 'public, max-age=3600')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# PNG transparent 1x1 pixel
@@ -1868,7 +1825,9 @@ DO NOT share this file publicly as it may contain sensitive information.
logger.error(f"Erreur lors du chargement de l'image {platform_name}: {e}", exc_info=True)
self.send_response(500)
self.send_header('Content-type', 'image/png')
self.send_header('Cache-Control', 'public, max-age=60') # Cache court pour les erreurs
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# PNG transparent en cas d'erreur

View File

@@ -2,15 +2,18 @@
Module de scraping pour récupérer les métadonnées des jeux depuis TheGamesDB.net API v1
"""
import logging
import os
import requests
import re
from io import BytesIO
import pygame
import config
logger = logging.getLogger(__name__)
# Clé API publique pour TheGamesDB
API_KEY = "bdbb4a1ce5f1c12c1bcc119aeb4d4923d3887e22ad336d576e9b9e5da5ecaa3c"
SOURCE_SUFFIXES = {"Archive", "LolRoms", "Torrent", "1Fichier"}
THEGAMESDB_API_KEY_PATH = getattr(config, "THEGAMESDB_API_KEY_PATH", os.path.join(config.APP_FOLDER, "assets", "TheGamesDBAPI.txt"))
API_BASE_URL = "https://api.thegamesdb.net/v1"
# Mapping des noms de plateformes vers leurs IDs sur TheGamesDB API
@@ -141,6 +144,55 @@ PLATFORM_MAPPING = {
"Amiga": 4911
}
SCRAPER_PLATFORM_ALIASES = {
"Family Computer Disk System (Famicom)": "Family Computer Disk System",
"Nintendo Famicom Disk System": "Family Computer Disk System",
}
def normalize_scraper_platform_name(platform_name):
"""Map display platform names back to scraper-compatible base names."""
text = str(platform_name or "").strip()
if not text:
return ""
while True:
match = re.search(r'\(([^()]+)\)\s*$', text)
if not match:
break
suffix = match.group(1).strip()
if suffix in SOURCE_SUFFIXES or suffix == "RomHacks":
text = text[:match.start()].rstrip()
continue
break
return SCRAPER_PLATFORM_ALIASES.get(text, text)
def get_thegamesdb_api_key():
"""Load TheGamesDB API key from env or shared local file."""
env_key = os.environ.get("RGSX_THEGAMESDB_API_KEY", "").strip()
if env_key:
return env_key, "env"
try:
if not os.path.exists(THEGAMESDB_API_KEY_PATH):
os.makedirs(os.path.dirname(THEGAMESDB_API_KEY_PATH), exist_ok=True)
with open(THEGAMESDB_API_KEY_PATH, 'w', encoding='utf-8') as handle:
handle.write("")
except Exception as exc:
logger.warning(f"Impossible de préparer le fichier de clé TheGamesDB: {exc}")
try:
with open(THEGAMESDB_API_KEY_PATH, 'r', encoding='utf-8') as handle:
file_key = handle.read().strip()
if file_key:
return file_key, THEGAMESDB_API_KEY_PATH
except Exception as exc:
logger.warning(f"Impossible de lire la clé TheGamesDB: {exc}")
return "", "missing"
def clean_game_name(game_name):
"""
@@ -180,12 +232,24 @@ def get_game_metadata(game_name, platform_name):
Keys: image_url, game_page_url, description, genre, release_date, error
"""
clean_name = clean_game_name(game_name)
logger.info(f"Recherche métadonnées pour: '{clean_name}' sur plateforme '{platform_name}'")
normalized_platform_name = normalize_scraper_platform_name(platform_name)
api_key, api_key_source = get_thegamesdb_api_key()
if not api_key:
logger.warning("Clé API TheGamesDB absente")
return {"error": "Clé API TheGamesDB manquante"}
logger.info(
f"Recherche métadonnées pour: '{clean_name}' sur plateforme '{platform_name}'"
f" -> '{normalized_platform_name}'"
)
# Obtenir l'ID de la plateforme
platform_id = PLATFORM_MAPPING.get(platform_name)
platform_id = PLATFORM_MAPPING.get(normalized_platform_name)
if not platform_id:
logger.warning(f"Plateforme '{platform_name}' non trouvée dans le mapping")
logger.warning(
f"Plateforme '{platform_name}' normalisée en '{normalized_platform_name}' non trouvée dans le mapping"
)
return {"error": f"Plateforme '{platform_name}' non supportée"}
try:
@@ -193,14 +257,16 @@ def get_game_metadata(game_name, platform_name):
# Documentation: https://api.thegamesdb.net/#/Games/GamesbyName
url = f"{API_BASE_URL}/Games/ByGameName"
params = {
"apikey": API_KEY,
"apikey": api_key,
"name": clean_name,
"filter[platform]": platform_id,
"fields": "players,publishers,genres,overview,last_updated,rating,platform,coop,youtube,os,processor,ram,hdd,video,sound,alternates",
"include": "boxart"
}
logger.debug(f"Requête API: {url} avec name='{clean_name}', platform={platform_id}")
logger.debug(
f"Requête API: {url} avec name='{clean_name}', platform={platform_id}, key_source={api_key_source}"
)
response = requests.get(url, params=params, timeout=15)
if response.status_code != 200:
@@ -247,7 +313,7 @@ def get_game_metadata(game_name, platform_name):
try:
images_url = f"{API_BASE_URL}/Games/Images"
images_params = {
"apikey": API_KEY,
"apikey": api_key,
"games_id": game_id,
"filter[type]": "boxart"
}

View File

@@ -2,6 +2,7 @@
let currentPlatform = null;
let currentGameSort = 'name_asc'; // Type de tri actuel: 'name_asc', 'name_desc', 'size_asc', 'size_desc'
let currentGames = []; // Stocke les jeux actuels pour le tri
const loggedUnparsedSizeTexts = new Set();
let lastProgressUpdate = Date.now();
let autoRefreshTimeout = null;
let progressInterval = null;
@@ -209,6 +210,74 @@
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
function parseSizeToBytes(sizeText) {
if (!sizeText) return 0;
const rawText = String(sizeText).trim();
let normalized = rawText.replace(/octets?/gi, 'B');
if (normalized.includes(',') && normalized.includes('.')) {
normalized = normalized.replace(/,/g, '');
} else if (normalized.includes(',')) {
normalized = normalized.replace(',', '.');
}
const match = normalized.match(/^([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)/);
if (!match) {
if (!loggedUnparsedSizeTexts.has(rawText)) {
loggedUnparsedSizeTexts.add(rawText);
console.warn('[RGSX][sort] Taille non interpretable:', rawText);
}
return 0;
}
const value = parseFloat(match[1]);
if (Number.isNaN(value)) return 0;
const unit = match[2].toLowerCase();
const multipliers = {
b: 1,
byte: 1,
bytes: 1,
o: 1,
k: 1024,
ko: 1024,
kb: 1024,
kib: 1024,
kio: 1024,
m: 1024 ** 2,
mo: 1024 ** 2,
mb: 1024 ** 2,
mib: 1024 ** 2,
mio: 1024 ** 2,
g: 1024 ** 3,
go: 1024 ** 3,
gb: 1024 ** 3,
gib: 1024 ** 3,
gio: 1024 ** 3,
t: 1024 ** 4,
to: 1024 ** 4,
tb: 1024 ** 4,
tib: 1024 ** 4,
tio: 1024 ** 4,
p: 1024 ** 5,
po: 1024 ** 5,
pb: 1024 ** 5,
pib: 1024 ** 5,
pio: 1024 ** 5,
};
if (!multipliers[unit]) {
if (!loggedUnparsedSizeTexts.has(rawText)) {
loggedUnparsedSizeTexts.add(rawText);
console.warn('[RGSX][sort] Unite de taille non supportee:', rawText, '->', unit);
}
return 0;
}
return Math.round(value * multipliers[unit]);
}
// Appliquer les traductions à tous les éléments marqués
function applyTranslations() {
@@ -1071,6 +1140,38 @@
currentGameSort = sortType;
const items = Array.from(document.querySelectorAll('.game-item'));
const gamesList = document.querySelector('.games-list');
if (!gamesList) {
console.warn('[RGSX][sort] .games-list introuvable pour le tri', sortType);
return;
}
const shouldLogSizeSort = sortType === 'size_asc' || sortType === 'size_desc';
const getSizeInBytes = (sizeElem) => {
if (!sizeElem) return 0;
return parseSizeToBytes(sizeElem.textContent);
};
if (shouldLogSizeSort) {
const previewBefore = items.slice(0, 5).map(item => {
const sizeText = item.querySelector('.game-size')?.textContent?.trim() || '';
return {
name: item.querySelector('.game-name')?.textContent?.trim() || '',
sizeText,
sizeBytes: getSizeInBytes(item.querySelector('.game-size')),
};
});
const zeroSizedCount = items.filter(item => {
const sizeElem = item.querySelector('.game-size');
return sizeElem && getSizeInBytes(sizeElem) === 0;
}).length;
console.debug('[RGSX][sort] Debut tri taille', {
sortType,
totalItems: items.length,
zeroSizedCount,
previewBefore,
});
}
// Trier les éléments
items.sort((a, b) => {
@@ -1079,41 +1180,15 @@
const sizeElemA = a.querySelector('.game-size');
const sizeElemB = b.querySelector('.game-size');
// Extraire la taille en Mo (normalisée)
const getSizeInMo = (sizeElem) => {
if (!sizeElem) return 0;
const text = sizeElem.textContent;
// Support des formats: "100 Mo", "2.5 Go" (français) et "100 MB", "2.5 GB" (anglais)
// Plus Ko/KB, o/B, To/TB
const match = text.match(/([0-9.]+)\s*(o|B|Ko|KB|Mo|MB|Go|GB|To|TB)/i);
if (!match) return 0;
let size = parseFloat(match[1]);
const unit = match[2].toUpperCase();
// Convertir tout en Mo
if (unit === 'O' || unit === 'B') {
size /= (1024 * 1024); // octets/bytes vers Mo
} else if (unit === 'KO' || unit === 'KB') {
size /= 1024; // Ko vers Mo
} else if (unit === 'MO' || unit === 'MB') {
// Déjà en Mo
} else if (unit === 'GO' || unit === 'GB') {
size *= 1024; // Go vers Mo
} else if (unit === 'TO' || unit === 'TB') {
size *= 1024 * 1024; // To vers Mo
}
return size;
};
switch(sortType) {
case 'name_asc':
return nameA.localeCompare(nameB);
case 'name_desc':
return nameB.localeCompare(nameA);
case 'size_asc':
return getSizeInMo(sizeElemA) - getSizeInMo(sizeElemB);
return getSizeInBytes(sizeElemA) - getSizeInBytes(sizeElemB);
case 'size_desc':
return getSizeInMo(sizeElemB) - getSizeInMo(sizeElemA);
return getSizeInBytes(sizeElemB) - getSizeInBytes(sizeElemA);
default:
return 0;
}
@@ -1124,6 +1199,18 @@
items.forEach(item => {
gamesList.appendChild(item);
});
if (shouldLogSizeSort) {
const previewAfter = items.slice(0, 5).map(item => ({
name: item.querySelector('.game-name')?.textContent?.trim() || '',
sizeText: item.querySelector('.game-size')?.textContent?.trim() || '',
sizeBytes: getSizeInBytes(item.querySelector('.game-size')),
}));
console.debug('[RGSX][sort] Fin tri taille', {
sortType,
previewAfter,
});
}
// Mettre à jour les boutons de tri
document.querySelectorAll('.sort-btn').forEach(btn => {
@@ -1150,6 +1237,7 @@
// Construire le HTML avec les traductions
let searchPlaceholder = t('web_search_platform');
const platformImageCacheBuster = Date.now();
let html = `
<div class="search-box">
<input type="text" id="platform-search" placeholder="🔍 ${searchPlaceholder}"
@@ -1164,9 +1252,9 @@
let gameCountText = t('web_game_count', '📦', p.games_count || 0);
html += `
<div class="platform-card" onclick='loadGames("${p.platform_name.replace(/"/g, "&quot;").replace(/'/g, "&#39;")}")'>
<img src="/api/image/${encodeURIComponent(p.platform_name)}"
<img src="/api/image/${encodeURIComponent(p.platform_name)}?v=${platformImageCacheBuster}"
alt="${p.platform_name}"
onerror="this.src='/api/image/default'">
onerror="this.src='/api/image/default?v=${platformImageCacheBuster}'">
<h3>${p.platform_name}</h3>
<div class="count">${gameCountText}</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
{
"version": "2.6.1.5"
"version": "2.6.3.6"
}

View File

@@ -357,8 +357,12 @@ if "!EXITCODE!"=="0" (
echo.
echo %ESC%%GREEN%RGSX closed successfully.%ESC%%RESET%
echo.
echo [%DATE% %TIME%] Application closed successfully >> "%LOG_FILE%"
) else (
echo [%DATE% %TIME%] Application closed successfully >> "%LOG_FILE%") else if "!EXITCODE!"=="1" (
echo.
echo %ESC%%GREEN%RGSX closed normally.%ESC%%RESET%
echo.
>> "%LOG_FILE%" echo [%DATE% %TIME%] Application closed normally >> "%LOG_FILE%"
goto :end) else (
echo.
echo %ESC%%RED%RGSX exited with error code !EXITCODE!%ESC%%RESET%
echo.
@@ -370,7 +374,7 @@ if "!EXITCODE!"=="0" (
echo [%DATE% %TIME%] ========================================== >> "%LOG_FILE%"
echo [%DATE% %TIME%] Session ended normally >> "%LOG_FILE%"
echo [%DATE% %TIME%] ========================================== >> "%LOG_FILE%"
timeout /t 2 >nul
ping -n 1 -w 5000 127.255.255.255 >nul
exit /b 0
:error