Compare commits

...

3 Commits

Author SHA1 Message Date
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
17 changed files with 3084 additions and 596 deletions

View File

@@ -2,6 +2,53 @@ import os
import platform
import warnings
def _enable_windows_dpi_awareness_early():
"""Enable DPI awareness before importing pygame so SDL sees physical monitor sizes."""
if platform.system() != "Windows":
return
try:
os.environ.setdefault("SDL_WINDOWS_DPI_AWARENESS", "permonitorv2")
except Exception:
pass
try:
import ctypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
if hasattr(user32, "SetProcessDpiAwarenessContext"):
for awareness in (-4, -3):
try:
if user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(awareness)):
return
except Exception:
continue
except Exception:
pass
try:
import ctypes
shcore = ctypes.WinDLL("shcore", use_last_error=True)
if hasattr(shcore, "SetProcessDpiAwareness"):
shcore.SetProcessDpiAwareness(2)
return
except Exception:
pass
try:
import ctypes
user32 = ctypes.WinDLL("user32", use_last_error=True)
if hasattr(user32, "SetProcessDPIAware"):
user32.SetProcessDPIAware()
except Exception:
pass
_enable_windows_dpi_awareness_early()
# Ignorer le warning de deprecation de pkg_resources dans pygame
warnings.filterwarnings("ignore", category=UserWarning, module="pygame.pkgdata")
warnings.filterwarnings("ignore", message="pkg_resources is deprecated")
@@ -19,6 +66,7 @@ import logging
import requests
import queue
import datetime
from datetime import timezone
import subprocess
import sys
import threading
@@ -28,7 +76,7 @@ from display import (
init_display, draw_loading_screen, draw_error_screen, draw_platform_grid,
draw_progress_screen, draw_controls, draw_virtual_keyboard,
draw_extension_warning, draw_pause_menu, draw_controls_help, draw_game_list,
draw_global_search_list,
draw_global_search_list, draw_global_sort_menu,
draw_display_menu, draw_filter_menu_choice, draw_filter_advanced, draw_filter_priority_config,
draw_history_list, draw_clear_history_dialog, draw_cancel_download_dialog,
draw_confirm_dialog, draw_reload_games_data_dialog, draw_popup, draw_gradient,
@@ -41,7 +89,7 @@ from controls_mapper import map_controls, draw_controls_mapping, get_actions
from controls import load_controls_config
from utils import (
load_sources, check_extension_before_download, extract_data,
play_random_music, load_music_config, load_api_keys
play_random_music, load_music_config, load_api_keys, _refresh_loading_feedback, _format_size_bytes
)
from history import load_history, save_history, load_downloaded_games
from config import OTA_data_ZIP
@@ -99,6 +147,7 @@ _run_windows_gamelist_update()
try:
config.update_checked = False
config.gamelist_update_prompted = False # Flag pour ne pas redemander la mise à jour plusieurs fois
config.gamelist_refreshed_this_session = False
config.pending_update_version = ""
config.startup_update_confirmed = False
config.text_file_mode = ""
@@ -760,6 +809,7 @@ async def main():
"filter_menu_choice",
"filter_advanced",
"filter_priority_config",
"global_sort_menu",
"platform_search",
}
if config.menu_state in SIMPLE_HANDLE_STATES:
@@ -1199,6 +1249,8 @@ async def main():
draw_filter_platforms_menu(screen)
elif config.menu_state == "filter_menu_choice":
draw_filter_menu_choice(screen)
elif config.menu_state == "global_sort_menu":
draw_global_sort_menu(screen)
elif config.menu_state == "filter_advanced":
draw_filter_advanced(screen)
elif config.menu_state == "filter_priority_config":
@@ -1440,6 +1492,10 @@ async def main():
try:
success, message = extract_data(local_zip, dest_dir, local_zip)
if success:
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
config.gamelist_refreshed_this_session = True
logger.debug(f"Extraction locale réussie : {message}")
config.loading_progress = 70.0
config.needs_redraw = True
@@ -1460,17 +1516,28 @@ async def main():
config.popup_timer = 5000
else:
try:
_refresh_loading_feedback(
current_system=_("loading_download_data"),
progress=config.loading_progress,
force=True,
)
with requests.get(sources_zip_url, stream=True, headers=headers, timeout=30) as response:
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
logger.debug(f"Taille totale du ZIP : {total_size} octets")
downloaded = 0
download_started_at = time.time()
last_loading_refresh = 0.0
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
with open(zip_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
for chunk in response.iter_content(chunk_size=262144):
if chunk:
f.write(chunk)
downloaded += len(chunk)
elapsed = max(0.001, time.time() - download_started_at)
speed_bytes_per_second = downloaded / elapsed
progress_detail = f"{_format_size_bytes(downloaded)} / {_format_size_bytes(total_size)}" if total_size > 0 else _format_size_bytes(downloaded)
speed_detail = f"{speed_bytes_per_second / (1024 * 1024):.1f} MB/s"
config.download_progress[sources_zip_url] = {
"downloaded_size": downloaded,
"total_size": total_size,
@@ -1479,7 +1546,16 @@ async def main():
}
config.loading_progress = 15.0 + (35.0 * downloaded / total_size) if total_size > 0 else 15.0
config.needs_redraw = True
await asyncio.sleep(0)
now = time.time()
should_refresh = (now - last_loading_refresh) >= 0.12 or (total_size > 0 and downloaded >= total_size)
if should_refresh:
last_loading_refresh = now
_refresh_loading_feedback(
current_system=_("loading_download_data"),
progress=config.loading_progress,
detail_lines=[progress_detail, speed_detail],
)
await asyncio.sleep(0)
logger.debug(f"ZIP téléchargé : {zip_path}")
config.current_loading_system = _("loading_extracting_data")
@@ -1488,6 +1564,11 @@ async def main():
dest_dir = config.SAVE_FOLDER
success, message = extract_data(zip_path, dest_dir, sources_zip_url)
if success:
from rgsx_settings import get_remote_gamelist_timestamp, set_last_gamelist_update
remote_update_dt = get_remote_gamelist_timestamp(sources_zip_url)
set_last_gamelist_update(remote_update_dt)
config.gamelist_refreshed_this_session = True
logger.debug(f"Extraction réussie : {message}")
config.loading_progress = 70.0
config.needs_redraw = True
@@ -1526,34 +1607,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,388 @@
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from pathlib import Path
_TORRENT_DOWNLOAD_SCHEME = "rgsx+torrent"
def _format_size_bytes(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
if size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def build_torrent_download_url(source_url: str, file_index: int, relative_path: str, size_bytes: int | None = None) -> str:
params = {
"source": source_url,
"index": str(max(1, int(file_index))),
"path": relative_path,
}
if isinstance(size_bytes, int) and size_bytes > 0:
params["size"] = str(size_bytes)
return f"{_TORRENT_DOWNLOAD_SCHEME}://download?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
def get_clean_display_name(raw_name, platform_id=None):
text = str(raw_name or "").strip()
if not text:
return ""
normalized = text.replace("\\", "/")
leaf_name = normalized.rsplit("/", 1)[-1]
display_name = Path(leaf_name).stem.strip()
prefixes = []
if platform_id:
prefixes.append(str(platform_id).strip())
for prefix in prefixes:
if not prefix:
continue
pattern = rf"^{re.escape(prefix)}[\s\-_:]+"
updated_name = re.sub(pattern, "", display_name, flags=re.IGNORECASE).strip()
if updated_name:
display_name = updated_name
return display_name.strip(" -_/")
def _decode_bencode_text(value) -> str:
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
if isinstance(value, str):
return value
return str(value or "")
def _bdecode(data: bytes, index: int = 0):
token = data[index:index + 1]
if token == b"i":
end = data.index(b"e", index)
return int(data[index + 1:end]), end + 1
if token == b"l":
items = []
index += 1
while data[index:index + 1] != b"e":
value, index = _bdecode(data, index)
items.append(value)
return items, index + 1
if token == b"d":
values = {}
index += 1
while data[index:index + 1] != b"e":
key, index = _bdecode(data, index)
value, index = _bdecode(data, index)
values[key] = value
return values, index + 1
if token.isdigit():
sep = data.index(b":", index)
length = int(data[index:sep])
start = sep + 1
end = start + length
return data[start:end], end
raise ValueError(f"Invalid bencode token at offset {index}: {token!r}")
def is_torrent_manifest_url(url: str | None) -> bool:
if not url or not isinstance(url, str):
return False
try:
parsed = urllib.parse.urlparse(url.strip())
except Exception:
return False
return (parsed.path or "").lower().endswith(".torrent")
def _extract_torrent_source(item) -> tuple[str, str] | None:
if isinstance(item, (list, tuple)):
if len(item) < 2:
return None
source_name = str(item[0] or "").strip()
source_url = item[1] if isinstance(item[1], str) else None
if source_url and is_torrent_manifest_url(source_url):
return source_name, source_url.strip()
return None
if isinstance(item, dict):
source_url = item.get("torrent_url") or item.get("url") or item.get("download") or item.get("link")
if not isinstance(source_url, str) or not source_url.strip():
return None
source_type = str(item.get("type") or item.get("source_type") or item.get("source") or "").strip().lower()
if source_type == "torrent" or is_torrent_manifest_url(source_url):
source_name = item.get("game_name") or item.get("name") or item.get("title") or item.get("game") or item.get("label")
if not source_name:
parsed = urllib.parse.urlparse(source_url)
source_name = urllib.parse.unquote(Path(parsed.path).name)
return str(source_name or "").strip(), source_url.strip()
return None
def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list[dict[str, str | int]]:
torrent_data, _next_index = _bdecode(payload)
if not isinstance(torrent_data, dict):
raise ValueError("Torrent root metadata is not a dictionary")
info = torrent_data.get(b"info")
if not isinstance(info, dict):
raise ValueError("Torrent metadata does not contain an info dictionary")
entries: list[dict[str, str | int]] = []
files = info.get(b"files")
root_name = _decode_bencode_text(info.get(b"name.utf-8") or info.get(b"name") or "").strip()
if isinstance(files, list):
for file_index, file_entry in enumerate(files, start=1):
if not isinstance(file_entry, dict):
continue
path_parts = file_entry.get(b"path.utf-8") or file_entry.get(b"path") or []
if not isinstance(path_parts, list):
continue
parts = [_decode_bencode_text(part).strip() for part in path_parts]
parts = [part for part in parts if part]
if not parts:
continue
full_path = "/".join(parts)
download_path = "/".join([part for part in [root_name, full_path] if part])
entries.append(
{
"name": parts[-1],
"path": full_path,
"download_path": download_path or full_path,
"index": file_index,
"size_bytes": int(file_entry.get(b"length") or 0),
"source_url": source_url,
}
)
else:
if root_name:
entries.append(
{
"name": root_name,
"path": root_name,
"download_path": root_name,
"index": 1,
"size_bytes": int(info.get(b"length") or 0),
"source_url": source_url,
}
)
duplicate_names: dict[str, int] = {}
for entry in entries:
name = str(entry["name"])
duplicate_names[name] = duplicate_names.get(name, 0) + 1
for entry in entries:
if duplicate_names.get(str(entry["name"]), 0) > 1:
entry["name"] = str(entry["path"])
return entries
def _fetch_torrent_entries(source_url: str) -> list[dict[str, str | int]]:
request = urllib.request.Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept": "*/*",
},
)
with urllib.request.urlopen(request, timeout=30) as response:
payload = response.read()
return _extract_torrent_entries_from_bytes(payload, source_url)
def _iter_game_rows(data):
if isinstance(data, dict) and "games" in data:
data = data["games"]
if isinstance(data, list):
return data
if isinstance(data, dict):
return [data]
return []
def _build_platform_search_entries(
rows,
torrent_manifest_cache: dict[str, list[dict[str, str | int]]],
warnings: list[str],
platform_id: str,
) -> list[dict[str, str | int]]:
indexed_entries: list[dict[str, str | int]] = []
for item in rows:
torrent_source = _extract_torrent_source(item)
if torrent_source is not None:
source_name, source_url = torrent_source
entries = torrent_manifest_cache.get(source_url)
if entries is None:
try:
entries = _fetch_torrent_entries(source_url)
torrent_manifest_cache[source_url] = entries
except Exception as exc:
warnings.append(f"{platform_id}: failed to build torrent cache for {source_name or source_url}: {exc}")
entries = []
for entry in entries:
game_name = str(entry.get("name") or "").strip()
if not game_name:
continue
size_bytes = int(entry.get("size_bytes") or 0)
file_index = int(entry.get("index") or 1)
relative_path = str(entry.get("download_path") or entry.get("path") or game_name)
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": game_name,
"display_name": get_clean_display_name(game_name, platform_id),
"url": build_torrent_download_url(source_url, file_index, relative_path, size_bytes),
"size": _format_size_bytes(size_bytes) if size_bytes > 0 else "",
"size_bytes": size_bytes,
}
)
continue
if isinstance(item, dict):
name = item.get("game_name") or item.get("name") or item.get("title") or item.get("game")
if name:
size = item.get("size") or item.get("filesize") or item.get("length") or ""
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": str(name),
"display_name": get_clean_display_name(name, platform_id),
"url": str(item.get("url") or item.get("download") or item.get("link") or item.get("href") or ""),
"size": str(size) if size else "",
"size_bytes": 0,
}
)
continue
if isinstance(item, (list, tuple)):
if len(item) > 0 and str(item[0] or "").strip():
size = item[2] if len(item) > 2 and item[2] is not None else ""
url = item[1] if len(item) > 1 and isinstance(item[1], str) else ""
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": str(item[0]),
"display_name": get_clean_display_name(item[0], platform_id),
"url": url,
"size": str(size) if size else "",
"size_bytes": 0,
}
)
continue
if isinstance(item, str) and item.strip():
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": item.strip(),
"display_name": get_clean_display_name(item, platform_id),
"url": "",
"size": "",
"size_bytes": 0,
}
)
continue
if item is not None:
item_text = str(item)
indexed_entries.append(
{
"platform_id": platform_id,
"game_name": item_text,
"display_name": get_clean_display_name(item_text, platform_id),
"url": "",
"size": "",
"size_bytes": 0,
}
)
return indexed_entries
def build_caches(games_dir: Path) -> tuple[dict[str, list[dict[str, str | int]]], dict[str, dict[str, str | int]], list[dict[str, str | int]], list[str]]:
torrent_manifest_cache: dict[str, list[dict[str, str | int]]] = {}
platform_count_cache: dict[str, dict[str, str | int]] = {}
global_search_index: list[dict[str, str | int]] = []
warnings: list[str] = []
for game_file in sorted(games_dir.glob("*.json")):
with game_file.open("r", encoding="utf-8") as handle:
data = json.load(handle)
rows = _iter_game_rows(data)
platform_id = game_file.stem
platform_entries = _build_platform_search_entries(rows, torrent_manifest_cache, warnings, platform_id)
count = len(platform_entries)
platform_count_cache[platform_id] = {
"path": "",
"mtime_ns": 0,
"file_name": game_file.name,
"size_bytes": game_file.stat().st_size,
"count": int(count),
}
global_search_index.extend(platform_entries)
return torrent_manifest_cache, platform_count_cache, global_search_index, warnings
def main() -> int:
parser = argparse.ArgumentParser(description="Build portable RGSX cache files from exported games JSONs.")
parser.add_argument("--games-dir", required=True, help="Directory containing exported games/*.json files")
parser.add_argument("--output-dir", required=True, help="Directory where cache JSON files will be written")
args = parser.parse_args()
games_dir = Path(args.games_dir)
output_dir = Path(args.output_dir)
if not games_dir.is_dir():
print(json.dumps({"ok": False, "error": f"games directory not found: {games_dir}"}), file=sys.stderr)
return 2
output_dir.mkdir(parents=True, exist_ok=True)
try:
torrent_manifest_cache, platform_count_cache, global_search_index, warnings = build_caches(games_dir)
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
return 1
torrent_cache_path = output_dir / "torrent_manifest_cache.json"
platform_count_cache_path = output_dir / "platform_games_count_cache.json"
global_search_index_path = output_dir / "global_search_index.json"
torrent_cache_payload = {"version": 1, "entries": torrent_manifest_cache}
platform_count_payload = {"version": 2, "entries": platform_count_cache}
global_search_payload = {"version": 1, "entries": global_search_index}
torrent_cache_path.write_text(json.dumps(torrent_cache_payload, ensure_ascii=False, indent=2), encoding="utf-8")
platform_count_cache_path.write_text(json.dumps(platform_count_payload, ensure_ascii=False, indent=2), encoding="utf-8")
global_search_index_path.write_text(json.dumps(global_search_payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"torrent_manifest_count": len(torrent_manifest_cache),
"platform_count_entries": len(platform_count_cache),
"global_search_entries": len(global_search_index),
"torrent_cache_path": str(torrent_cache_path),
"platform_count_cache_path": str(platform_count_cache_path),
"global_search_index_path": str(global_search_index_path),
"warnings": warnings,
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -27,7 +27,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.1.6.1"
app_version = "2.6.3.0"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -195,6 +195,10 @@ PRECONF_CONTROLS_PATH = os.path.join(APP_FOLDER, "assets", "controls")
CONTROLS_CONFIG_PATH = os.path.join(SAVE_FOLDER, "controls.json")
HISTORY_PATH = os.path.join(SAVE_FOLDER, "history.json")
DOWNLOADED_GAMES_PATH = os.path.join(SAVE_FOLDER, "downloaded_games.json")
TORRENT_MANIFEST_CACHE_PATH = os.path.join(SAVE_FOLDER, "torrent_manifest_cache.json")
PLATFORM_GAME_COUNT_CACHE_PATH = os.path.join(SAVE_FOLDER, "platform_games_count_cache.json")
GLOBAL_SEARCH_INDEX_CACHE_PATH = os.path.join(SAVE_FOLDER, "global_search_index.json")
PENDING_TORRENT_REFRESH_MARKER_PATH = os.path.join(SAVE_FOLDER, "pending_torrent_refresh.marker")
RGSX_SETTINGS_PATH = os.path.join(SAVE_FOLDER, "rgsx_settings.json")
API_KEY_1FICHIER_PATH = os.path.join(SAVE_FOLDER, "1FichierAPI.txt")
API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt")
@@ -441,11 +445,20 @@ global_search_query = "" # Texte saisi pour la recherche globale
global_search_selected = 0 # Index du resultat global selectionne
global_search_scroll_offset = 0 # Offset de defilement des resultats globaux
global_search_editing = False # True si le clavier virtuel est actif pour la recherche globale
global_search_allow_empty = False # True pour afficher tous les resultats sans requete (filtre/tri globaux)
global_search_title_override = "" # Titre alternatif pour la vue globale
global_search_return_state = "platform" # Etat de retour depuis la vue globale
global_sort_option = "name_asc" # Tri global courant
# Variables pour le filtrage avancé
selected_filter_choice = 0 # Index dans le menu de choix de filtrage (recherche / avancé)
selected_filter_option = 0 # Index dans le menu de filtrage avancé
game_filter_obj = None # Objet GameFilters pour le filtrage avancé
filter_menu_context = "platform" # Contexte d'ouverture du menu filtre unifie
filter_menu_entries = [] # Entrees du menu filtre unifie
filter_menu_return_state = "platform" # Etat de retour depuis le menu filtre unifie
filter_target_scope = "local" # Portee du filtrage avance (local/global)
global_sort_selected = 0 # Index selectionne dans le menu de tri global
# Gestion des états du menu
needs_redraw = False # Indicateur si l'écran doit être redessiné

View File

@@ -15,11 +15,15 @@ from utils import (
load_games, check_extension_before_download, is_extension_supported,
load_extensions_json, play_random_music, sanitize_filename,
save_music_config, load_api_keys, _get_dest_folder_name,
extract_zip, extract_rar, find_file_with_or_without_extension, find_matching_files, toggle_web_service_at_boot, check_web_service_status,
extract_zip, extract_rar, extract_7z, find_file_with_or_without_extension, find_matching_files, toggle_web_service_at_boot, check_web_service_status,
restart_application, generate_support_zip, load_sources,
ensure_download_provider_keys, missing_all_provider_keys, build_provider_paths_string,
start_connection_status_check, get_clean_display_name, get_existing_history_matches,
move_files_to_directory, parse_torrent_download_url
clear_torrent_manifest_cache,
request_torrent_manifest_refresh,
clear_platform_game_count_cache,
move_files_to_directory, parse_torrent_download_url,
_refresh_loading_feedback,
)
from history import load_history, clear_history, add_to_history, save_history, scan_roms_for_downloaded_games
from language import _, get_available_languages, set_language
@@ -39,6 +43,13 @@ logger = logging.getLogger(__name__)
# Extensions d'archives pour lesquelles on ignore l'avertissement d'extension non supportée
ARCHIVE_EXTENSIONS = {'.zip', '.7z', '.rar', '.tar', '.gz', '.xz', '.bz2'}
GLOBAL_SORT_OPTIONS = [
("name_asc", lambda: _("web_sort_name_asc") or "A-Z (Name)"),
("name_desc", lambda: _("web_sort_name_desc") or "Z-A (Name)"),
("size_asc", lambda: _("web_sort_size_asc") or "Size +- (Small first)"),
("size_desc", lambda: _("web_sort_size_desc") or "Size -+ (Large first)"),
]
def _notify_torrent_in_maintenance(game_name: str | None = None) -> None:
try:
@@ -69,6 +80,167 @@ def _wrap_index(current_index: int, delta: int, item_count: int) -> int:
return (current_index + delta) % item_count
def _parse_game_size_to_bytes(value) -> int:
if isinstance(value, (int, float)):
return max(0, int(value))
if not isinstance(value, str):
return 0
text = value.strip().replace(',', '.')
if not text:
return 0
match = re.match(r'^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?$', text)
if not match:
return 0
amount = float(match.group(1))
unit = (match.group(2) or 'B').strip().lower()
multipliers = {
'b': 1,
'byte': 1,
'bytes': 1,
'octet': 1,
'octets': 1,
'kb': 1024,
'kib': 1024,
'ko': 1024,
'mb': 1024 ** 2,
'mib': 1024 ** 2,
'mo': 1024 ** 2,
'gb': 1024 ** 3,
'gib': 1024 ** 3,
'go': 1024 ** 3,
'tb': 1024 ** 4,
'tib': 1024 ** 4,
'to': 1024 ** 4,
'pb': 1024 ** 5,
'pib': 1024 ** 5,
'po': 1024 ** 5,
}
return int(amount * multipliers.get(unit, 0)) if unit in multipliers else 0
def _sort_global_items(items: list[dict]) -> list[dict]:
option = getattr(config, 'global_sort_option', 'name_asc') or 'name_asc'
reverse = option in ('name_desc', 'size_desc')
if option.startswith('size_'):
return sorted(
items,
key=lambda item: (
int(item.get('size_bytes') or 0),
str(item.get('display_name') or '').lower(),
str(item.get('platform_label') or '').lower(),
),
reverse=reverse,
)
return sorted(
items,
key=lambda item: (
str(item.get('display_name') or '').lower(),
str(item.get('platform_label') or '').lower(),
int(item.get('size_bytes') or 0),
),
reverse=reverse,
)
def _get_global_sort_index(option: str | None = None) -> int:
target = option or getattr(config, 'global_sort_option', 'name_asc')
for index, (key, _) in enumerate(GLOBAL_SORT_OPTIONS):
if key == target:
return index
return 0
def _sort_local_games(items: list[Game]) -> list[Game]:
option = getattr(config, 'global_sort_option', 'name_asc') or 'name_asc'
reverse = option in ('name_desc', 'size_desc')
if option.startswith('size_'):
return sorted(
items,
key=lambda game: (
_parse_game_size_to_bytes(game.size),
str(game.display_name or game.name or '').lower(),
),
reverse=reverse,
)
return sorted(
items,
key=lambda game: (
str(game.display_name or game.name or '').lower(),
_parse_game_size_to_bytes(game.size),
),
reverse=reverse,
)
def _build_filter_menu_entries(context: str) -> list[dict[str, str]]:
global_search_label = 'Recherche globale' if (_ is None or _("global_search_title") == "global_search_title") else _("global_search_title").format("").replace(" : ", "").rstrip(': ')
platform_search_label = 'Recherche sur cette plateforme' if (_ is None or _("platform_search_title") == "platform_search_title") else _("platform_search_title")
advanced_filter_label = 'Filtrer' if (_ is None or _("filter_advanced") == "filter_advanced") else _("filter_advanced")
sort_label = 'Trier' if (_ is None or _("web_sort") == "web_sort") else _("web_sort")
back_label = 'Retour' if (_ is None or _("menu_back") == "menu_back") else _("menu_back")
entries = []
if context == 'game':
entries.extend([
{
'key': 'platform_search',
'label': platform_search_label,
},
{
'key': 'global_sort',
'label': sort_label,
},
{
'key': 'global_search',
'label': global_search_label,
},
{
'key': 'global_filter',
'label': advanced_filter_label,
},
])
else:
entries.extend([
{
'key': 'global_search',
'label': global_search_label,
},
{
'key': 'global_filter',
'label': advanced_filter_label,
},
{
'key': 'global_sort',
'label': sort_label,
},
])
entries.append({
'key': 'back',
'label': back_label,
})
return entries
def open_unified_filter_menu(source_state: str) -> None:
context = 'game' if source_state == 'game' else 'global'
config.filter_menu_context = context
config.filter_menu_entries = _build_filter_menu_entries(context)
config.filter_menu_return_state = validate_menu_state(source_state)
config.selected_filter_choice = 0
config.previous_menu_state = source_state
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug(f"Ouverture du menu filtre unifie depuis {source_state}")
# Variables globales pour la répétition
key_states = {} # Dictionnaire pour suivre l'état des touches
@@ -102,6 +274,7 @@ VALID_STATES = [
"filter_search", # recherche par nom (existant, mais renommé)
"filter_advanced", # filtrage avancé par région, etc.
"filter_priority_config", # configuration priorité régions pour one-rom-per-game
"global_sort_menu", # menu de tri global
"platform_search", # recherche globale inter-plateformes
"platform_folder_config", # configuration du dossier personnalisé pour une plateforme
"folder_browser", # navigateur de dossiers intégré
@@ -446,8 +619,8 @@ def filter_games_by_search_query() -> list[Game]:
game_name = game.display_name
if config.search_query.lower() in game_name.lower():
filtered_games.append(game)
return filtered_games
return _sort_local_games(filtered_games)
GLOBAL_SEARCH_KEYBOARD_LAYOUT = [
@@ -466,11 +639,33 @@ def _get_platform_label(platform_id: str) -> str:
return config.platform_names.get(platform_id, platform_id)
def _build_global_search_loading_title() -> str:
fallback = "Loading..."
if _ is None:
return fallback
try:
text = _("global_search_title").format("").replace(" : ", "").rstrip(': ')
except Exception:
text = ""
return text or fallback
def build_global_search_index() -> list[dict]:
indexed_games = []
total_platforms = max(1, len(config.platforms))
for platform_index, platform in enumerate(config.platforms):
platform_id = _get_platform_id(platform)
platform_label = _get_platform_label(platform_id)
_refresh_loading_feedback(
current_system=_build_global_search_loading_title(),
progress=((platform_index / total_platforms) * 100.0),
detail_lines=[
_("loading_platform_counter").format(platform_index + 1, total_platforms) if _ else f"Platform {platform_index + 1}/{total_platforms}",
_("loading_platform_name").format(platform_label) if _ else f"Platform: {platform_label}",
_("loading_read_games_resolve_sources") if _ else "Reading games and resolving sources...",
],
force=True,
)
for game in load_games(platform_id):
display_name = game.display_name or Path(game.name).stem
indexed_games.append({
@@ -482,21 +677,135 @@ def build_global_search_index() -> list[dict]:
"search_name": display_name.lower(),
"url": game.url,
"size": game.size,
"size_bytes": _parse_game_size_to_bytes(game.size),
"game_obj": game,
})
indexed_games.sort(key=lambda item: (item["platform_label"].lower(), item["display_name"].lower()))
return indexed_games
_refresh_loading_feedback(
current_system=_build_global_search_loading_title(),
progress=100.0,
detail_lines=[
_("loading_platform_counter").format(total_platforms, total_platforms) if _ else f"Platform {total_platforms}/{total_platforms}",
],
force=True,
)
return _sort_global_items(indexed_games)
def _load_embedded_global_search_index() -> list[dict] | None:
cache_path = getattr(config, 'GLOBAL_SEARCH_INDEX_CACHE_PATH', '')
if not cache_path or not os.path.exists(cache_path):
return None
try:
with open(cache_path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning(f"Impossible de charger l'index global embarque: {exc}")
return None
raw_entries = payload.get('entries') if isinstance(payload, dict) else None
if not isinstance(raw_entries, list):
return None
platform_order: dict[str, int] = {}
for index, platform in enumerate(config.platforms):
platform_order[_get_platform_id(platform)] = index
indexed_games = []
for raw_entry in raw_entries:
if not isinstance(raw_entry, dict):
continue
platform_id = str(raw_entry.get('platform_id') or '').strip()
if not platform_id or platform_id not in platform_order:
continue
game_name = str(raw_entry.get('game_name') or '').strip()
if not game_name:
continue
display_name = str(raw_entry.get('display_name') or '').strip() or Path(game_name).stem
url = str(raw_entry.get('url') or '').strip() or None
size = str(raw_entry.get('size') or '').strip() or None
try:
size_bytes = int(raw_entry.get('size_bytes') or 0)
except (TypeError, ValueError):
size_bytes = 0
game_obj = Game(name=game_name, url=url, size=size, display_name=display_name)
indexed_games.append({
'platform_id': platform_id,
'platform_label': _get_platform_label(platform_id),
'platform_index': platform_order[platform_id],
'game_name': game_name,
'display_name': display_name,
'search_name': display_name.lower(),
'url': url,
'size': size,
'size_bytes': size_bytes,
'game_obj': game_obj,
})
if indexed_games:
logger.info(f"Index global charge depuis le cache embarque: {len(indexed_games)} jeux")
return _sort_global_items(indexed_games)
return None
def _ensure_global_search_index(operation_title: str | None = None) -> None:
index_signature = tuple(config.platforms)
if getattr(config, 'global_search_index', None) and getattr(config, 'global_search_index_signature', None) == index_signature:
return
embedded_index = _load_embedded_global_search_index()
if embedded_index is not None:
config.global_search_index = embedded_index
config.global_search_index_signature = index_signature
return
previous_menu_state = getattr(config, 'menu_state', 'platform')
previous_loading_system = getattr(config, 'current_loading_system', '')
previous_loading_progress = getattr(config, 'loading_progress', 0.0)
previous_loading_detail_lines = list(getattr(config, 'loading_detail_lines', []) or [])
config.menu_state = "loading"
config.current_loading_system = operation_title or _build_global_search_loading_title()
config.loading_progress = 0.0
config.loading_detail_lines = [config.current_loading_system]
config.needs_redraw = True
_refresh_loading_feedback(force=True)
try:
config.global_search_index = build_global_search_index()
config.global_search_index_signature = index_signature
finally:
config.menu_state = previous_menu_state
config.current_loading_system = previous_loading_system
config.loading_progress = previous_loading_progress
config.loading_detail_lines = previous_loading_detail_lines
config.needs_redraw = True
def refresh_global_search_results(reset_selection: bool = True) -> None:
query = (config.global_search_query or "").strip().lower()
if not query:
config.global_search_results = []
else:
config.global_search_results = [
item for item in config.global_search_index
items = list(getattr(config, 'global_search_index', []) or [])
filter_obj = getattr(config, 'game_filter_obj', None)
if filter_obj and filter_obj.is_active():
item_by_game = {id(item.get('game_obj')): item for item in items}
filtered_games = filter_obj.apply_filters([item.get('game_obj') for item in items if item.get('game_obj') is not None])
items = [item_by_game[id(game)] for game in filtered_games if id(game) in item_by_game]
if query:
items = [
item for item in items
if query in item.get("search_name", item["display_name"].lower())
]
elif not getattr(config, 'global_search_allow_empty', False):
items = []
config.global_search_results = _sort_global_items(items)
if reset_selection:
config.global_search_selected = 0
@@ -508,30 +817,58 @@ def refresh_global_search_results(reset_selection: bool = True) -> None:
def enter_global_search() -> None:
index_signature = tuple(config.platforms)
if not getattr(config, 'global_search_index', None) or getattr(config, 'global_search_index_signature', None) != index_signature:
config.global_search_index = build_global_search_index()
config.global_search_index_signature = index_signature
_ensure_global_search_index(_build_global_search_loading_title())
config.global_search_query = ""
config.global_search_results = []
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = bool(getattr(config, 'joystick', False))
config.global_search_allow_empty = False
config.global_search_title_override = _("global_search_title").format("").replace(" : ", "").rstrip(': ') if _ else 'Recherche globale'
config.selected_key = (0, 0)
config.previous_menu_state = "platform"
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug("Entree en recherche globale inter-plateformes")
def enter_global_filtered_results() -> None:
_ensure_global_search_index(_("filter_advanced") if _ else "Loading...")
config.global_search_query = ""
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.global_search_allow_empty = True
config.global_search_title_override = _("filter_advanced") if _ else 'Filtrer'
refresh_global_search_results(reset_selection=True)
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug(f"Affichage des resultats globaux filtres: {len(config.global_search_results)}")
def enter_global_sorted_results() -> None:
_ensure_global_search_index(_("web_sort") if _ else "Loading...")
config.global_search_query = ""
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.global_search_allow_empty = True
config.global_search_title_override = _("web_sort") if _ else 'Trier'
refresh_global_search_results(reset_selection=True)
config.menu_state = "platform_search"
config.needs_redraw = True
logger.debug(f"Affichage des resultats globaux tries ({config.global_sort_option}): {len(config.global_search_results)}")
def exit_global_search() -> None:
config.global_search_query = ""
config.global_search_results = []
config.global_search_selected = 0
config.global_search_scroll_offset = 0
config.global_search_editing = False
config.global_search_allow_empty = False
config.global_search_title_override = ""
config.selected_key = (0, 0)
config.menu_state = "platform"
config.menu_state = validate_menu_state(getattr(config, 'global_search_return_state', None) or getattr(config, 'previous_menu_state', None))
config.needs_redraw = True
@@ -832,7 +1169,7 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
logger.debug("Ouverture history depuis platform")
elif is_input_matched(event, "filter"):
enter_global_search()
open_unified_filter_menu("platform")
elif is_input_matched(event, "confirm"):
# Démarrer le chronomètre pour l'appui long - ne pas exécuter immédiatement
# L'action sera exécutée au relâchement si appui court, ou config dossier si appui long
@@ -1152,12 +1489,7 @@ def handle_controls(event, sources, joystick, screen):
event.value)
config.needs_redraw = True
elif is_input_matched(event, "filter"):
# Afficher le menu de choix entre recherche et filtrage avancé
config.menu_state = "filter_menu_choice"
config.selected_filter_choice = 0
config.previous_menu_state = "game"
config.needs_redraw = True
logger.debug("Ouverture du menu de filtrage")
open_unified_filter_menu("game")
elif is_input_matched(event, "history"):
config.history_origin = "game"
config.menu_state = "history"
@@ -1499,7 +1831,7 @@ def handle_controls(event, sources, joystick, screen):
# Dialogue fichier de support
elif config.menu_state == "support_dialog":
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel"):
if is_input_matched(event, "confirm") or is_input_matched(event, "cancel") or is_input_matched(event, "start"):
# Retour au menu pause
config.menu_state = "pause_menu"
config.needs_redraw = True
@@ -1555,7 +1887,7 @@ def handle_controls(event, sources, joystick, screen):
# Vérifier si c'est une archive ET si le fichier existe
if actual_filename and file_exists:
ext = os.path.splitext(actual_filename)[1].lower()
if ext in ['.zip', '.rar']:
if ext in ['.zip', '.rar', '.7z']:
options.append("extract_archive")
elif ext == '.txt':
options.append("open_file")
@@ -2035,6 +2367,8 @@ def handle_controls(event, sources, joystick, screen):
success, msg = extract_zip(file_path, dest_dir, url)
elif ext == '.rar':
success, msg = extract_rar(file_path, dest_dir, url)
elif ext == '.7z':
success, msg = extract_7z(file_path, dest_dir, url)
else:
success, msg = False, "Not an archive"
@@ -2880,9 +3214,16 @@ def handle_controls(event, sources, joystick, screen):
shutil.rmtree(config.GAMES_FOLDER)
if os.path.exists(config.IMAGES_FOLDER):
shutil.rmtree(config.IMAGES_FOLDER)
# Mettre à jour la date
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
clear_torrent_manifest_cache()
clear_platform_game_count_cache()
request_torrent_manifest_refresh()
# Mettre à jour la date et mémoriser la version distante déjà proposée
from rgsx_settings import (
set_last_gamelist_prompt_remote_update,
set_last_gamelist_update,
)
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "restart_popup"
config.popup_message = _("popup_gamelist_updating") if _ else "Updating game list... Restarting..."
config.popup_timer = 2000
@@ -2893,18 +3234,25 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "loading"
config.needs_redraw = True
else:
# Pas de cache existant, juste mettre à jour la date et continuer
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
# Pas de cache existant, juste mettre à jour la date et mémoriser la version distante déjà proposée
from rgsx_settings import (
set_last_gamelist_prompt_remote_update,
set_last_gamelist_update,
)
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "loading"
config.needs_redraw = True
else: # Non
logger.info("Utilisateur a refusé la mise à jour de la liste des jeux")
# Ne pas mettre à jour la date pour redemander plus tard
from rgsx_settings import set_last_gamelist_prompt_remote_update
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "platform"
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
logger.info("Utilisateur a annulé le prompt de mise à jour")
from rgsx_settings import set_last_gamelist_prompt_remote_update
set_last_gamelist_prompt_remote_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "platform"
config.needs_redraw = True
@@ -3225,9 +3573,12 @@ def handle_controls(event, sources, joystick, screen):
if os.path.exists(config.IMAGES_FOLDER):
shutil.rmtree(config.IMAGES_FOLDER)
logger.debug("Dossier images supprimé avec succès")
clear_torrent_manifest_cache()
clear_platform_game_count_cache()
request_torrent_manifest_refresh()
# Mettre à jour la date de dernière mise à jour
from rgsx_settings import set_last_gamelist_update
set_last_gamelist_update()
set_last_gamelist_update(getattr(config, 'gamelist_remote_update_timestamp', None))
config.menu_state = "restart_popup"
config.popup_message = _("popup_redownload_success")
config.popup_timer = 2000 # bref message
@@ -3309,18 +3660,24 @@ def handle_controls(event, sources, joystick, screen):
# Menu de choix filtrage
elif config.menu_state == "filter_menu_choice":
entries = getattr(config, 'filter_menu_entries', []) or _build_filter_menu_entries(getattr(config, 'filter_menu_context', 'global'))
return_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
total_entries = max(1, len(entries))
if is_input_matched(event, "up"):
config.selected_filter_choice = (config.selected_filter_choice - 1) % 2
config.selected_filter_choice = (config.selected_filter_choice - 1) % total_entries
config.needs_redraw = True
elif is_input_matched(event, "down"):
config.selected_filter_choice = (config.selected_filter_choice + 1) % 2
config.selected_filter_choice = (config.selected_filter_choice + 1) % total_entries
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
if config.selected_filter_choice == 0:
# Recherche par nom (mode existant)
selected_entry = entries[config.selected_filter_choice] if entries else {'key': 'back'}
selected_key = selected_entry.get('key')
if selected_key == 'global_search':
config.global_search_return_state = return_state
enter_global_search()
elif selected_key == 'platform_search':
config.search_mode = True
config.search_query = ""
# Initialiser avec les jeux déjà filtrés par les filtres avancés si actifs
if hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
else:
@@ -3330,27 +3687,73 @@ def handle_controls(event, sources, joystick, screen):
config.selected_key = (0, 0)
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Entrée en mode recherche par nom")
else:
# Filtrage avancé
logger.debug("Entrée en mode recherche sur cette plateforme")
elif selected_key == 'global_filter':
from game_filters import GameFilters
from rgsx_settings import load_game_filters
# Initialiser le filtre
if not hasattr(config, 'game_filter_obj'):
config.game_filter_obj = GameFilters()
filter_dict = load_game_filters()
if filter_dict:
config.game_filter_obj.load_from_dict(filter_dict)
config.filter_target_scope = 'local' if getattr(config, 'filter_menu_context', 'global') == 'game' else 'saved'
config.global_search_return_state = return_state
config.previous_menu_state = 'filter_menu_choice'
config.menu_state = "filter_advanced"
config.selected_filter_option = 0
config.needs_redraw = True
logger.debug("Entrée en filtrage avancé")
logger.debug("Entrée en filtrage avancé global")
elif selected_key == 'global_sort':
config.global_search_return_state = return_state
config.global_sort_selected = _get_global_sort_index()
config.menu_state = 'global_sort_menu'
config.previous_menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug("Ouverture du menu de tri global")
else:
config.menu_state = return_state
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
config.menu_state = "game"
config.menu_state = return_state
config.needs_redraw = True
logger.debug(f"Retour depuis menu filtre vers {config.menu_state}")
elif config.menu_state == 'global_sort_menu':
total_items = len(GLOBAL_SORT_OPTIONS) + 1
if is_input_matched(event, 'up'):
config.global_sort_selected = (config.global_sort_selected - 1) % total_items
config.needs_redraw = True
elif is_input_matched(event, 'down'):
config.global_sort_selected = (config.global_sort_selected + 1) % total_items
config.needs_redraw = True
elif is_input_matched(event, 'confirm'):
if config.global_sort_selected < len(GLOBAL_SORT_OPTIONS):
config.global_sort_option = GLOBAL_SORT_OPTIONS[config.global_sort_selected][0]
if getattr(config, 'filter_menu_context', 'global') == 'game':
if config.search_query:
config.filtered_games = filter_games_by_search_query()
config.filter_active = True
elif hasattr(config, 'game_filter_obj') and config.game_filter_obj and config.game_filter_obj.is_active():
config.filtered_games = _sort_local_games(config.game_filter_obj.apply_filters(config.games))
config.filter_active = True
else:
config.filtered_games = _sort_local_games(config.games)
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
config.needs_redraw = True
logger.debug(f"Tri local applique sur la liste courante ({config.global_sort_option})")
else:
enter_global_sorted_results()
else:
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
elif is_input_matched(event, 'cancel'):
config.menu_state = 'filter_menu_choice'
config.needs_redraw = True
logger.debug("Retour à la liste des jeux")
# Filtrage avancé
elif config.menu_state == "filter_advanced":
@@ -3482,38 +3885,60 @@ def handle_controls(event, sources, joystick, screen):
if button_idx == 0:
# Apply
save_game_filters(config.game_filter_obj.to_dict())
# Appliquer aux jeux actuels
if config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filter_active = True
if getattr(config, 'filter_target_scope', 'local') == 'global':
enter_global_filtered_results()
elif getattr(config, 'filter_target_scope', 'local') == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
config.needs_redraw = True
else:
config.filtered_games = config.games
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = "game"
config.needs_redraw = True
if config.game_filter_obj.is_active():
config.filtered_games = config.game_filter_obj.apply_filters(config.games)
config.filter_active = True
else:
config.filtered_games = config.games
config.filter_active = False
config.current_game = 0
config.scroll_offset = 0
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Filtres appliqués")
elif button_idx == 1:
# Reset
config.game_filter_obj.reset()
save_game_filters(config.game_filter_obj.to_dict())
config.filtered_games = config.games
config.filter_active = False
config.needs_redraw = True
if getattr(config, 'filter_target_scope', 'local') == 'global':
config.needs_redraw = True
elif getattr(config, 'filter_target_scope', 'local') == 'saved':
config.needs_redraw = True
else:
config.filtered_games = config.games
config.filter_active = False
config.needs_redraw = True
logger.debug("Filtres réinitialisés")
elif button_idx == 2:
# Back
config.menu_state = "game"
scope = getattr(config, 'filter_target_scope', 'local')
if scope == 'global':
config.menu_state = "filter_menu_choice"
elif scope == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
else:
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Retour sans appliquer les filtres")
elif is_input_matched(event, "cancel"):
config.menu_state = "game"
scope = getattr(config, 'filter_target_scope', 'local')
if scope == 'global':
config.menu_state = "filter_menu_choice"
elif scope == 'saved':
config.menu_state = validate_menu_state(getattr(config, 'filter_menu_return_state', None))
else:
config.menu_state = "game"
config.needs_redraw = True
logger.debug("Annulation du filtrage avancé")

View File

@@ -28,6 +28,93 @@ from pathlib import Path
from typing import Dict, Any
import urllib.request
def _get_windows_monitor_physical_sizes() -> list[tuple[int, int]]:
"""Return physical monitor resolutions from Win32, bypassing DPI-scaled SDL values."""
if platform.system() != "Windows":
return []
try:
import ctypes
from ctypes import wintypes
CCHDEVICENAME = 32
ENUM_CURRENT_SETTINGS = -1
class MONITORINFOEXW(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("rcMonitor", wintypes.RECT),
("rcWork", wintypes.RECT),
("dwFlags", wintypes.DWORD),
("szDevice", wintypes.WCHAR * CCHDEVICENAME),
]
class DEVMODEW(ctypes.Structure):
_fields_ = [
("dmDeviceName", wintypes.WCHAR * CCHDEVICENAME),
("dmSpecVersion", wintypes.WORD),
("dmDriverVersion", wintypes.WORD),
("dmSize", wintypes.WORD),
("dmDriverExtra", wintypes.WORD),
("dmFields", wintypes.DWORD),
("dmPositionX", wintypes.LONG),
("dmPositionY", wintypes.LONG),
("dmDisplayOrientation", wintypes.DWORD),
("dmDisplayFixedOutput", wintypes.DWORD),
("dmColor", wintypes.SHORT),
("dmDuplex", wintypes.SHORT),
("dmYResolution", wintypes.SHORT),
("dmTTOption", wintypes.SHORT),
("dmCollate", wintypes.SHORT),
("dmFormName", wintypes.WCHAR * 32),
("dmLogPixels", wintypes.WORD),
("dmBitsPerPel", wintypes.DWORD),
("dmPelsWidth", wintypes.DWORD),
("dmPelsHeight", wintypes.DWORD),
("dmDisplayFlags", wintypes.DWORD),
("dmDisplayFrequency", wintypes.DWORD),
("dmICMMethod", wintypes.DWORD),
("dmICMIntent", wintypes.DWORD),
("dmMediaType", wintypes.DWORD),
("dmDitherType", wintypes.DWORD),
("dmReserved1", wintypes.DWORD),
("dmReserved2", wintypes.DWORD),
("dmPanningWidth", wintypes.DWORD),
("dmPanningHeight", wintypes.DWORD),
]
user32 = ctypes.WinDLL("user32", use_last_error=True)
monitors: list[tuple[int, int]] = []
monitor_enum_proc = ctypes.WINFUNCTYPE(
wintypes.BOOL,
wintypes.HMONITOR,
wintypes.HDC,
ctypes.POINTER(wintypes.RECT),
wintypes.LPARAM,
)
def _callback(hmonitor, hdc, lprect, lparam):
monitor_info = MONITORINFOEXW()
monitor_info.cbSize = ctypes.sizeof(MONITORINFOEXW)
if user32.GetMonitorInfoW(hmonitor, ctypes.byref(monitor_info)):
devmode = DEVMODEW()
devmode.dmSize = ctypes.sizeof(DEVMODEW)
if user32.EnumDisplaySettingsW(monitor_info.szDevice, ENUM_CURRENT_SETTINGS, ctypes.byref(devmode)):
width = int(devmode.dmPelsWidth or 0)
height = int(devmode.dmPelsHeight or 0)
if width > 0 and height > 0:
monitors.append((width, height))
return True
return True
user32.EnumDisplayMonitors(0, 0, monitor_enum_proc(_callback), 0)
return monitors
except Exception as e:
logger.debug(f"Résolution physique Win32 indisponible: {e}")
return []
logger = logging.getLogger(__name__)
OVERLAY = None # Initialisé dans init_display()
@@ -470,7 +557,11 @@ def init_display():
# Obtenir la résolution du moniteur cible
try:
if hasattr(pygame.display, 'get_desktop_sizes') and num_displays > 1:
win32_sizes = _get_windows_monitor_physical_sizes()
if target_monitor < len(win32_sizes):
screen_width, screen_height = win32_sizes[target_monitor]
logger.debug(f"Résolution moniteur via Win32: {screen_width}x{screen_height} (monitor={target_monitor})")
elif hasattr(pygame.display, 'get_desktop_sizes') and num_displays > 1:
desktop_sizes = pygame.display.get_desktop_sizes()
if target_monitor < len(desktop_sizes):
screen_width, screen_height = desktop_sizes[target_monitor]
@@ -821,7 +912,7 @@ def draw_loading_screen(screen):
for detail_line in detail_lines:
if not detail_line:
continue
rendered_lines.extend(wrap_text(str(detail_line), config.small_font, max_detail_width))
rendered_lines.append(truncate_text_middle(str(detail_line), config.small_font, max_detail_width, is_filename=False))
for index, detail_line in enumerate(rendered_lines[:3]):
detail_surface = config.small_font.render(detail_line, True, THEME_COLORS["title_text"])
@@ -1305,10 +1396,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}"
@@ -2120,15 +2211,20 @@ def get_display_extension(file_name):
def draw_global_search_list(screen):
"""Affiche la recherche globale par nom sur toutes les plateformes."""
"""Affiche la vue globale unifiée (recherche, filtre, tri)."""
query = getattr(config, 'global_search_query', '') or ''
results = getattr(config, 'global_search_results', []) or []
keyboard_active = bool(getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False))
allow_empty = bool(getattr(config, 'global_search_allow_empty', False))
custom_title = (getattr(config, 'global_search_title_override', '') or '').strip()
screen.blit(OVERLAY, (0, 0))
title_query = query + "_" if (getattr(config, 'joystick', False) and getattr(config, 'global_search_editing', False)) or (not getattr(config, 'joystick', False)) else query
title_text = _("global_search_title").format(title_query)
if custom_title:
title_text = custom_title if not title_query else f"{custom_title} : {title_query}"
else:
title_text = _("global_search_title").format(title_query)
if results:
title_text += f" ({len(results)})"
@@ -2167,7 +2263,7 @@ def draw_global_search_list(screen):
message_zone_top = title_rect_inflated.bottom + 24
message_zone_bottom = max(message_zone_top + 80, reserved_bottom)
if not query.strip():
if not query.strip() and not allow_empty:
message = _("global_search_empty_query")
lines = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
@@ -2497,14 +2593,16 @@ def draw_history_list(screen):
folder_text = _get_dest_folder_name(platform)
# Correction du calcul de la taille
size = entry.get("total_size", 0)
color = THEME_COLORS["fond_lignes"] if i == current_history_item_inverted else THEME_COLORS["text"]
size_text = format_size(size)
status = entry.get("status", "Inconnu")
progress = entry.get("progress", 0)
progress = max(0, min(100, progress)) # Clamp progress between 0 and 100
size = entry.get("total_size", 0)
if (not size or int(size or 0) <= 0) and status in ["Téléchargement", "Downloading"]:
size = entry.get("downloaded_size", 0)
color = THEME_COLORS["fond_lignes"] if i == current_history_item_inverted else THEME_COLORS["text"]
size_text = format_size(size)
# Precompute provider prefix once
provider_prefix = entry.get("provider_prefix") or (entry.get("provider") + ":" if entry.get("provider") else "")
@@ -2512,10 +2610,14 @@ def draw_history_list(screen):
if status in ["Téléchargement", "Downloading"]:
# Vérifier si un message personnalisé existe (ex: mode gratuit avec attente)
custom_message = entry.get('message', '')
total_size_value = int(entry.get("total_size", 0) or 0)
downloaded_size_value = int(entry.get("downloaded_size", 0) or 0)
# Détecter les messages du mode gratuit (commencent par '[' dans toutes les langues)
if custom_message and custom_message.strip().startswith('['):
# Utiliser le message personnalisé pour le mode gratuit
status_text = custom_message
elif total_size_value <= 0 and downloaded_size_value > 0:
status_text = str(status)
else:
# Comportement normal: afficher le pourcentage
status_text = _("history_status_downloading").format(progress)
@@ -2949,6 +3051,9 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
"pause_connection_status": [
("cancel", _("controls_cancel_back")),
],
"support_dialog": [
("start", _("controls_cancel_back")),
],
}
# Cas spécial : pause_settings_menu avec option roms_folder sélectionnée
@@ -3807,7 +3912,7 @@ def draw_pause_games_menu(screen, selected_index):
hide_premium_txt = f"{hide_premium_label}: < {status_hide_premium} >"
# Filter platforms
filter_txt = _("submenu_display_filter_platforms") if _ else "Filter Platforms"
filter_txt = _("submenu_display_filter_platforms") if _ else "Show/Hide Platforms"
back_txt = _("menu_back") if _ else "Back"
options = [update_txt, scan_txt, history_txt, source_txt, unsupported_txt, hide_premium_txt, filter_txt, back_txt]
@@ -4582,12 +4687,15 @@ def draw_gamelist_update_prompt(screen):
screen.blit(OVERLAY, (0, 0))
from config import GAMELIST_UPDATE_DAYS
from rgsx_settings import get_last_gamelist_update
from rgsx_settings import get_last_gamelist_update, format_gamelist_update_display
last_update = get_last_gamelist_update()
if last_update:
message = _("gamelist_update_prompt_with_date").format(GAMELIST_UPDATE_DAYS, last_update) if _ else f"Game list hasn't been updated for more than {GAMELIST_UPDATE_DAYS} days (last update: {last_update}). Download the latest version?"
remote_update = getattr(config, 'gamelist_remote_update_display', '') or ''
local_update = getattr(config, 'gamelist_local_update_display', '') or format_gamelist_update_display(last_update)
if last_update and remote_update:
message = _("gamelist_update_prompt_remote_newer").format(local_update, remote_update) if _ else f"A newer online game list is available (local: {local_update}, online: {remote_update}). Download the latest version?"
elif last_update:
message = _("gamelist_update_prompt_with_date").format(local_update) if _ else f"Local game list last update: {local_update}. Download the latest version?"
else:
message = _("gamelist_update_prompt_first_time") if _ else "Would you like to download the latest game list?"
@@ -4885,22 +4993,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 []
@@ -5119,7 +5222,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':
@@ -5376,7 +5479,7 @@ def draw_history_confirm_delete(screen):
def draw_history_extract_archive(screen):
"""Affiche la confirmation d'extraction d'archive."""
"""Affiche la confirmation d'extraction forcée d'archive."""
screen.blit(OVERLAY, (0, 0))
if not config.history or config.current_history_item >= len(config.history):
@@ -5385,7 +5488,8 @@ def draw_history_extract_archive(screen):
entry = config.history[config.current_history_item]
game_name = entry.get("game_name", "Unknown")
message = f"Extract archive: {game_name}?"
prompt = _("history_extract_archive_confirm") if _ else "Force extract archive"
message = f"{prompt}: {game_name}?"
wrapped_message = wrap_text(message, config.font, config.screen_width - 80)
line_height = config.font.get_height() + 5
text_height = len(wrapped_message) * line_height
@@ -5681,7 +5785,7 @@ def draw_scraper_screen(screen):
def draw_filter_menu_choice(screen):
"""Affiche le menu de choix entre recherche par nom et filtrage avancé"""
"""Affiche le menu filtre unifie."""
screen.blit(OVERLAY, (0, 0))
# Titre
@@ -5691,10 +5795,8 @@ def draw_filter_menu_choice(screen):
screen.blit(title_surface, title_rect)
# Options
options = [
_("filter_search_by_name"),
_("filter_advanced")
]
entries = getattr(config, 'filter_menu_entries', []) or []
options = [entry.get('label', '') for entry in entries]
# Calculer hauteur dynamique basée sur la taille de police
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
@@ -5746,6 +5848,49 @@ def draw_filter_menu_choice(screen):
screen.blit(text_surface, text_rect)
def draw_global_sort_menu(screen):
screen.blit(OVERLAY, (0, 0))
title = _("web_sort") if _ else "Trier"
title_surface = config.title_font.render(title, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, 60))
screen.blit(title_surface, title_rect)
options = [
_("web_sort_name_asc") if _ else "A-Z (Nom)",
_("web_sort_name_desc") if _ else "Z-A (Nom)",
_("web_sort_size_asc") if _ else "Taille +- (Petit d'abord)",
_("web_sort_size_desc") if _ else "Taille -+ (Grand d'abord)",
_("menu_back") if _ else "Retour",
]
sample_text = config.font.render("Sample", True, THEME_COLORS["text"])
font_height = sample_text.get_height()
button_height = max(60, font_height + 30)
max_text_width = 0
for option in options:
text_surface = config.font.render(option, True, THEME_COLORS["text"])
max_text_width = max(max_text_width, text_surface.get_width())
button_width = max(460, max_text_width + 80)
menu_y = 150
button_spacing = 20
for i, option in enumerate(options):
y = menu_y + i * (button_height + button_spacing)
x = (config.screen_width - button_width) // 2
if i == getattr(config, 'global_sort_selected', 0):
color = THEME_COLORS["button_selected"]
border_color = THEME_COLORS["border_selected"]
else:
color = THEME_COLORS["button_idle"]
border_color = THEME_COLORS["border"]
pygame.draw.rect(screen, color, (x, y, button_width, button_height), border_radius=12)
pygame.draw.rect(screen, border_color, (x, y, button_width, button_height), 3, border_radius=12)
text_surface = config.font.render(option, True, THEME_COLORS["text"])
text_rect = text_surface.get_rect(center=(config.screen_width // 2, y + button_height // 2))
screen.blit(text_surface, text_rect)
def draw_filter_advanced(screen):
"""Affiche l'écran de filtrage avancé"""

View File

@@ -2,11 +2,41 @@ import json
import os
import logging
import re
import threading
import time
import config
from datetime import datetime
logger = logging.getLogger(__name__)
def _atomic_write_json(target_path, payload):
temp_path = f"{target_path}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
last_error = None
for attempt in range(5):
try:
os.replace(temp_path, target_path)
last_error = None
break
except PermissionError as e:
last_error = e
time.sleep(0.15 * (attempt + 1))
if last_error is not None:
raise last_error
finally:
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
# Chemin par défaut pour history.json
def init_history():
@@ -77,24 +107,9 @@ def save_history(history):
history_path = getattr(config, 'HISTORY_PATH')
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
# Écriture atomique : écrire dans un fichier temporaire puis renommer
temp_path = history_path + '.tmp'
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(history, f, indent=2, ensure_ascii=False)
f.flush() # Forcer l'écriture sur disque
os.fsync(f.fileno()) # Synchroniser avec le système de fichiers
# Renommer atomiquement (remplace l'ancien fichier)
os.replace(temp_path, history_path)
_atomic_write_json(history_path, history)
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {history_path} : {e}")
# Nettoyer le fichier temporaire en cas d'erreur
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except:
pass
def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None):
"""Ajoute une entrée à l'historique."""
@@ -314,23 +329,10 @@ def save_downloaded_games(downloaded_games_dict):
try:
normalized_downloaded = _normalize_downloaded_games_dict(downloaded_games_dict)
os.makedirs(os.path.dirname(downloaded_path), exist_ok=True)
# Écriture atomique
temp_path = downloaded_path + '.tmp'
with open(temp_path, "w", encoding='utf-8') as f:
json.dump(normalized_downloaded, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, downloaded_path)
_atomic_write_json(downloaded_path, normalized_downloaded)
logger.debug(f"Jeux téléchargés sauvegardés : {_count_downloaded_games(normalized_downloaded)} jeux")
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {downloaded_path} : {e}")
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except:
pass
def mark_game_as_downloaded(platform_name, game_name, file_size=None):

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Spiele und Bilder werden heruntergeladen...",
"loading_extracting_data": "Der initiale Datenordner wird entpackt...",
"loading_load_systems": "Systeme werden geladen...",
"loading_platform_counter": "Plattform {0}/{1}",
"loading_platform_name": "Plattform: {0}",
"loading_game_entries_progress": "Spiele gescannt: {0}/{1}",
"loading_read_games_resolve_sources": "Spiele werden gelesen und Quellen aufgelost...",
"loading_torrent_manifest_analysis": "Torrent-Manifest wird analysiert: {0}",
"loading_torrent_files_progress": "Torrent-Dateien: {0}/{1}",
"error_extract_data_failed": "Herunterladen oder Entpacken des initialen Datenordners fehlgeschlagen.",
"error_no_internet": "Keine Internetverbindung. Überprüfe dein Netzwerk.",
"error_api_key": "Achtung, du musst deinen API-Schlüssel (nur Premium) in der Datei {0} eingeben",
@@ -26,6 +32,7 @@
"game_filter": "Aktiver Filter: {0}",
"game_search": "Filtern: {0}",
"global_search_title": "Globale Suche: {0}",
"platform_search_title": "Diese Plattform durchsuchen",
"global_search_empty_query": "Geben Sie einen Namen ein, um alle Systeme zu durchsuchen",
"global_search_no_results": "Keine Ergebnisse fur: {0}",
"game_header_name": "Name",
@@ -60,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.",
@@ -301,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",
@@ -317,6 +325,7 @@
"history_scraper_not_implemented": "Scraper noch nicht implementiert",
"history_confirm_delete": "Dieses Spiel von der Festplatte löschen?",
"history_file_not_found": "Datei nicht gefunden",
"history_extract_archive_confirm": "Archivsextraktion erzwingen",
"history_extracting": "Extrahieren...",
"history_extracted": "Extrahiert",
"history_delete_success": "Spiel erfolgreich gelöscht",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Downloading games and images...",
"loading_extracting_data": "Extracting initial Data folder...",
"loading_load_systems": "Loading systems...",
"loading_platform_counter": "Platform {0}/{1}",
"loading_platform_name": "Platform: {0}",
"loading_game_entries_progress": "Games scanned: {0}/{1}",
"loading_read_games_resolve_sources": "Reading games and resolving sources...",
"loading_torrent_manifest_analysis": "Analyzing torrent manifest: {0}",
"loading_torrent_files_progress": "Torrent files: {0}/{1}",
"error_extract_data_failed": "Failed to download or extract the initial Data folder.",
"error_no_internet": "No Internet connection. Check your network.",
"error_api_key": "Please enter your API key (premium only) in the file {0}",
@@ -26,6 +32,7 @@
"game_filter": "Active filter: {0}",
"game_search": "Filter: {0}",
"global_search_title": "Global search: {0}",
"platform_search_title": "Search this platform",
"global_search_empty_query": "Type a game name to search across all systems",
"global_search_no_results": "No results for: {0}",
"game_header_name": "Name",
@@ -60,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.",
@@ -193,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",
@@ -300,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",
@@ -319,6 +327,7 @@
"history_scraper_not_implemented": "Scraper not yet implemented",
"history_confirm_delete": "Delete this game from disk?",
"history_file_not_found": "File not found",
"history_extract_archive_confirm": "Force extract archive",
"history_extracting": "Extracting...",
"history_extracted": "Extracted",
"history_delete_success": "Game deleted successfully",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Descargando juegos e imágenes...",
"loading_extracting_data": "Extrayendo la carpeta de datos inicial...",
"loading_load_systems": "Cargando sistemas...",
"loading_platform_counter": "Plataforma {0}/{1}",
"loading_platform_name": "Plataforma: {0}",
"loading_game_entries_progress": "Juegos analizados: {0}/{1}",
"loading_read_games_resolve_sources": "Leyendo juegos y resolviendo fuentes...",
"loading_torrent_manifest_analysis": "Analizando el manifiesto torrent: {0}",
"loading_torrent_files_progress": "Archivos del torrent: {0}/{1}",
"error_extract_data_failed": "Error al descargar o extraer la carpeta de datos inicial.",
"error_no_internet": "Sin conexión a Internet. Verifica tu red.",
"error_api_key": "Atención, debes ingresar tu clave API (solo premium) en el archivo {0}",
@@ -26,6 +32,7 @@
"game_filter": "Filtro activo: {0}",
"game_search": "Filtrar: {0}",
"global_search_title": "Busqueda global: {0}",
"platform_search_title": "Buscar en esta plataforma",
"global_search_empty_query": "Escribe un nombre para buscar en todas las consolas",
"global_search_no_results": "Sin resultados para: {0}",
"game_header_name": "Nombre",
@@ -59,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}",
@@ -301,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",
@@ -317,6 +325,7 @@
"history_scraper_not_implemented": "Scraper aún no implementado",
"history_confirm_delete": "¿Eliminar este juego del disco?",
"history_file_not_found": "Archivo no encontrado",
"history_extract_archive_confirm": "Forzar extraccion del archivo",
"history_extracting": "Extrayendo...",
"history_extracted": "Extraído",
"history_delete_success": "Juego eliminado con éxito",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Téléchargement des jeux et images...",
"loading_extracting_data": "Extraction du dossier de données initial...",
"loading_load_systems": "Chargement des systèmes...",
"loading_platform_counter": "Plateforme {0}/{1}",
"loading_platform_name": "Plateforme : {0}",
"loading_game_entries_progress": "Jeux analyses : {0}/{1}",
"loading_read_games_resolve_sources": "Lecture des jeux et résolution des sources...",
"loading_torrent_manifest_analysis": "Analyse du manifest torrent : {0}",
"loading_torrent_files_progress": "Fichiers du torrent : {0}/{1}",
"error_extract_data_failed": "Échec du téléchargement ou de l'extraction du dossier de données initial.",
"error_no_internet": "Pas de connexion Internet. Vérifiez votre réseau.",
"error_api_key": "Attention il faut renseigner sa clé API (premium only) dans le fichier {0}",
@@ -26,6 +32,7 @@
"game_filter": "Filtre actif : {0}",
"game_search": "Filtrer : {0}",
"global_search_title": "Recherche globale : {0}",
"platform_search_title": "Recherche sur cette plateforme",
"global_search_empty_query": "Saisissez un nom pour rechercher dans toutes les consoles",
"global_search_no_results": "Aucun resultat pour : {0}",
"game_header_name": "Nom",
@@ -60,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.",
@@ -190,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",
@@ -300,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",
@@ -319,6 +327,7 @@
"history_scraper_not_implemented": "Scraper pas encore implémenté",
"history_confirm_delete": "Supprimer ce jeu du disque ?",
"history_file_not_found": "Fichier introuvable",
"history_extract_archive_confirm": "Forcer l'extraction de l'archive",
"history_extracting": "Extraction en cours...",
"history_extracted": "Extrait",
"history_delete_success": "Jeu supprimé avec succès",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Download giochi e immagini...",
"loading_extracting_data": "Estrazione cartella Dati iniziale...",
"loading_load_systems": "Caricamento sistemi...",
"loading_platform_counter": "Piattaforma {0}/{1}",
"loading_platform_name": "Piattaforma: {0}",
"loading_game_entries_progress": "Giochi analizzati: {0}/{1}",
"loading_read_games_resolve_sources": "Lettura dei giochi e risoluzione delle sorgenti...",
"loading_torrent_manifest_analysis": "Analisi del manifest torrent: {0}",
"loading_torrent_files_progress": "File del torrent: {0}/{1}",
"error_extract_data_failed": "Errore nel download o nell'estrazione della cartella Dati iniziale.",
"error_no_internet": "Nessuna connessione Internet. Controlla la rete.",
"error_api_key": "Inserisci la tua API key (solo premium) nel file {0}",
@@ -26,6 +32,7 @@
"game_filter": "Filtro attivo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Ricerca globale: {0}",
"platform_search_title": "Cerca in questa piattaforma",
"global_search_empty_query": "Digita un nome per cercare in tutte le console",
"global_search_no_results": "Nessun risultato per: {0}",
"game_header_name": "Nome",
@@ -59,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}",
@@ -296,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",
@@ -312,6 +320,7 @@
"history_scraper_not_implemented": "Scraper non ancora implementato",
"history_confirm_delete": "Eliminare questo gioco dal disco?",
"history_file_not_found": "File non trovato",
"history_extract_archive_confirm": "Forza estrazione archivio",
"history_extracting": "Estrazione in corso...",
"history_extracted": "Estratto",
"history_delete_success": "Gioco eliminato con successo",

View File

@@ -13,6 +13,12 @@
"loading_downloading_games_images": "Baixando jogos e imagens...",
"loading_extracting_data": "Extraindo pasta inicial Data...",
"loading_load_systems": "Carregando sistemas...",
"loading_platform_counter": "Plataforma {0}/{1}",
"loading_platform_name": "Plataforma: {0}",
"loading_game_entries_progress": "Jogos analisados: {0}/{1}",
"loading_read_games_resolve_sources": "Lendo jogos e resolvendo fontes...",
"loading_torrent_manifest_analysis": "Analisando o manifesto torrent: {0}",
"loading_torrent_files_progress": "Arquivos do torrent: {0}/{1}",
"error_extract_data_failed": "Falha ao baixar ou extrair a pasta inicial Data.",
"error_no_internet": "Sem conexão com a Internet. Verifique sua rede.",
"error_api_key": "Insira sua chave API (somente premium) no arquivo {0}",
@@ -26,6 +32,7 @@
"game_filter": "Filtro ativo: {0}",
"game_search": "Filtro: {0}",
"global_search_title": "Busca global: {0}",
"platform_search_title": "Pesquisar nesta plataforma",
"global_search_empty_query": "Digite um nome para buscar em todos os consoles",
"global_search_no_results": "Nenhum resultado para: {0}",
"game_header_name": "Nome",
@@ -60,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.",
@@ -302,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",
@@ -318,6 +326,7 @@
"history_scraper_not_implemented": "Scraper ainda não implementado",
"history_confirm_delete": "Excluir este jogo do disco?",
"history_file_not_found": "Arquivo não encontrado",
"history_extract_archive_confirm": "Forcar extracao do arquivo",
"history_extracting": "Extraindo...",
"history_extracted": "Extraído",
"history_delete_success": "Jogo excluído com sucesso",

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,7 @@ def load_rgsx_settings():
"roms_folder": "",
"web_service_at_boot": False,
"last_gamelist_update": None,
"last_gamelist_prompt_remote_update": None,
"platform_custom_paths": {} # Chemins personnalisés par plateforme
}
@@ -120,18 +126,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

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
import config
from history import load_history, save_history
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url, request_torrent_manifest_refresh
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
@@ -972,13 +972,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({

View File

@@ -35,6 +35,50 @@ logger = logging.getLogger(__name__)
# Désactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP
def _resolve_7z_command():
if config.OPERATING_SYSTEM == "Windows":
return config.SEVEN_Z_EXE
candidates = []
bundled = getattr(config, 'SEVEN_Z_LINUX', '')
if bundled:
candidates.append(bundled)
for binary_name in ("7zz", "7z", "7zr"):
resolved = shutil.which(binary_name)
if resolved and resolved not in candidates:
candidates.append(resolved)
last_error = None
for candidate in candidates:
if not candidate:
continue
try:
if os.path.isabs(candidate) and not os.path.exists(candidate):
continue
if os.path.exists(candidate) and not os.access(candidate, os.X_OK):
logger.warning(f"{candidate} n'est pas exécutable, correction des permissions...")
os.chmod(candidate, 0o755)
probe = subprocess.run(
[candidate],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
if isinstance(probe.returncode, int):
return candidate
except OSError as exc:
last_error = exc
logger.warning(f"Binaire 7z ignoré ({candidate}): {exc}")
except Exception as exc:
last_error = exc
logger.warning(f"Impossible d'utiliser le binaire 7z {candidate}: {exc}")
if last_error is not None:
logger.error(f"Aucun binaire 7z utilisable trouvé: {last_error}")
return bundled or None
def get_clean_display_name(raw_name, platform_id=None):
"""Return a user-facing game title from a raw file/path entry."""
text = str(raw_name or "").strip()
@@ -63,7 +107,12 @@ def get_clean_display_name(raw_name, platform_id=None):
return display_name.strip(" -_/")
_games_cache = {}
_platform_game_count_cache = {}
_platform_game_count_cache_loaded = False
_platform_game_count_cache_lock = threading.Lock()
_torrent_manifest_cache = {}
_torrent_manifest_cache_loaded = False
_torrent_manifest_cache_lock = threading.Lock()
_TORRENT_DOWNLOAD_SCHEME = "rgsx+torrent"
logging.getLogger("urllib3").setLevel(logging.WARNING)
@@ -80,6 +129,399 @@ def is_mixer_available():
# Liste globale pour stocker les systèmes avec une erreur 404
unavailable_systems = []
def _load_persistent_torrent_manifest_cache() -> None:
global _torrent_manifest_cache_loaded, _torrent_manifest_cache
if _torrent_manifest_cache_loaded:
return
with _torrent_manifest_cache_lock:
if _torrent_manifest_cache_loaded:
return
cache_path = getattr(config, 'TORRENT_MANIFEST_CACHE_PATH', '')
loaded_cache = {}
try:
if cache_path and os.path.exists(cache_path):
with open(cache_path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
if isinstance(payload, dict):
entries = payload.get('entries') if isinstance(payload.get('entries'), dict) else payload
if isinstance(entries, dict):
for source_url, cached_entries in entries.items():
if not isinstance(source_url, str) or not isinstance(cached_entries, list):
continue
safe_entries = []
for entry in cached_entries:
if not isinstance(entry, dict):
continue
safe_entries.append({
'name': str(entry.get('name') or ''),
'path': str(entry.get('path') or ''),
'download_path': str(entry.get('download_path') or entry.get('path') or ''),
'index': int(entry.get('index') or 1),
'size_bytes': int(entry.get('size_bytes') or 0),
'source_url': str(entry.get('source_url') or source_url),
})
if safe_entries:
loaded_cache[source_url] = safe_entries
logger.info(f"Cache torrent charge: {len(loaded_cache)} manifestes")
except Exception as exc:
logger.warning(f"Impossible de charger le cache torrent persistant: {exc}")
_torrent_manifest_cache = loaded_cache
_torrent_manifest_cache_loaded = True
def _save_persistent_torrent_manifest_cache() -> None:
cache_path = getattr(config, 'TORRENT_MANIFEST_CACHE_PATH', '')
if not cache_path:
return
with _torrent_manifest_cache_lock:
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
payload = {
'version': 1,
'entries': _torrent_manifest_cache,
}
temp_path = f"{cache_path}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(temp_path, 'w', encoding='utf-8') as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
last_error = None
for attempt in range(5):
try:
os.replace(temp_path, cache_path)
last_error = None
break
except PermissionError as exc:
last_error = exc
time.sleep(0.15 * (attempt + 1))
if last_error is not None:
raise last_error
except Exception as exc:
logger.warning(f"Impossible de sauvegarder le cache torrent persistant: {exc}")
finally:
try:
if 'temp_path' in locals() and os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
def clear_torrent_manifest_cache() -> None:
global _torrent_manifest_cache, _torrent_manifest_cache_loaded
with _torrent_manifest_cache_lock:
_torrent_manifest_cache = {}
_torrent_manifest_cache_loaded = True
cache_path = getattr(config, 'TORRENT_MANIFEST_CACHE_PATH', '')
try:
if cache_path and os.path.exists(cache_path):
os.remove(cache_path)
logger.info("Cache torrent invalide")
except Exception as exc:
logger.warning(f"Impossible de supprimer le cache torrent: {exc}")
def request_torrent_manifest_refresh() -> None:
marker_path = getattr(config, 'PENDING_TORRENT_REFRESH_MARKER_PATH', '')
if not marker_path:
return
try:
os.makedirs(os.path.dirname(marker_path), exist_ok=True)
with open(marker_path, 'w', encoding='utf-8') as handle:
handle.write(datetime.now().isoformat())
logger.info("Rafraichissement complet des manifests torrent planifie")
except Exception as exc:
logger.warning(f"Impossible de planifier le rafraichissement torrent: {exc}")
def is_torrent_manifest_refresh_requested() -> bool:
marker_path = getattr(config, 'PENDING_TORRENT_REFRESH_MARKER_PATH', '')
return bool(marker_path and os.path.exists(marker_path))
def clear_torrent_manifest_refresh_request() -> None:
marker_path = getattr(config, 'PENDING_TORRENT_REFRESH_MARKER_PATH', '')
if not marker_path or not os.path.exists(marker_path):
return
try:
os.remove(marker_path)
logger.debug("Demande de rafraichissement torrent consommee")
except Exception as exc:
logger.warning(f"Impossible de supprimer le marqueur de rafraichissement torrent: {exc}")
def _load_persistent_platform_game_count_cache() -> None:
global _platform_game_count_cache_loaded, _platform_game_count_cache
if _platform_game_count_cache_loaded:
return
with _platform_game_count_cache_lock:
if _platform_game_count_cache_loaded:
return
cache_path = getattr(config, 'PLATFORM_GAME_COUNT_CACHE_PATH', '')
loaded_cache = {}
try:
if cache_path and os.path.exists(cache_path):
with open(cache_path, 'r', encoding='utf-8') as handle:
payload = json.load(handle)
entries = payload.get('entries') if isinstance(payload, dict) else payload
if isinstance(entries, dict):
for platform_id, entry in entries.items():
if not isinstance(platform_id, str) or not isinstance(entry, dict):
continue
loaded_cache[platform_id] = {
'path': str(entry.get('path') or ''),
'mtime_ns': int(entry.get('mtime_ns') or 0),
'file_name': str(entry.get('file_name') or ''),
'size_bytes': int(entry.get('size_bytes') or 0),
'count': int(entry.get('count') or 0),
}
logger.info(f"Cache compteurs plateformes charge: {len(loaded_cache)} entrees")
except Exception as exc:
logger.warning(f"Impossible de charger le cache compteurs plateformes: {exc}")
_platform_game_count_cache = loaded_cache
_platform_game_count_cache_loaded = True
def _save_persistent_platform_game_count_cache() -> None:
cache_path = getattr(config, 'PLATFORM_GAME_COUNT_CACHE_PATH', '')
if not cache_path:
return
with _platform_game_count_cache_lock:
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
payload = {
'version': 2,
'entries': _platform_game_count_cache,
}
temp_path = f"{cache_path}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(temp_path, 'w', encoding='utf-8') as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
last_error = None
for attempt in range(5):
try:
os.replace(temp_path, cache_path)
last_error = None
break
except PermissionError as exc:
last_error = exc
time.sleep(0.15 * (attempt + 1))
if last_error is not None:
raise last_error
except Exception as exc:
logger.warning(f"Impossible de sauvegarder le cache compteurs plateformes: {exc}")
finally:
try:
if 'temp_path' in locals() and os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
def clear_platform_game_count_cache() -> None:
global _platform_game_count_cache, _platform_game_count_cache_loaded
with _platform_game_count_cache_lock:
_platform_game_count_cache = {}
_platform_game_count_cache_loaded = True
cache_path = getattr(config, 'PLATFORM_GAME_COUNT_CACHE_PATH', '')
try:
if cache_path and os.path.exists(cache_path):
os.remove(cache_path)
logger.info("Cache compteurs plateformes invalide")
except Exception as exc:
logger.warning(f"Impossible de supprimer le cache compteurs plateformes: {exc}")
def _resolve_game_file(platform_id: str):
platform_dict = None
for pd in config.platform_dicts:
if pd.get("platform_name") == platform_id or pd.get("platform") == platform_id:
platform_dict = pd
break
candidates = [os.path.join(config.GAMES_FOLDER, f"{platform_id}.json")]
norm = normalize_platform_name(platform_id)
if norm and norm != platform_id:
candidates.append(os.path.join(config.GAMES_FOLDER, f"{norm}.json"))
if platform_dict:
folder_name = platform_dict.get("folder")
if folder_name:
candidates.append(os.path.join(config.GAMES_FOLDER, f"{folder_name}.json"))
for candidate in candidates:
if os.path.exists(candidate):
return candidate, platform_dict, candidates
return None, platform_dict, candidates
def _get_cached_platform_game_count(platform_id: str, game_file: str, game_mtime_ns: int, game_size_bytes: int) -> int | None:
_load_persistent_platform_game_count_cache()
cached_entry = _platform_game_count_cache.get(platform_id)
if not isinstance(cached_entry, dict):
return None
cached_path = str(cached_entry.get('path') or '')
cached_mtime_ns = int(cached_entry.get('mtime_ns') or 0)
cached_file_name = str(cached_entry.get('file_name') or '')
cached_size_bytes = int(cached_entry.get('size_bytes') or 0)
if cached_path == game_file and cached_mtime_ns == int(game_mtime_ns):
return max(0, int(cached_entry.get('count') or 0))
# Portable fallback for caches embedded in games.zip.
if cached_file_name and cached_file_name == os.path.basename(game_file) and cached_size_bytes == int(game_size_bytes):
return max(0, int(cached_entry.get('count') or 0))
return None
def _store_platform_game_count(platform_id: str, game_file: str, game_mtime_ns: int, game_size_bytes: int, count: int) -> None:
_load_persistent_platform_game_count_cache()
normalized_entry = {
'path': game_file,
'mtime_ns': int(game_mtime_ns),
'file_name': os.path.basename(game_file),
'size_bytes': int(game_size_bytes),
'count': max(0, int(count)),
}
with _platform_game_count_cache_lock:
existing_entry = _platform_game_count_cache.get(platform_id)
if isinstance(existing_entry, dict):
existing_normalized = {
'path': str(existing_entry.get('path') or ''),
'mtime_ns': int(existing_entry.get('mtime_ns') or 0),
'file_name': str(existing_entry.get('file_name') or ''),
'size_bytes': int(existing_entry.get('size_bytes') or 0),
'count': int(existing_entry.get('count') or 0),
}
if existing_normalized == normalized_entry:
return
_platform_game_count_cache[platform_id] = normalized_entry
_save_persistent_platform_game_count_cache()
def _get_torrent_entry_count(source_url: str, display_label: str | None = None, platform_id: str | None = None, allow_network_fetch: bool = True) -> int:
_load_persistent_torrent_manifest_cache()
cached = _torrent_manifest_cache.get(source_url)
if cached is not None:
return len(cached)
if not allow_network_fetch:
logger.debug(f"Comptage torrent differe (cache absent, fetch desactive): {platform_id or 'unknown'} -> {source_url}")
return 0
return len(_get_torrent_entries(source_url, display_label=display_label, platform_id=platform_id))
def get_platform_game_count(platform_id: str, allow_torrent_manifest_fetch: bool = True) -> int:
game_file, resolved_platform_dict, candidates = _resolve_game_file(platform_id)
if not game_file:
logger.warning(f"Aucun fichier de jeux trouvé pour {platform_id} (candidats: {candidates})")
return 0
game_stat = os.stat(game_file)
game_mtime_ns = game_stat.st_mtime_ns
game_size_bytes = game_stat.st_size
cached_count = _get_cached_platform_game_count(platform_id, game_file, game_mtime_ns, game_size_bytes)
if cached_count is not None:
return cached_count
with open(game_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict) and 'games' in data:
data = data['games']
count = 0
def count_from_dict(d):
torrent_source = _extract_torrent_source(d)
if torrent_source is not None:
source_name, source_url = torrent_source
try:
return _get_torrent_entry_count(
source_url,
display_label=source_name,
platform_id=platform_id,
allow_network_fetch=allow_torrent_manifest_fetch,
)
except Exception as exc:
logger.error(f"Erreur comptage torrent pour {platform_id} ({source_name or source_url}): {exc}")
return 0
name = d.get('game_name') or d.get('name') or d.get('title') or d.get('game')
return 1 if name else 0
if isinstance(data, list):
total_items = max(1, len(data))
for item_index, item in enumerate(data, start=1):
if item_index == 1 or item_index == total_items or item_index % 250 == 0:
_refresh_loading_feedback(
detail_lines=[
_("loading_platform_name").format(platform_id),
_("loading_game_entries_progress").format(item_index, total_items),
_("loading_read_games_resolve_sources"),
],
force=True,
)
if isinstance(item, dict):
count += count_from_dict(item)
elif isinstance(item, (list, tuple)):
torrent_source = _extract_torrent_source(item)
if torrent_source is not None:
source_name, source_url = torrent_source
try:
count += _get_torrent_entry_count(
source_url,
display_label=source_name,
platform_id=platform_id,
allow_network_fetch=allow_torrent_manifest_fetch,
)
except Exception as exc:
logger.error(f"Erreur comptage torrent pour {platform_id} ({source_name or source_url}): {exc}")
elif len(item) > 0:
count += 1
elif isinstance(item, str):
count += 1
elif item is not None:
count += 1
elif isinstance(data, dict):
count += count_from_dict(data)
else:
logger.warning(f"Format de fichier jeux inattendu pour {platform_id}: {type(data)}")
_store_platform_game_count(platform_id, game_file, game_mtime_ns, game_size_bytes, count)
return count
def _refresh_loading_feedback(current_system: str | None = None, progress: float | None = None, detail_lines=None, force: bool = False):
"""Refresh the blocking startup loading screen with more granular details."""
try:
if current_system is not None:
config.current_loading_system = current_system
if progress is not None:
config.loading_progress = max(0.0, min(100.0, float(progress)))
if detail_lines is not None:
config.loading_detail_lines = [str(line) for line in detail_lines if line]
config.needs_redraw = True
if getattr(config, 'menu_state', '') != 'loading' or pygame is None:
return
now = time.time()
last_update = float(getattr(config, '_loading_feedback_last_update', 0.0) or 0.0)
if not force and (now - last_update) < 0.12:
return
config._loading_feedback_last_update = now
screen = pygame.display.get_surface()
if screen is None:
return
from display import draw_gradient, draw_loading_screen, draw_controls, THEME_COLORS
draw_gradient(screen, THEME_COLORS["background_top"], THEME_COLORS["background_bottom"])
draw_loading_screen(screen)
draw_controls(screen, config.menu_state, getattr(config, 'current_music_name', None), getattr(config, 'music_popup_start_time', 0))
pygame.display.flip()
pygame.event.pump()
except Exception as exc:
logger.debug(f"Impossible de rafraichir le loading screen: {exc}")
# Cache/process flags for extensions generation/loading
@@ -191,8 +633,8 @@ def parse_torrent_download_url(url: str | None) -> dict[str, str | int] | None:
}
def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list[dict[str, str | int]]:
torrent_data, _ = _bdecode(payload)
def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str, display_label: str | None = None, platform_id: str | None = None) -> 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")
@@ -204,9 +646,18 @@ def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list
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):
total_files = len(files)
resolved_label = display_label or root_name or Path(urllib.parse.urlparse(source_url).path).name or source_url
for file_index, file_entry in enumerate(files, start=1):
if not isinstance(file_entry, dict):
continue
if file_index == 1 or file_index == total_files or file_index % 25 == 0:
detail_lines = []
if platform_id:
detail_lines.append(_("loading_platform_name").format(platform_id))
detail_lines.append(_("loading_torrent_files_progress").format(file_index, total_files))
detail_lines.append(_("loading_torrent_manifest_analysis").format(resolved_label))
_refresh_loading_feedback(detail_lines=detail_lines, force=True)
path_parts = file_entry.get(b"path.utf-8") or file_entry.get(b"path") or []
if not isinstance(path_parts, list):
continue
@@ -247,7 +698,8 @@ def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list
return entries
def _get_torrent_entries(source_url: str) -> list[dict[str, str | int]]:
def _get_torrent_entries(source_url: str, display_label: str | None = None, platform_id: str | None = None) -> list[dict[str, str | int]]:
_load_persistent_torrent_manifest_cache()
cached = _torrent_manifest_cache.get(source_url)
if cached is not None:
return cached
@@ -259,8 +711,9 @@ def _get_torrent_entries(source_url: str) -> list[dict[str, str | int]]:
response = requests.get(source_url, headers=headers, timeout=30)
response.raise_for_status()
entries = _extract_torrent_entries_from_bytes(response.content, source_url)
entries = _extract_torrent_entries_from_bytes(response.content, source_url, display_label=display_label, platform_id=platform_id)
_torrent_manifest_cache[source_url] = entries
_save_persistent_torrent_manifest_cache()
return entries
@@ -296,14 +749,31 @@ def _expand_torrent_source(item, platform_id: str) -> list[tuple[str, None, str
source_name, source_url = source
try:
entries = _get_torrent_entries(source_url)
_refresh_loading_feedback(
detail_lines=[
_("loading_platform_name").format(platform_id),
_("loading_torrent_manifest_analysis").format(source_name or Path(urllib.parse.urlparse(source_url).path).name),
]
)
entries = _get_torrent_entries(source_url, display_label=source_name, platform_id=platform_id)
except Exception as exc:
label = source_name or source_url
logger.error(f"Erreur chargement torrent pour {platform_id} ({label}): {exc}")
return []
expanded: list[tuple[str, None, str | None]] = []
for entry in entries:
total_entries = max(1, len(entries))
display_label = source_name or Path(urllib.parse.urlparse(source_url).path).name or source_url
for position, entry in enumerate(entries, start=1):
if position == 1 or position == total_entries or position % 25 == 0:
_refresh_loading_feedback(
detail_lines=[
_("loading_platform_name").format(platform_id),
_("loading_torrent_files_progress").format(position, total_entries),
_("loading_torrent_manifest_analysis").format(display_label),
],
force=True,
)
game_name = str(entry.get("name") or "").strip()
if not game_name:
continue
@@ -1297,8 +1767,14 @@ def _get_dest_folder_name(platform_key: str) -> str:
# Fonction pour charger sources.json
def load_sources():
def load_sources(allow_torrent_manifest_fetch: bool | None = None):
try:
if allow_torrent_manifest_fetch is None:
allow_torrent_manifest_fetch = is_torrent_manifest_refresh_requested()
logger.debug(
"Chargement des sources (%s fetch manifest torrent)",
"avec" if allow_torrent_manifest_fetch else "sans",
)
sources = []
if os.path.exists(config.SOURCES_FILE):
with open(config.SOURCES_FILE, 'r', encoding='utf-8') as f:
@@ -1417,16 +1893,43 @@ def load_sources():
config.platform_dict_by_name = {d.get("platform_name", ""): d for d in config.platform_dicts}
except Exception:
config.platform_dict_by_name = {}
_load_persistent_platform_game_count_cache()
config.games_count = {}
for platform_name in config.platforms:
games = load_games(platform_name)
config.games_count[platform_name] = len(games)
total_platforms = max(1, len(config.platforms))
for index, platform_name in enumerate(config.platforms, start=1):
progress_value = 80.0 + ((index - 1) / total_platforms) * 19.0
_refresh_loading_feedback(
current_system=_("loading_load_systems"),
progress=progress_value,
detail_lines=[
_("loading_platform_counter").format(index, total_platforms),
platform_name,
_("loading_read_games_resolve_sources"),
],
force=True,
)
config.games_count[platform_name] = get_platform_game_count(
platform_name,
allow_torrent_manifest_fetch=allow_torrent_manifest_fetch,
)
_refresh_loading_feedback(
current_system=_("loading_load_systems"),
progress=80.0 + (index / total_platforms) * 19.0,
detail_lines=[
_("loading_platform_counter").format(index, total_platforms),
platform_name,
_("loading_read_games_resolve_sources"),
],
)
if config.games_count:
try:
summary = ", ".join([f"{name}: {count}" for name, count in config.games_count.items()])
logger.debug(f"Nombre de jeux par système: {summary}")
except Exception:
pass
if allow_torrent_manifest_fetch:
clear_torrent_manifest_refresh_request()
_refresh_loading_feedback(detail_lines=[], force=True)
return sources
except Exception as e:
logger.error(f"Erreur fusion systèmes + détection jeux: {e}")
@@ -1434,37 +1937,15 @@ def load_sources():
def load_games(platform_id:str) -> list[Game]:
try:
# Retrouver l'objet plateforme pour accéder éventuellement à 'folder'
platform_dict = None
for pd in config.platform_dicts:
if pd.get("platform_name") == platform_id or pd.get("platform") == platform_id:
platform_dict = pd
break
candidates = []
# 1. Nom exact
candidates.append(os.path.join(config.GAMES_FOLDER, f"{platform_id}.json"))
# 2. Nom normalisé
norm = normalize_platform_name(platform_id)
if norm and norm != platform_id:
candidates.append(os.path.join(config.GAMES_FOLDER, f"{norm}.json"))
# 3. Folder déclaré
if platform_dict:
folder_name = platform_dict.get("folder")
if folder_name:
candidates.append(os.path.join(config.GAMES_FOLDER, f"{folder_name}.json"))
game_file = None
for c in candidates:
if os.path.exists(c):
game_file = c
break
game_file, resolved_platform_dict, candidates = _resolve_game_file(platform_id)
if not game_file:
_games_cache.pop(platform_id, None)
logger.warning(f"Aucun fichier de jeux trouvé pour {platform_id} (candidats: {candidates})")
return []
game_mtime_ns = os.stat(game_file).st_mtime_ns
game_stat = os.stat(game_file)
game_mtime_ns = game_stat.st_mtime_ns
game_size_bytes = game_stat.st_size
cached_entry = _games_cache.get(platform_id)
if cached_entry and cached_entry.get("path") == game_file and cached_entry.get("mtime_ns") == game_mtime_ns:
return cached_entry["games"]
@@ -1490,7 +1971,18 @@ def load_games(platform_id:str) -> list[Game]:
normalized.append((str(name), url if isinstance(url, str) and url.strip() else None, str(size) if size else None))
if isinstance(data, list):
for item in data:
total_items = max(1, len(data))
for item_index, item in enumerate(data, start=1):
torrent_source = _extract_torrent_source(item)
if (item_index == 1 or item_index == total_items or item_index % 100 == 0) and torrent_source is None:
_refresh_loading_feedback(
detail_lines=[
_("loading_platform_name").format(platform_id),
_("loading_game_entries_progress").format(item_index, total_items),
_("loading_read_games_resolve_sources"),
],
force=True,
)
if isinstance(item, (list, tuple)):
torrent_rows = _expand_torrent_source(item, platform_id)
if torrent_rows is not None:
@@ -1526,6 +2018,7 @@ def load_games(platform_id:str) -> list[Game]:
"mtime_ns": game_mtime_ns,
"games": games_list,
}
_store_platform_game_count(platform_id, game_file, game_mtime_ns, game_size_bytes, len(games_list))
return games_list
except Exception as e:
_games_cache.pop(platform_id, None)
@@ -2137,18 +2630,9 @@ def extract_7z(archive_path, dest_dir, url):
try:
os.makedirs(dest_dir, exist_ok=True)
if config.OPERATING_SYSTEM == "Windows":
seven_z_cmd = config.SEVEN_Z_EXE
else:
seven_z_cmd = config.SEVEN_Z_LINUX
try:
if os.path.exists(seven_z_cmd) and not os.access(seven_z_cmd, os.X_OK):
logger.warning("7zz n'est pas exécutable, correction des permissions...")
os.chmod(seven_z_cmd, 0o755)
except Exception as e:
logger.error(f"Erreur lors de la vérification des permissions de 7zz: {e}")
seven_z_cmd = _resolve_7z_command()
if not os.path.exists(seven_z_cmd):
if not seven_z_cmd or (os.path.isabs(seven_z_cmd) and not os.path.exists(seven_z_cmd)):
return False, "7z non trouvé - vérifiez que 7z.exe (Windows) ou 7zz (Linux) est présent dans assets/progs"
# Capture état initial
@@ -2407,21 +2891,9 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
os.makedirs(game_folder_path, exist_ok=True)
try:
if config.OPERATING_SYSTEM == "Windows":
seven_z_cmd = config.SEVEN_Z_EXE
else:
seven_z_cmd = config.SEVEN_Z_LINUX
# Vérifier et corriger les permissions de 7zz sur Linux
try:
if os.path.exists(seven_z_cmd):
if not os.access(seven_z_cmd, os.X_OK):
logger.warning(f"7zz n'est pas exécutable, correction des permissions...")
os.chmod(seven_z_cmd, 0o755)
logger.info(f"Permissions corrigées pour {seven_z_cmd}")
else:
return False, f"7zz non trouvé: {seven_z_cmd}"
except Exception as e:
logger.error(f"Erreur lors de la vérification des permissions de 7zz: {e}")
seven_z_cmd = _resolve_7z_command()
if not seven_z_cmd or (os.path.isabs(seven_z_cmd) and not os.path.exists(seven_z_cmd)):
return False, f"7zz non trouvé: {config.SEVEN_Z_LINUX}"
extract_cmd = [seven_z_cmd, "x", decrypted_iso_path, f"-o{game_folder_path}", "-y"]
logger.debug(f"Commande d'extraction ISO: {' '.join(extract_cmd)}")

View File

@@ -1,3 +1,3 @@
{
"version": "2.6.1.6.1"
"version": "2.6.3.0"
}