Compare commits

...

26 Commits

Author SHA1 Message Date
skymike03
d7449073d2 ## v2.6.3.4 (2026.04.10)
- Fix ps3 handle with uncompressed folder (rename folder to .ps3)
- show and save real name of vimm's file when downloading
2026-04-10 23:18:20 +02:00
skymike03
e0d34304d5 Replace timeout with ping for delay before exit in RGSX Retrobat 2026-04-10 18:02:39 +02:00
skymike03
65584e411a Improve logging for application closure in RGSX Retrobat 2026-04-10 17:43:46 +02:00
skymike03
142fffcfb1 ## v2.6.3.3 (2026.04.10)
- add vimms support
- fix scraper api
2026-04-10 02:06:24 +02:00
skymike03
ffd186f69b remove file 2026-04-08 18:25:52 +02:00
skymike03
fd3695f78d - Fix scraper API bug 2026-04-07 21:13:01 +02:00
skymike03
579d0a1c28 ## v2.6.3.2 (2026.04.07)
- Add little badge on platform icon to identify the source of games (add icons files)
- Fix sorting size bug with commas
- Fix history locate/extract/delete game options not showing after some downloads
2026-04-07 18:52:28 +02:00
skymike03
60ca7bc375 - Fix history locate/extract/delete game options not showing after some downloads 2026-04-07 00:03:05 +02:00
skymike03
c6a76f56d5 ## v2.6.3.1 (2026.04.06)
- Correct games sorting by size and filtering bugs
- Add persistant sorting option for all systems
- Correct a crash when saving some filter option
- Fix file not deleted after cancel download bug
- Fix slow download start on lolroms source
- Correct PS3 converting bug with ISO
2026-04-06 23:09:10 +02:00
skymike03
f835d00886 ## v2.6.3.0 (2026.04.06)
- Torrents are STILL DISABLED FOR NOW  , download is ready but sources are not fast (other sources are available for almost all platforms)
- Update RGSX support zip gen text to show correct button to close the message
- Update resolution detection on windows with dpi scale suppport to avoid using a bad screen resolution
- fix history write errors on the LOG
- fix torrents refreshing every rgsx start and takes few minutes. now it will do after a gamelist refresh only.
- ask to update game list automatically on boot only if it detect a new update on server
- add new filter global menu and sort by name or size
- loads games count and torrent cache local only to speed up app start
- add cache for fastest search (included in game data zip)
- speed up the gamelist data zip download
- add ability to extract zip/rar/7z for a downloaded game if auto extract disable for example or archive was a supported extension.
2026-04-06 20:43:11 +02:00
skymike03
e84c7ae167 ## v2.6.2.0
- add verbose info for platform loading at app start
- speed up app start by loading torrent files only when open a plateform
- add a torrent cache to speedup access next time opening the app (deleted and recreated after each rgsx games update)
- add support to new sources (ie lolroms)  to have a new alternative. Torrent is not implanted for the moment
- correct 7z archive extracting error on batocera (arm)
- fix status not showing extract message for 7z and rar
- correct missing download size/progress on some links
2026-04-04 01:34:22 +02:00
skymike03
ce39722351 ## v2.6.1.7 (2026.01.02)
- correct version heading check to changelog extraction logic
2026-04-02 22:56:16 +02:00
skymike03
a7dad84108 ## v2.6.1.6.1 (2026.01.02)
- fix update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 22:47:29 +02:00
skymike03
c9f48d20dd ## v2.6.1.6 (2026.01.02)
- update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 21:47:38 +02:00
skymike03
cd7795f70e v2.6.1.5.1 (2026.01.02)
- fix some torrent  handling for minerva FUTURE using. Games are available, but download torrent through RGSX is not available for now. So don't ask about dl ps2/ps3/gc/ds games , and if you try to download ,you will have a "maintenance" message
2026-04-02 18:44:55 +02:00
skymike03
6813a0bc3d v2.6.1.5 (2026.04.01)
- test implant torrent handling for minerva support (disabled for now)
2026-04-01 18:34:48 +02:00
skymike03
21b39c66b9 v2.6.1.4 (2026.03.30)
- Add browser-like headers for file downloads with debrids and enhance AllDebrid link handling
2026-03-30 21:12:59 +02:00
skymike03
42b2204aeb Reverted back to original version after test 2026-03-22 12:25:45 +01:00
skymike03
67a38c45aa ## v2.6.3.1.0 TEST (2026.03.22)
- Test discord auto release changelog
- Test
2026-03-22 12:20:46 +01:00
skymike03
893b73ecc5 Refactor Discord changelog notification step in release workflow 2026-03-22 12:09:32 +01:00
skymike03
5e1a684275 Enhance Discord notifications with changelog and bot details 2026-03-22 12:06:06 +01:00
skymike03
9226a818f3 v2.6.3.1 (test update) 2026-03-22 11:56:58 +01:00
skymike03
2fd1bcaf01 test discord 2026-03-22 11:55:58 +01:00
skymike03
875bf8fa23 v2.6.1.3 (2026.03.21)
- add update changelog on start before applying new update
2026-03-21 18:26:39 +01:00
skymike03
f9cbf0196e v2.6.1.2 (2026.03.21)
- added paging navigation on folder browser and full page list
2026-03-21 17:36:06 +01:00
skymike03
eb86d69895 v2.6.1.1 (2026.21.03)
- Improved History/Downloads table readability by giving more space to game titles and using middle truncation for long names
- Cleaned displayed game names to remove platform/path prefixes from titles
- Improved file matching for downloaded and extracted games, including support for filename variants and tag differences
- Updated Locate file to show all matching files instead of only one path
- Added a Move action from the locate screen, using the existing folder browser to move all listed files to a selected destination
- Added collision-safe file moves and persisted moved paths in history
- Added localized labels/messages for the new move flow
- Fixed a startup crash caused by a translation function name conflict
- Fixed navigation after move so OK and Back work correctly from the locate screen
2026-03-21 17:29:39 +01:00
27 changed files with 6185 additions and 1212 deletions

