Compare commits

...

8 Commits

Author SHA1 Message Date
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
21 changed files with 4313 additions and 911 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,7 +89,7 @@ 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 config import OTA_data_ZIP
@@ -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 = ""
@@ -439,7 +488,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 +497,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 +811,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 +1251,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 +1494,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 +1518,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 +1548,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 +1566,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 +1609,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 @@
<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,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.1"
app_version = "2.6.3.2"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -195,6 +195,10 @@ 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")
@@ -441,11 +445,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é

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, parse_torrent_download_url
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,6 +47,13 @@ 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:
@@ -69,6 +84,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
@@ -102,6 +224,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é
@@ -446,8 +569,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 = [
@@ -466,11 +589,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({
@@ -482,21 +627,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
@@ -508,30 +767,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
@@ -832,7 +1119,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
@@ -1047,7 +1334,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
@@ -1075,7 +1362,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
@@ -1152,12 +1439,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"
@@ -1499,7 +1781,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
@@ -1526,11 +1808,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
@@ -1555,7 +1855,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")
@@ -1570,6 +1870,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)
@@ -1766,6 +2091,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))
@@ -2032,6 +2360,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"
@@ -2877,9 +3207,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
@@ -2890,18 +3227,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
@@ -3222,9 +3566,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
@@ -3306,20 +3653,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
@@ -3327,27 +3680,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":
@@ -3479,38 +3879,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é")
@@ -3813,7 +4235,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
@@ -2497,14 +2615,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,10 +2632,14 @@ 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)
# 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)
@@ -2949,6 +3073,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 +3934,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 +4709,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 +5015,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 +5210,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 +5262,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 +5282,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 +5544,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 +5553,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 +5850,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 +5860,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 +5913,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,41 @@ import json
import os
import logging
import re
import threading
import time
import config
from datetime import datetime
logger = logging.getLogger(__name__)
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 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():
@@ -77,24 +107,9 @@ def save_history(history):
history_path = getattr(config, 'HISTORY_PATH')
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)
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
def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None):
"""Ajoute une entrée à l'historique."""
@@ -314,23 +329,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +67,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 +309,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 +325,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 +439,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +67,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 +201,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 +308,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 +327,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 +441,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +66,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 +309,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 +325,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 +439,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +67,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 +198,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 +308,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 +327,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 +441,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +66,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 +304,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 +320,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 +434,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,6 +32,7 @@
"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",
@@ -47,6 +54,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 +67,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 +310,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 +326,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 +440,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, parse_torrent_download_url
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"""
@@ -972,13 +976,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({
@@ -1777,86 +1786,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)
@@ -1865,7 +1816,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
@@ -1876,7 +1829,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,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.1"
"version": "2.6.3.2"
}