Compare commits

..

11 Commits

Author SHA1 Message Date
skymike03
a7dad84108 ## v2.6.1.6.1 (2026.01.02)
- fix update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 22:47:29 +02:00
skymike03
c9f48d20dd ## v2.6.1.6 (2026.01.02)
- update 1fichier error message with free download bacause sometimes the host block download on free mode (only way to bypass is to get an api key / premium account)
2026-04-02 21:47:38 +02:00
skymike03
cd7795f70e v2.6.1.5.1 (2026.01.02)
- fix some torrent  handling for minerva FUTURE using. Games are available, but download torrent through RGSX is not available for now. So don't ask about dl ps2/ps3/gc/ds games , and if you try to download ,you will have a "maintenance" message
2026-04-02 18:44:55 +02:00
skymike03
6813a0bc3d v2.6.1.5 (2026.04.01)
- test implant torrent handling for minerva support (disabled for now)
2026-04-01 18:34:48 +02:00
skymike03
21b39c66b9 v2.6.1.4 (2026.03.30)
- Add browser-like headers for file downloads with debrids and enhance AllDebrid link handling
2026-03-30 21:12:59 +02:00
skymike03
42b2204aeb Reverted back to original version after test 2026-03-22 12:25:45 +01:00
skymike03
67a38c45aa ## v2.6.3.1.0 TEST (2026.03.22)
- Test discord auto release changelog
- Test
2026-03-22 12:20:46 +01:00
skymike03
893b73ecc5 Refactor Discord changelog notification step in release workflow 2026-03-22 12:09:32 +01:00
skymike03
5e1a684275 Enhance Discord notifications with changelog and bot details 2026-03-22 12:06:06 +01:00
skymike03
9226a818f3 v2.6.3.1 (test update) 2026-03-22 11:56:58 +01:00
skymike03
2fd1bcaf01 test discord 2026-03-22 11:55:58 +01:00
15 changed files with 1329 additions and 497 deletions

View File

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

View File

@@ -1050,7 +1050,8 @@ async def main():
if success:
toast_msg = f"[OK] {game_name}\n{_('download_completed') if _ else 'Download completed'}"
else:
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
del config.download_tasks[task_id]
@@ -1072,7 +1073,8 @@ async def main():
config.download_progress.clear()
config.pending_download = None
# Afficher un toast au lieu de changer de page
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
del config.download_tasks[task_id]
@@ -1111,7 +1113,8 @@ async def main():
if success:
toast_msg = f"[OK] {game_name}\n{_('download_completed') if _ else 'Download completed'}"
else:
toast_msg = f"[ERROR] {game_name}\n{_('download_failed') if _ else 'Download failed'}"
toast_body = message or (_('download_failed') if _ else 'Download failed')
toast_msg = f"[ERROR] {game_name}\n{toast_body}"
show_toast(toast_msg, 3000)
config.needs_redraw = True
logger.debug(f"[DOWNLOAD_TASK] Toast displayed after completion, task_id={task_id}")

Binary file not shown.

View File

@@ -9,8 +9,8 @@ from dataclasses import dataclass
@dataclass(slots=True)
class Game:
name: str
url: str
size: str
url: Optional[str]
size: Optional[str]
display_name: str # name withou file extension or platform prefix
regions: Optional[list[str]] = None
is_non_release: Optional[bool] = None
@@ -27,7 +27,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.1.3"
app_version = "2.6.1.6.1"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -225,6 +225,8 @@ PS3DEC_EXE = os.path.join(APP_FOLDER,"assets", "progs", "ps3dec_win.exe")
PS3DEC_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "ps3dec_linux")
SEVEN_Z_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "7zz")
SEVEN_Z_EXE = os.path.join(APP_FOLDER,"assets", "progs", "7z.exe")
ARIA2C_EXE = os.path.join(APP_FOLDER,"assets", "progs", "aria2c.exe")
ARIA2C_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "aria2c")
# Détection du système d'exploitation (une seule fois au démarrage)
OPERATING_SYSTEM = platform.system()

View File