View File

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

View File

@@ -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,20 +76,20 @@ 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,
draw_toast, show_toast, THEME_COLORS, sync_display_metrics
)
from language import _
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, cancel_all_downloads, download_queue_worker
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, apply_pending_update, cancel_all_downloads, download_queue_worker
from controls import handle_controls, validate_menu_state, process_key_repeats, get_emergency_controls
from controls_mapper import map_controls, draw_controls_mapping, get_actions
from controls import load_controls_config
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,10 @@ _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 = ""
except Exception:
pass
@@ -436,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:
@@ -444,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
@@ -457,6 +511,7 @@ async def main():
running = True
loading_step = "none"
ota_update_task = None
sources = []
config.last_state_change_time = 0
config.debounce_delay = 50
@@ -489,6 +544,9 @@ async def main():
if config.menu_state == "download_progress" and current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
if config.menu_state == "loading" and current_time - last_redraw_time >= 100:
config.needs_redraw = True
last_redraw_time = current_time
# Forcer redraw toutes les 100 ms dans history avec téléchargement actif
if config.menu_state == "history" and any(entry["status"] in ["Downloading", "Téléchargement"] for entry in config.history):
if current_time - last_redraw_time >= 100:
@@ -753,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:
@@ -1043,7 +1102,8 @@ async def main():
if success:
toast_msg = f"[OK] {game_name}\n{_('download_completed') if _ else 'Download completed'}"
else:
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
del config.download_tasks[task_id]
@@ -1065,7 +1125,8 @@ async def main():
config.download_progress.clear()
config.pending_download = None
# Afficher un toast au lieu de changer de page
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
del config.download_tasks[task_id]
@@ -1104,7 +1165,8 @@ async def main():
if success:
toast_msg = f"[OK] {game_name}\n{_('download_completed') if _ else 'Download completed'}"
else:
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
logger.debug(f"[DOWNLOAD_TASK] Toast displayed after completion, task_id={task_id}")
@@ -1189,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":
@@ -1357,6 +1421,10 @@ async def main():
config.error_message = message or _("error_check_updates_failed")
config.needs_redraw = True
logger.debug(f"Erreur OTA : {message}")
elif getattr(config, "pending_update_version", ""):
loading_step = "await_ota_confirmation"
config.needs_redraw = True
continue
else:
loading_step = "check_data"
config.current_loading_system = _("loading_downloading_games_images")
@@ -1364,6 +1432,38 @@ async def main():
config.needs_redraw = True
logger.debug(f"Étape chargement : {loading_step}, progress={config.loading_progress}")
continue # Passer immédiatement à check_data
elif loading_step == "await_ota_confirmation":
if not getattr(config, "startup_update_confirmed", False):
await asyncio.sleep(0.01)
continue
latest_version = getattr(config, "pending_update_version", "")
config.startup_update_confirmed = False
ota_update_task = asyncio.create_task(apply_pending_update(latest_version))
loading_step = "apply_ota_update"
config.needs_redraw = True
continue
elif loading_step == "apply_ota_update":
if ota_update_task is None:
loading_step = "check_data"
continue
if not ota_update_task.done():
await asyncio.sleep(0.01)
continue
success, message = await ota_update_task
ota_update_task = None
if not success:
config.menu_state = "error"
config.error_message = message or _("error_check_updates_failed")
config.needs_redraw = True
else:
config.pending_update_version = ""
config.text_file_mode = ""
config.text_file_content = ""
config.loading_detail_lines = []
config.needs_redraw = True
continue
elif loading_step == "check_data":
is_data_empty = not os.path.exists(config.GAMES_FOLDER) or not any(os.scandir(config.GAMES_FOLDER))
if is_data_empty:
@@ -1394,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
@@ -1414,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,
@@ -1433,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")
@@ -1442,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
@@ -1480,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 @@
ea388d0d46cd18c3606b1abdba68790b6d7f66ee19ce3bb4f99a26fadafcc77a

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1006 B

View File

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

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

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

View File

@@ -9,8 +9,8 @@ from dataclasses import dataclass
@dataclass(slots=True)
class Game:
name: str
url: str
size: str
url: Optional[str]
size: Optional[str]
display_name: str # name withou file extension or platform prefix
regions: Optional[list[str]] = None
is_non_release: Optional[bool] = None
@@ -27,7 +27,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.1.0"
app_version = "2.6.3.4"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -195,12 +195,17 @@ PRECONF_CONTROLS_PATH = os.path.join(APP_FOLDER, "assets", "controls")
CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json")
HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json")
DOWNLOADED_GAMES_PATH = os.path.join(SAVE_FOLDER, "downloaded_games.json")
TORRENT_MANIFEST_CACHE_PATH = os.path.join(SAVE_FOLDER, "torrent_manifest_cache.json")
PLATFORM_GAME_COUNT_CACHE_PATH = os.path.join(SAVE_FOLDER, "platform_games_count_cache.json")
GLOBAL_SEARCH_INDEX_CACHE_PATH = os.path.join(SAVE_FOLDER, "global_search_index.json")
PENDING_TORRENT_REFRESH_MARKER_PATH = os.path.join(SAVE_FOLDER, "pending_torrent_refresh.marker")
RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json")
API_KEY_1FICHIER_PATH = os.path.join(SAVE_FOLDER, "1FichierAPI.txt")
API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt")
API_KEY_DEBRIDLINK_PATH = os.path.join(SAVE_FOLDER, "DebridLinkAPI.txt")
API_KEY_REALDEBRID_PATH = os.path.join(SAVE_FOLDER, "RealDebridAPI.txt")
ARCHIVE_ORG_COOKIE_PATH = os.path.join(APP_FOLDER, "assets", "ArchiveOrgCookie.txt")
THEGAMESDB_API_KEY_PATH = os.path.join(APP_FOLDER, "assets", "TheGamesDBAPI.txt")
@@ -225,6 +230,8 @@ PS3DEC_EXE = os.path.join(APP_FOLDER,"assets", "progs", "ps3dec_win.exe")
PS3DEC_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "ps3dec_linux")
SEVEN_Z_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "7zz")
SEVEN_Z_EXE = os.path.join(APP_FOLDER,"assets", "progs", "7z.exe")
ARIA2C_EXE = os.path.join(APP_FOLDER,"assets", "progs", "aria2c.exe")
ARIA2C_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "aria2c")
# Détection du système d'exploitation (une seule fois au démarrage)
OPERATING_SYSTEM = platform.system()
@@ -439,11 +446,20 @@ global_search_query = "" # Texte saisi pour la recherche globale
global_search_selected = 0 # Index du resultat global selectionne
global_search_scroll_offset = 0 # Offset de defilement des resultats globaux
global_search_editing = False # True si le clavier virtuel est actif pour la recherche globale
global_search_allow_empty = False # True pour afficher tous les resultats sans requete (filtre/tri globaux)
global_search_title_override = "" # Titre alternatif pour la vue globale
global_search_return_state = "platform" # Etat de retour depuis la vue globale
global_sort_option = "name_asc" # Tri global courant
# Variables pour le filtrage avancé
selected_filter_choice = 0 # Index dans le menu de choix de filtrage (recherche / avancé)
selected_filter_option = 0 # Index dans le menu de filtrage avancé
game_filter_obj = None # Objet GameFilters pour le filtrage avancé
filter_menu_context = "platform" # Contexte d'ouverture du menu filtre unifie
filter_menu_entries = [] # Entrees du menu filtre unifie
filter_menu_return_state = "platform" # Etat de retour depuis le menu filtre unifie
filter_target_scope = "local" # Portee du filtrage avance (local/global)
global_sort_selected = 0 # Index selectionne dans le menu de tri global
# Gestion des états du menu
needs_redraw = False # Indicateur si l'écran doit être redessiné
@@ -494,6 +510,10 @@ hide_premium_systems = False # Indicateur pour masquer les systèmes premium
# Variables diverses
update_checked = False
pending_update_version = ""
startup_update_confirmed = False
text_file_mode = ""
loading_detail_lines = []
extension_confirm_selection = 0 # Index de sélection pour confirmation d'extension
controls_config = {} # Configuration des contrôles personnalisés
selected_key = (0, 0) # Position du curseur dans le clavier virtuel

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,10 @@ from datetime import datetime
import config
from utils import (truncate_text_middle, wrap_text, load_system_image, truncate_text_end,
check_web_service_status, check_custom_dns_status, load_api_keys,
_get_dest_folder_name, find_file_with_or_without_extension,
get_connection_status_targets, get_connection_status_snapshot)
_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, 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
@@ -27,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()
@@ -469,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]
@@ -794,7 +887,11 @@ def draw_loading_screen(screen):
screen.blit(text_surface, text_rect)
loading_y = rect_y + rect_height + int(config.screen_height * 0.0926)
text = config.small_font.render(truncate_text_middle(f"{config.current_loading_system}", config.small_font, config.screen_width - 2 * margin_horizontal), True, THEME_COLORS["text"])
text = config.small_font.render(
truncate_text_middle(f"{config.current_loading_system}", config.small_font, config.screen_width - 2 * margin_horizontal, is_filename=False),
True,
THEME_COLORS["text"]
)
text_rect = text.get_rect(center=(config.screen_width // 2, loading_y))
screen.blit(text, text_rect)
@@ -804,9 +901,24 @@ def draw_loading_screen(screen):
bar_width = int(config.screen_width * 0.2083)
bar_height = int(config.screen_height * 0.037)
bar_y = loading_y + int(config.screen_height * 0.0926)
progress_width = (bar_width * config.loading_progress) / 100
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (config.screen_width // 2 - bar_width // 2, loading_y + int(config.screen_height * 0.0926), bar_width, bar_height), border_radius=8)
pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (config.screen_width // 2 - bar_width // 2, loading_y + int(config.screen_height * 0.0926), progress_width, bar_height), border_radius=8)
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (config.screen_width // 2 - bar_width // 2, bar_y, bar_width, bar_height), border_radius=8)
pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (config.screen_width // 2 - bar_width // 2, bar_y, progress_width, bar_height), border_radius=8)
detail_lines = getattr(config, 'loading_detail_lines', []) or []
detail_y = bar_y + bar_height + 14
max_detail_width = config.screen_width - 2 * margin_horizontal
rendered_lines = []
for detail_line in detail_lines:
if not detail_line:
continue
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"])
detail_rect = detail_surface.get_rect(center=(config.screen_width // 2, detail_y + index * (config.small_font.get_height() + 4)))
screen.blit(detail_surface, detail_rect)
# Écran d'erreur
def draw_error_screen(screen):
@@ -1205,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)
@@ -1285,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}"
@@ -1684,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
@@ -1761,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)
@@ -2100,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)})"
@@ -2147,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
@@ -2358,14 +2496,14 @@ def draw_history_list(screen):
pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12)
screen.blit(title_surface, title_rect)
# Define column widths as percentages of available space (give more space to status/error messages)
# Prioritize the game title by shrinking size/status columns.
column_width_percentages = {
"platform": 0.13,
"game_name": 0.25,
"ext": 0.08,
"folder": 0.12,
"size": 0.08,
"status": 0.34
"game_name": 0.40,
"ext": 0.07,
"folder": 0.16,
"size": 0.06,
"status": 0.18
}
available_width = int(0.95 * config.screen_width - 60) # Total available width for columns
col_platform_width = int(available_width * column_width_percentages["platform"])
@@ -2471,19 +2609,22 @@ def draw_history_list(screen):
for idx, i in enumerate(range(config.history_scroll_offset, min(config.history_scroll_offset + items_per_page, len(history)))):
entry = history[i]
platform = entry.get("platform", "Inconnu")
game_name = entry.get("game_name", "Inconnu")
ext_text = get_display_extension(game_name)
raw_game_name = entry.get("game_name", "Inconnu")
game_name = entry.get("display_name") or get_clean_display_name(raw_game_name, platform)
ext_text = get_display_extension(raw_game_name)
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 "")
@@ -2491,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)
@@ -2547,7 +2692,7 @@ def draw_history_list(screen):
status_color = THEME_COLORS.get("text", (255, 255, 255))
platform_text = truncate_text_end(platform, config.small_font, col_platform_width - 10)
game_text = truncate_text_end(str(game_name).rsplit('.', 1)[0] if '.' in str(game_name) else str(game_name), config.small_font, col_game_width - 10)
game_text = truncate_text_middle(str(game_name), config.small_font, col_game_width - 10, is_filename=False)
ext_text = truncate_text_end(ext_text, config.small_font, col_ext_width - 10)
folder_text = truncate_text_end(folder_text, config.small_font, col_folder_width - 10)
size_text = truncate_text_end(size_text, config.small_font, col_size_width - 10)
@@ -2883,6 +3028,11 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
("history", _("controls_action_close_history")),
("cancel", _("controls_cancel_back")),
],
"history_show_folder": [
("confirm", _("button_OK")),
("clear_history", _("history_move_action")),
("cancel", _("controls_cancel_back")),
],
"scraper": [
("confirm", _("controls_confirm_select")),
("cancel", _("controls_cancel_back")),
@@ -2899,6 +3049,7 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
],
"folder_browser": [
("confirm", _("folder_browser_enter")),
(("page_up", "page_down"), _("controls_pages")),
("history", _("folder_browser_select")),
("clear_history", _("folder_new_folder")),
("cancel", _("controls_cancel_back")),
@@ -2922,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
@@ -3780,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]
@@ -4555,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?"
@@ -4692,6 +4849,8 @@ def draw_folder_browser(screen):
# Titre selon le mode
if browser_mode == "roms_root":
title = _("folder_browser_title_roms_root") if _ else "Select default ROMs folder"
elif browser_mode == "history_move":
title = _("folder_browser_title_history_move") if _ else "Select destination folder"
else:
title = _("folder_browser_title").format(platform_name) if _ else f"Select folder for {platform_name}"
title_text = config.font.render(title, True, THEME_COLORS["text"])
@@ -4711,8 +4870,17 @@ def draw_folder_browser(screen):
list_y = panel_y + 100
list_height = panel_height - 180
item_height = max(35, config.small_font.get_height() + 10)
visible_items = min(visible_items, list_height // item_height)
visible_items = max(1, list_height // item_height)
config.folder_browser_visible_items = visible_items
max_scroll_offset = max(0, len(items) - visible_items)
if scroll_offset > max_scroll_offset:
scroll_offset = max_scroll_offset
config.folder_browser_scroll_offset = scroll_offset
if selection >= len(items) and items:
selection = len(items) - 1
config.folder_browser_selection = selection
# Afficher les éléments visibles
for i in range(visible_items):
@@ -4847,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 []
@@ -5046,6 +5209,30 @@ def draw_history_game_options(screen):
dest_folder = _get_dest_folder_name(platform)
base_path = os.path.join(config.ROMS_FOLDER, dest_folder)
file_exists, actual_filename, actual_path = find_file_with_or_without_extension(base_path, game_name)
actual_matches = find_matching_files(base_path, game_name)
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 = []
@@ -5075,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':
@@ -5095,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")
@@ -5156,6 +5368,7 @@ def draw_history_show_folder(screen):
# Utiliser le chemin réel trouvé (avec ou sans extension)
actual_path = getattr(config, 'history_actual_path', None)
actual_filename = getattr(config, 'history_actual_filename', None)
actual_matches = getattr(config, 'history_actual_matches', None) or []
if not actual_path or not actual_filename:
# Fallback si pas trouvé
@@ -5164,7 +5377,7 @@ def draw_history_show_folder(screen):
actual_filename = game_name
# Vérifier si le fichier existe
file_exists = os.path.exists(actual_path)
file_exists = bool(actual_matches) or os.path.exists(actual_path)
# Message
title = _("history_folder_path_label") if _ else "Destination path:"
@@ -5175,8 +5388,18 @@ def draw_history_show_folder(screen):
margin_top_bottom = 30
rect_width = min(config.screen_width - 100, 800)
# Wrapper le chemin avec la bonne largeur (largeur de la boîte - marges)
path_wrapped = wrap_text(actual_path, config.small_font, rect_width - 80)
# Wrapper les chemins avec la bonne largeur (largeur de la boîte - marges)
if actual_matches:
path_wrapped = []
for index, (match_filename, match_path) in enumerate(actual_matches, start=1):
wrapped_match = wrap_text(match_path, config.small_font, rect_width - 80)
if wrapped_match:
path_wrapped.append(f"{index}. {wrapped_match[0]}")
path_wrapped.extend(wrapped_match[1:])
else:
path_wrapped.append(f"{index}. {match_path}")
else:
path_wrapped = wrap_text(actual_path, config.small_font, rect_width - 80)
# Ajouter un message si le fichier n'existe pas
warning_lines = []
@@ -5321,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):
@@ -5330,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
@@ -5362,6 +5586,7 @@ def draw_text_file_viewer(screen):
content = getattr(config, 'text_file_content', '')
filename = getattr(config, 'text_file_name', 'Unknown')
scroll_offset = getattr(config, 'text_file_scroll_offset', 0)
viewer_mode = getattr(config, 'text_file_mode', '')
# Dimensions
margin = 40
@@ -5438,6 +5663,11 @@ def draw_text_file_viewer(screen):
position_surface = config.small_font.render(position_text, True, THEME_COLORS["title_text"])
position_rect = position_surface.get_rect(right=rect_x + rect_width - 30, bottom=rect_y + rect_height - 10)
screen.blit(position_surface, position_rect)
if viewer_mode == 'ota_update':
hint_surface = config.small_font.render("Confirm: Update", True, THEME_COLORS["text_selected"])
hint_rect = hint_surface.get_rect(left=rect_x + 30, bottom=rect_y + rect_height - 10)
screen.blit(hint_surface, hint_rect)
else:
# Aucun contenu
no_content_text = "Empty file"
@@ -5620,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
@@ -5630,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"])
@@ -5685,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,10 +13,17 @@
"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",
"error_invalid_download_data": "Ungültige Downloaddaten",
"popup_torrent_in_maintenance": "Torrent in Wartung, bitte warten",
"error_delete_sources": "Fehler beim Löschen der Datei systems_list.json oder Ordner",
"platform_no_platform": "Keine Plattform",
"platform_page": "Seite {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -56,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.",
@@ -297,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",
@@ -307,15 +319,19 @@
"history_option_delete_game": "Spiel löschen",
"history_option_error_info": "Fehlerdetails",
"history_option_retry": "Download wiederholen",
"history_move_action": "Verschieben",
"history_option_back": "Zurück",
"history_folder_path_label": "Zielpfad:",
"history_scraper_not_implemented": "Scraper noch nicht implementiert",
"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",
"history_delete_error": "Fehler beim Löschen des Spiels: {0}",
"history_move_success": "{0} Datei(en) verschoben nach: {1}",
"history_move_error": "Fehler beim Verschieben: {0}",
"history_error_details_title": "Fehlerdetails",
"history_no_error_message": "Keine Fehlermeldung verfügbar",
"web_title": "RGSX Web-Oberfläche",
@@ -423,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",
@@ -480,6 +496,7 @@
"platform_folder_set": "Ordner für {0} festgelegt: {1}",
"platform_folder_default_path": "Standard: {0}",
"folder_browser_title": "Ordner für {0} auswählen",
"folder_browser_title_history_move": "Zielordner auswählen",
"folder_browser_parent": "Übergeordneter Ordner",
"folder_browser_enter": "Öffnen",
"folder_browser_select": "Auswählen",

View File

@@ -13,10 +13,17 @@
"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}",
"error_invalid_download_data": "Invalid download data",
"popup_torrent_in_maintenance": "Torrent under maintenance, please wait",
"error_delete_sources": "Error deleting systems_list.json file or folders",
"platform_no_platform": "No platform",
"platform_page": "Page {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -56,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.",
@@ -189,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",
@@ -296,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",
@@ -306,6 +318,7 @@
"history_option_delete_game": "Delete game",
"history_option_error_info": "Error details",
"history_option_retry": "Retry download",
"history_move_action": "Move",
"menu_scan_owned_roms": "Scan owned ROMs",
"popup_scan_owned_roms_done": "ROM scan complete: {0} games added across {1} platforms",
"popup_scan_owned_roms_error": "ROM scan error: {0}",
@@ -314,10 +327,13 @@
"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",
"history_delete_error": "Error deleting game: {0}",
"history_move_success": "Moved {0} file(s) to: {1}",
"history_move_error": "Error while moving files: {0}",
"history_error_details_title": "Error Details",
"history_no_error_message": "No error message available",
"web_title": "RGSX Web Interface",
@@ -425,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",
@@ -480,6 +496,7 @@
"platform_folder_set": "Folder set for {0}: {1}",
"platform_folder_default_path": "Default: {0}",
"folder_browser_title": "Select folder for {0}",
"folder_browser_title_history_move": "Select destination folder",
"folder_browser_parent": "Parent folder",
"folder_browser_enter": "Enter",
"folder_browser_select": "Select",

View File

@@ -13,10 +13,17 @@
"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}",
"error_invalid_download_data": "Datos de descarga no válidos",
"popup_torrent_in_maintenance": "Torrent en mantenimiento, por favor espere",
"error_delete_sources": "Error al eliminar el archivo systems_list.json o carpetas",
"platform_no_platform": "Ninguna plataforma",
"platform_page": "Página {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -55,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}",
@@ -297,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",
@@ -307,15 +319,19 @@
"history_option_delete_game": "Eliminar juego",
"history_option_error_info": "Detalles del error",
"history_option_retry": "Reintentar descarga",
"history_move_action": "Mover",
"history_option_back": "Volver",
"history_folder_path_label": "Ruta de destino:",
"history_scraper_not_implemented": "Scraper aún no implementado",
"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",
"history_delete_error": "Error al eliminar juego: {0}",
"history_move_success": "{0} archivo(s) movido(s) a: {1}",
"history_move_error": "Error al mover los archivos: {0}",
"history_error_details_title": "Detalles del error",
"history_no_error_message": "No hay mensaje de error disponible",
"web_title": "Interfaz Web RGSX",
@@ -423,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",
@@ -478,6 +494,7 @@
"platform_folder_set": "Carpeta establecida para {0}: {1}",
"platform_folder_default_path": "Por defecto: {0}",
"folder_browser_title": "Seleccionar carpeta para {0}",
"folder_browser_title_history_move": "Seleccionar carpeta de destino",
"folder_browser_parent": "Carpeta superior",
"folder_browser_enter": "Entrar",
"folder_browser_select": "Seleccionar",

View File

@@ -13,10 +13,17 @@
"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}",
"error_invalid_download_data": "Données de téléchargement invalides",
"popup_torrent_in_maintenance": "Torrent en maintenance, veuillez patienter",
"error_delete_sources": "Erreur lors de la suppression du fichier systems_list.json ou dossiers",
"platform_no_platform": "Aucune plateforme",
"platform_page": "Page {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -56,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.",
@@ -186,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",
@@ -296,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",
@@ -306,6 +318,7 @@
"history_option_delete_game": "Supprimer le jeu",
"history_option_error_info": "Détails de l'erreur",
"history_option_retry": "Retenter le téléchargement",
"history_move_action": "Déplacer",
"menu_scan_owned_roms": "Scanner les ROMs présentes",
"popup_scan_owned_roms_done": "Scan ROMs terminé : {0} jeux ajoutés sur {1} plateformes",
"popup_scan_owned_roms_error": "Erreur scan ROMs : {0}",
@@ -314,10 +327,13 @@
"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",
"history_delete_error": "Erreur lors de la suppression du jeu : {0}",
"history_move_success": "{0} fichier(s) déplacé(s) vers : {1}",
"history_move_error": "Erreur lors du déplacement : {0}",
"history_error_details_title": "Détails de l'erreur",
"history_no_error_message": "Aucun message d'erreur disponible",
"web_title": "Interface Web RGSX",
@@ -425,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",
@@ -480,6 +496,7 @@
"platform_folder_set": "Dossier défini pour {0}: {1}",
"platform_folder_default_path": "Par défaut: {0}",
"folder_browser_title": "Sélectionner le dossier pour {0}",
"folder_browser_title_history_move": "Sélectionner le dossier de destination",
"folder_browser_parent": "Dossier parent",
"folder_browser_enter": "Entrer",
"folder_browser_select": "Valider",

View File

@@ -13,10 +13,17 @@
"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}",
"error_invalid_download_data": "Dati di download non validi",
"popup_torrent_in_maintenance": "Torrent in manutenzione, attendere prego",
"error_delete_sources": "Errore nell'eliminazione del file systems_list.json o delle cartelle",
"platform_no_platform": "Nessuna piattaforma",
"platform_page": "Pagina {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -55,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}",
@@ -292,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",
@@ -302,15 +314,19 @@
"history_option_delete_game": "Elimina gioco",
"history_option_error_info": "Dettagli errore",
"history_option_retry": "Riprova download",
"history_move_action": "Sposta",
"history_option_back": "Indietro",
"history_folder_path_label": "Percorso destinazione:",
"history_scraper_not_implemented": "Scraper non ancora implementato",
"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",
"history_delete_error": "Errore durante l'eliminazione del gioco: {0}",
"history_move_success": "{0} file spostato/i in: {1}",
"history_move_error": "Errore durante lo spostamento: {0}",
"history_error_details_title": "Dettagli errore",
"history_no_error_message": "Nessun messaggio di errore disponibile",
"web_title": "Interfaccia Web RGSX",
@@ -418,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",
@@ -476,6 +492,7 @@
"platform_folder_set": "Cartella impostata per {0}: {1}",
"platform_folder_default_path": "Predefinito: {0}",
"folder_browser_title": "Seleziona cartella per {0}",
"folder_browser_title_history_move": "Seleziona cartella di destinazione",
"folder_browser_parent": "Cartella superiore",
"folder_browser_enter": "Entra",
"folder_browser_select": "Seleziona",

View File

@@ -13,10 +13,17 @@
"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}",
"error_invalid_download_data": "Dados de download inválidos",
"popup_torrent_in_maintenance": "Torrent em manutenção, aguarde",
"error_delete_sources": "Erro ao deletar arquivo sources.json ou pastas",
"platform_no_platform": "Sem plataforma",
"platform_page": "Página {0}/{1}",
@@ -25,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",
@@ -46,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",
@@ -56,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.",
@@ -298,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",
@@ -308,15 +320,19 @@
"history_option_delete_game": "Excluir jogo",
"history_option_error_info": "Detalhes do erro",
"history_option_retry": "Tentar novamente",
"history_move_action": "Mover",
"history_option_back": "Voltar",
"history_folder_path_label": "Caminho de destino:",
"history_scraper_not_implemented": "Scraper ainda não implementado",
"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",
"history_delete_error": "Erro ao excluir jogo: {0}",
"history_move_success": "{0} arquivo(s) movido(s) para: {1}",
"history_move_error": "Erro ao mover os arquivos: {0}",
"history_error_details_title": "Detalhes do erro",
"history_no_error_message": "Nenhuma mensagem de erro disponível",
"web_title": "Interface Web RGSX",
@@ -424,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",
@@ -480,6 +496,7 @@
"platform_folder_set": "Pasta definida para {0}: {1}",
"platform_folder_default_path": "Padrão: {0}",
"folder_browser_title": "Selecionar pasta para {0}",
"folder_browser_title_history_move": "Selecionar pasta de destino",
"folder_browser_parent": "Pasta superior",
"folder_browser_enter": "Entrar",
"folder_browser_select": "Selecionar",

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,3 @@
{
"version": "2.6.1.0"
"version": "2.6.3.4"
}

View File

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