@@ -19,7 +19,7 @@ from utils import (
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
move_files_to_directory, parse_torrent_download_url
)
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
@@ -40,6 +40,35 @@ logger = logging.getLogger(__name__)
ARCHIVE_EXTENSIONS = {'.zip', '.7z', '.rar', '.tar', '.gz', '.xz', '.bz2'}
def _notify_torrent_in_maintenance(game_name: str | None = None) -> None:
try:
message = _("popup_torrent_in_maintenance")
except Exception:
message = "torrent in maintence"
show_toast(message, 3000)
logger.info(f"Source torrent non telechargeable pour le moment: {game_name or 'unknown game'}")
def _has_download_url(url, game_name: str | None = None) -> bool:
if isinstance(url, str) and url.strip():
if parse_torrent_download_url(url) is not None:
_notify_torrent_in_maintenance(game_name)
config.needs_redraw = True
return False
return True
_notify_torrent_in_maintenance(game_name)
config.needs_redraw = True
return False
def _wrap_index(current_index: int, delta: int, item_count: int) -> int:
if item_count <= 0:
return 0
return (current_index + delta) % item_count
# Variables globales pour la répétition
key_states = {} # Dictionnaire pour suivre l'état des touches
@@ -559,9 +588,11 @@ def trigger_global_search_download(queue_only: bool = False) -> None:
game_name = result.get("game_name")
display_name = result.get("display_name") or get_clean_display_name(game_name, platform)
if not url or not platform or not game_name:
if not platform or not game_name:
logger.error(f"Resultat de recherche globale invalide: {result}")
return
if not _has_download_url(url, game_name):
return
pending_download = check_extension_before_download(url, platform, game_name)
if not pending_download:
@@ -1077,16 +1108,16 @@ def handle_controls(event, sources, joystick, screen):
else:
if is_input_matched(event, "up"):
if config.current_game > 0:
config.current_game -= 1
if games:
config.current_game = _wrap_index(config.current_game, -1, len(games))
update_key_state("up", True, event.type, event.key if event.type == pygame.KEYDOWN else
event.button if event.type == pygame.JOYBUTTONDOWN else
(event.axis, event.value) if event.type == pygame.JOYAXISMOTION else
event.value)
config.needs_redraw = True
elif is_input_matched(event, "down"):
if config.current_game < len(games) - 1:
config.current_game += 1
if games:
config.current_game = _wrap_index(config.current_game, 1, len(games))
update_key_state("down", True, event.type, event.key if event.type == pygame.KEYDOWN else
event.button if event.type == pygame.JOYBUTTONDOWN else
(event.axis, event.value) if event.type == pygame.JOYAXISMOTION else
@@ -1140,6 +1171,8 @@ def handle_controls(event, sources, joystick, screen):
url = game.url
game_name = game.name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
if not _has_download_url(url, game_name):
return action
pending_download = check_extension_before_download(url, platform, game_name)
if pending_download:
@@ -1320,8 +1353,8 @@ def handle_controls(event, sources, joystick, screen):
history = config.history
if is_input_matched(event, "up"):
# L'historique est inversé à l'affichage, donc UP descend dans l'index (incrément)
if config.current_history_item < len(history) - 1:
config.current_history_item += 1
if history:
config.current_history_item = _wrap_index(config.current_history_item, 1, len(history))
config.repeat_action = "up"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
@@ -1329,8 +1362,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
elif is_input_matched(event, "down"):
# L'historique est inversé à l'affichage, donc DOWN monte dans l'index (décrement)
if config.current_history_item > 0:
config.current_history_item -= 1
if history:
config.current_history_item = _wrap_index(config.current_history_item, -1, len(history))
config.repeat_action = "down"
config.repeat_start_time = current_time + REPEAT_DELAY
config.repeat_last_action = current_time
@@ -1719,7 +1752,7 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "history"
# Réinitialiser l'entrée et relancer
url = entry.get("url")
if url:
if _has_download_url(url, game_name):
# Mettre à jour le statut
entry["status"] = "Downloading"
entry["progress"] = 0
@@ -1733,6 +1766,9 @@ def handle_controls(event, sources, joystick, screen):
is_zip_non_supported = pending_download[3] if len(pending_download) > 3 else False
if is_1fichier_url(url):
ensure_download_provider_keys(False)
if missing_all_provider_keys():
logger.warning("Aucune clé API - Mode gratuit 1fichier sera utilisé (attente requise)")
task = asyncio.create_task(download_from_1fichier(url, platform, game_name, is_zip_non_supported, task_id))
else:
task = asyncio.create_task(download_rom(url, platform, game_name, is_zip_non_supported, task_id))
@@ -3686,6 +3722,10 @@ def handle_controls(event, sources, joystick, screen):
game_name = games[config.current_game].name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")
if not _has_download_url(url, game_name):
config.confirm_press_start_time = 0
config.confirm_long_press_triggered = False
return action
# Vérifier d'abord l'extension avant d'ajouter à l'historique
if is_1fichier_url(url):
@@ -3818,6 +3858,10 @@ def handle_controls(event, sources, joystick, screen):
game_name = games[config.current_game].name
platform = config.platforms[config.current_platform]["name"] if isinstance(config.platforms[config.current_platform], dict) else config.platforms[config.current_platform]
logger.debug(f"Appui court sur confirm ({press_duration}ms), téléchargement pour {game_name}, URL: {url}")
if not _has_download_url(url, game_name):
config.confirm_press_start_time = 0
config.confirm_long_press_triggered = False
return action
# Vérifier d'abord l'extension avant d'ajouter à l'historique
if is_1fichier_url(url):

View File

@@ -17,6 +17,7 @@
"error_no_internet": "Keine Internetverbindung. Überprüfe dein Netzwerk.",
"error_api_key": "Achtung, du musst deinen API-Schlüssel (nur Premium) in der Datei {0} eingeben",
"error_invalid_download_data": "Ungültige Downloaddaten",
"popup_torrent_in_maintenance": "Torrent in Wartung, bitte warten",
"error_delete_sources": "Fehler beim Löschen der Datei systems_list.json oder Ordner",
"platform_no_platform": "Keine Plattform",
"platform_page": "Seite {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Kostenloser Modus] Formular wird gesendet...",
"free_mode_link_found": "[Kostenloser Modus] Link gefunden: {0}...",
"free_mode_completed": "[Kostenloser Modus] Abgeschlossen: {0}",
"free_mode_guest_slots_unavailable": "1fichier: Der kostenlose Gast-Download ist vorübergehend nicht verfügbar (alle Slots sind belegt). Bitte versuchen Sie es später erneut.",
"free_mode_unavailable_in_app": "1fichier: Dieser Download ist derzeit in der Anwendung nicht verfügbar. Bitte versuchen Sie es später erneut.",
"free_mode_premium_advice": "Für unbegrenzte Downloads jederzeit und mit voller Geschwindigkeit benötigen Sie ein Premium-Konto oder einen Debrid-Dienst und müssen dessen API-Schlüssel in RGSX eintragen.",
"download_status": "{0}: {1}",
"download_canceled": "Download vom Benutzer abgebrochen.",
"download_removed_from_queue": "Aus der Download-Warteschlange entfernt",

View File

@@ -17,6 +17,7 @@
"error_no_internet": "No Internet connection. Check your network.",
"error_api_key": "Please enter your API key (premium only) in the file {0}",
"error_invalid_download_data": "Invalid download data",
"popup_torrent_in_maintenance": "Torrent under maintenance, please wait",
"error_delete_sources": "Error deleting systems_list.json file or folders",
"platform_no_platform": "No platform",
"platform_page": "Page {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Free mode] Submitting form...",
"free_mode_link_found": "[Free mode] Link found: {0}...",
"free_mode_completed": "[Free mode] Completed: {0}",
"free_mode_guest_slots_unavailable": "1fichier: free guest download is temporarily unavailable (all slots are currently in use). Please try again later.",
"free_mode_unavailable_in_app": "1fichier: this download is not available in the application right now. Please try again later.",
"free_mode_premium_advice": "For unlimited, on-demand, full-speed downloads, you need a premium account or debrid service and must enter its API key in RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download canceled by user.",
"download_removed_from_queue": "Removed from download queue",

View File

@@ -17,6 +17,7 @@
"error_no_internet": "Sin conexión a Internet. Verifica tu red.",
"error_api_key": "Atención, debes ingresar tu clave API (solo premium) en el archivo {0}",
"error_invalid_download_data": "Datos de descarga no válidos",
"popup_torrent_in_maintenance": "Torrent en mantenimiento, por favor espere",
"error_delete_sources": "Error al eliminar el archivo systems_list.json o carpetas",
"platform_no_platform": "Ninguna plataforma",
"platform_page": "Página {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Modo gratuito] Enviando formulario...",
"free_mode_link_found": "[Modo gratuito] Enlace encontrado: {0}...",
"free_mode_completed": "[Modo gratuito] Completado: {0}",
"free_mode_guest_slots_unavailable": "1fichier: la descarga gratuita como invitado no está disponible temporalmente (todos los cupos están ocupados). Inténtelo de nuevo más tarde.",
"free_mode_unavailable_in_app": "1fichier: esta descarga no está disponible en la aplicación en este momento. Inténtelo de nuevo más tarde.",
"free_mode_premium_advice": "Para descargar de forma ilimitada, cuando quiera y a máxima velocidad, necesita una cuenta premium o un desbridizador y debe introducir su clave API en RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Descarga cancelada por el usuario.",
"download_removed_from_queue": "Eliminado de la cola de descarga",

View File

@@ -17,6 +17,7 @@
"error_no_internet": "Pas de connexion Internet. Vérifiez votre réseau.",
"error_api_key": "Attention il faut renseigner sa clé API (premium only) dans le fichier {0}",
"error_invalid_download_data": "Données de téléchargement invalides",
"popup_torrent_in_maintenance": "Torrent en maintenance, veuillez patienter",
"error_delete_sources": "Erreur lors de la suppression du fichier systems_list.json ou dossiers",
"platform_no_platform": "Aucune plateforme",
"platform_page": "Page {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Mode gratuit] Soumission formulaire...",
"free_mode_link_found": "[Mode gratuit] Lien trouvé: {0}...",
"free_mode_completed": "[Mode gratuit] Terminé: {0}",
"free_mode_guest_slots_unavailable": "1fichier : le téléchargement gratuit invité est temporairement indisponible (tous les créneaux sont occupés). Réessayez plus tard.",
"free_mode_unavailable_in_app": "1fichier : ce téléchargement n'est pas disponible dans l'application pour le moment. Réessayez plus tard.",
"free_mode_premium_advice": "Pour télécharger de manière illimitée, quand vous voulez et à pleine vitesse, vous devez obtenir un compte premium ou un débrideur et entrer votre clé API dans RGSX.",
"download_status": "{0} : {1}",
"download_canceled": "Téléchargement annulé par l'utilisateur.",
"download_removed_from_queue": "Retiré de la file de téléchargement",

View File

@@ -17,6 +17,7 @@
"error_no_internet": "Nessuna connessione Internet. Controlla la rete.",
"error_api_key": "Inserisci la tua API key (solo premium) nel file {0}",
"error_invalid_download_data": "Dati di download non validi",
"popup_torrent_in_maintenance": "Torrent in manutenzione, attendere prego",
"error_delete_sources": "Errore nell'eliminazione del file systems_list.json o delle cartelle",
"platform_no_platform": "Nessuna piattaforma",
"platform_page": "Pagina {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Modalità gratuita] Invio modulo...",
"free_mode_link_found": "[Modalità gratuita] Link trovato: {0}...",
"free_mode_completed": "[Modalità gratuita] Completato: {0}",
"free_mode_guest_slots_unavailable": "1fichier: il download gratuito come ospite non è temporaneamente disponibile (tutti gli slot sono occupati). Riprova più tardi.",
"free_mode_unavailable_in_app": "1fichier: questo download non è disponibile nell'applicazione in questo momento. Riprova più tardi.",
"free_mode_premium_advice": "Per scaricare senza limiti, quando vuoi e alla massima velocità, hai bisogno di un account premium o di un servizio debrid e devi inserire la sua chiave API in RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download annullato dall'utente.",
"download_removed_from_queue": "Rimosso dalla coda di download",

View File

@@ -17,6 +17,7 @@
"error_no_internet": "Sem conexão com a Internet. Verifique sua rede.",
"error_api_key": "Insira sua chave API (somente premium) no arquivo {0}",
"error_invalid_download_data": "Dados de download inválidos",
"popup_torrent_in_maintenance": "Torrent em manutenção, aguarde",
"error_delete_sources": "Erro ao deletar arquivo sources.json ou pastas",
"platform_no_platform": "Sem plataforma",
"platform_page": "Página {0}/{1}",
@@ -46,6 +47,9 @@
"free_mode_submitting": "[Modo gratuito] Enviando formulário...",
"free_mode_link_found": "[Modo gratuito] Link encontrado: {0}...",
"free_mode_completed": "[Modo gratuito] Concluído: {0}",
"free_mode_guest_slots_unavailable": "1fichier: o download gratuito como convidado está temporariamente indisponível (todos os slots estão ocupados). Tente novamente mais tarde.",
"free_mode_unavailable_in_app": "1fichier: este download não está disponível no aplicativo no momento. Tente novamente mais tarde.",
"free_mode_premium_advice": "Para baixar sem limites, quando quiser e em velocidade máxima, você precisa de uma conta premium ou de um serviço debrid e deve inserir a chave API no RGSX.",
"download_status": "{0}: {1}",
"download_canceled": "Download cancelado pelo usuário.",
"download_removed_from_queue": "Removido da fila de download",

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
import config
from history import load_history, save_history
from utils import load_sources, load_games, extract_data, get_clean_display_name
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
@@ -1161,9 +1161,18 @@ class RGSXHandler(BaseHTTPRequestHandler):
game_url = game.url
if not game_url:
torrent_message = TRANSLATIONS.get('popup_torrent_in_maintenance', 'torrent in maintence')
self._send_json({
'success': False,
'error': 'URL de téléchargement non disponible'
'error': torrent_message
}, status=400)
return
if parse_torrent_download_url(game_url) is not None:
torrent_message = TRANSLATIONS.get('popup_torrent_in_maintenance', 'torrent in maintence')
self._send_json({
'success': False,
'error': torrent_message
}, status=400)
return

View File

@@ -7,6 +7,7 @@ import os
import logging
import platform
import subprocess
import urllib.parse
import config
from config import HEADLESS, Game
try:
@@ -62,6 +63,8 @@ def get_clean_display_name(raw_name, platform_id=None):
return display_name.strip(" -_/")
_games_cache = {}
_torrent_manifest_cache = {}
_TORRENT_DOWNLOAD_SCHEME = "rgsx+torrent"
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
@@ -79,6 +82,238 @@ unavailable_systems = []
# Cache/process flags for extensions generation/loading
def _format_size_bytes(size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} B"
value = float(size_bytes)
for unit in ["KB", "MB", "GB", "TB", "PB"]:
value /= 1024.0
if value < 1024.0 or unit == "PB":
return f"{value:.2f} {unit}"
return f"{size_bytes} B"
def _decode_bencode_text(value) -> str:
if isinstance(value, bytes):
for encoding in ("utf-8", "utf-8-sig", "latin-1"):
try:
return value.decode(encoding)
except UnicodeDecodeError:
continue
return value.decode("utf-8", errors="replace")
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 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 is_torrent_download_url(url: str | None) -> bool:
if not url or not isinstance(url, str):
return False
try:
return urllib.parse.urlparse(url).scheme == _TORRENT_DOWNLOAD_SCHEME
except Exception:
return False
def parse_torrent_download_url(url: str | None) -> dict[str, str | int] | None:
if not is_torrent_download_url(url):
return None
parsed = urllib.parse.urlparse(str(url))
query = urllib.parse.parse_qs(parsed.query)
source_url = (query.get("source") or [""])[0].strip()
relative_path = (query.get("path") or [""])[0].strip()
try:
file_index = int((query.get("index") or ["1"])[0])
except (TypeError, ValueError):
file_index = 1
try:
size_bytes = int((query.get("size") or ["0"])[0])
except (TypeError, ValueError):
size_bytes = 0
if not source_url or not relative_path:
return None
return {
"source_url": source_url,
"file_index": max(1, file_index),
"relative_path": relative_path,
"size_bytes": max(0, size_bytes),
}
def _extract_torrent_entries_from_bytes(payload: bytes, source_url: str) -> list[dict[str, str | int]]:
torrent_data, _ = _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([p for p in [root_name, full_path] if p])
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 = {}
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 _get_torrent_entries(source_url: str) -> list[dict[str, str | int]]:
cached = _torrent_manifest_cache.get(source_url)
if cached is not None:
return cached
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": "*/*",
}
response = requests.get(source_url, headers=headers, timeout=30)
response.raise_for_status()
entries = _extract_torrent_entries_from_bytes(response.content, source_url)
_torrent_manifest_cache[source_url] = entries
return entries
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 _expand_torrent_source(item, platform_id: str) -> list[tuple[str, None, str | None]] | None:
source = _extract_torrent_source(item)
if not source:
return None
source_name, source_url = source
try:
entries = _get_torrent_entries(source_url)
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:
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)
download_url = build_torrent_download_url(source_url, file_index, relative_path, size_bytes)
expanded.append((game_name, download_url, _format_size_bytes(size_bytes) if size_bytes > 0 else None))
return expanded
def restart_application(delay_ms: int = 2000):
"""Schedule a restart with a visible popup; actual restart happens in the main loop.
@@ -1244,6 +1479,10 @@ def load_games(platform_id:str) -> list[Game]:
normalized = [] # (name, url, size)
def extract_from_dict(d):
torrent_rows = _expand_torrent_source(d, platform_id)
if torrent_rows is not None:
normalized.extend(torrent_rows)
return
name = d.get('game_name') or d.get('name') or d.get('title') or d.get('game')
url = d.get('url') or d.get('download') or d.get('link') or d.get('href')
size = d.get('size') or d.get('filesize') or d.get('length')
@@ -1253,6 +1492,10 @@ def load_games(platform_id:str) -> list[Game]:
if isinstance(data, list):
for item in data:
if isinstance(item, (list, tuple)):
torrent_rows = _expand_torrent_source(item, platform_id)
if torrent_rows is not None:
normalized.extend(torrent_rows)
continue
if len(item) == 0:
continue
name = str(item[0])

View File

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