forked from Mirrors/RGSX
1018 lines
47 KiB
Python
1018 lines
47 KiB
Python
import shutil
|
|
import pygame # type: ignore
|
|
import re
|
|
import json
|
|
import os
|
|
import logging
|
|
import platform
|
|
import subprocess
|
|
import config
|
|
import threading
|
|
from rgsx_settings import load_rgsx_settings, save_rgsx_settings
|
|
import zipfile
|
|
import time
|
|
import random
|
|
import config
|
|
from history import save_history
|
|
from language import _
|
|
from datetime import datetime
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# Désactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP
|
|
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
logging.getLogger("requests").setLevel(logging.WARNING)
|
|
|
|
# Liste globale pour stocker les systèmes avec une erreur 404
|
|
unavailable_systems = []
|
|
|
|
|
|
# Détection système non-PC
|
|
def detect_non_pc():
|
|
arch = platform.machine()
|
|
try:
|
|
result = subprocess.run(["batocera-es-swissknife", "--arch"], capture_output=True, text=True, timeout=2)
|
|
if result.returncode == 0:
|
|
arch = result.stdout.strip()
|
|
#logger.debug(f"Architecture via batocera-es-swissknife: {arch}")
|
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
logger.debug(f"batocera-es-swissknife non disponible, utilisation de platform.machine(): {arch}")
|
|
|
|
is_non_pc = arch not in ["x86_64", "amd64", "AMD64"]
|
|
logger.debug(f"Système détecté: {platform.system()}, architecture: {arch}, is_non_pc={is_non_pc}")
|
|
return is_non_pc
|
|
|
|
|
|
# Fonction pour charger le fichier JSON des extensions supportées
|
|
def load_extensions_json():
|
|
"""Charge le fichier JSON contenant les extensions supportées."""
|
|
try:
|
|
with open(config.JSON_EXTENSIONS, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la lecture de {config.JSON_EXTENSIONS}: {e}")
|
|
return []
|
|
|
|
def check_extension_before_download(url, platform, game_name):
|
|
"""Vérifie l'extension avant de lancer le téléchargement et retourne un tuple de 4 éléments."""
|
|
try:
|
|
sanitized_name = sanitize_filename(game_name)
|
|
extensions_data = load_extensions_json()
|
|
if not extensions_data:
|
|
logger.error(f"Fichier {config.JSON_EXTENSIONS} vide ou introuvable")
|
|
return None
|
|
|
|
is_supported = is_extension_supported(sanitized_name, platform, extensions_data)
|
|
extension = os.path.splitext(sanitized_name)[1].lower()
|
|
is_archive = extension in (".zip", ".rar")
|
|
|
|
if is_supported:
|
|
logger.debug(f"L'extension de {sanitized_name} est supportée pour {platform}")
|
|
return (url, platform, game_name, False)
|
|
elif is_archive:
|
|
logger.debug(f"Archive {extension.upper()} détectée pour {sanitized_name}, extraction automatique prévue")
|
|
return (url, platform, game_name, True)
|
|
else:
|
|
logger.debug(f"Extension non supportée ({extension}) pour {sanitized_name}, avertissement affiché")
|
|
return (url, platform, game_name, False)
|
|
except Exception as e:
|
|
logger.error(f"Erreur vérification extension {url}: {str(e)}")
|
|
return None
|
|
|
|
# Fonction pour vérifier si l'extension est supportée pour une plateforme donnée
|
|
def is_extension_supported(filename, platform_key, extensions_data):
|
|
"""Vérifie si l'extension du fichier est supportée pour la plateforme donnée.
|
|
platform_key correspond maintenant à l'identifiant utilisé dans config.platforms (platform_name)."""
|
|
extension = os.path.splitext(filename)[1].lower()
|
|
|
|
dest_dir = None
|
|
for platform_dict in config.platform_dicts:
|
|
# Nouveau schéma: platform_name
|
|
if platform_dict.get("platform_name") == platform_key:
|
|
dest_dir = os.path.join(config.ROMS_FOLDER, platform_dict.get("folder"))
|
|
break
|
|
|
|
if not dest_dir:
|
|
logger.warning(f"Aucun dossier 'folder' trouvé pour la plateforme {platform}")
|
|
dest_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), platform)
|
|
|
|
dest_folder_name = os.path.basename(dest_dir)
|
|
for i, system in enumerate(extensions_data):
|
|
if system["folder"] == dest_folder_name:
|
|
result = extension in system["extensions"]
|
|
return result
|
|
|
|
logger.warning(f"Aucun système trouvé pour le dossier {dest_dir}")
|
|
return False
|
|
|
|
|
|
|
|
|
|
# Fonction pour charger sources.json
|
|
def load_sources():
|
|
"""Charge la liste de base depuis systems_list.json puis ajoute les plateformes
|
|
implicites (fichiers de jeux *.json non listés) à la fin sans réordonner.
|
|
|
|
Schéma attendu dans systems_list.json:
|
|
[ {"platform_name": str, "folder": str, (optionnel) "platform_image"|"system_image": str }, ... ]
|
|
|
|
Règles d'ajout automatique:
|
|
- Chaque fichier <nom>.json dans config.GAMES_FOLDER est candidat.
|
|
- Si <nom> ne correspond à aucune entrée existante (case sensitive simple),
|
|
on ajoute {"platform_name": <nom>, "folder": <nom>} en fin de liste.
|
|
- Aucun tri n'est appliqué pour préserver l'ordre original défini par l'utilisateur.
|
|
"""
|
|
try:
|
|
# Détection legacy: si sources.json (ancien format) existe encore, déclencher redownload automatique
|
|
legacy_path = os.path.join(config.SAVE_FOLDER, "sources.json")
|
|
if os.path.exists(legacy_path):
|
|
logger.warning("Ancien fichier sources.json détecté: déclenchement redownload cache jeux")
|
|
try:
|
|
# Supprimer ancien cache et forcer redémarrage logique comme dans l'option de menu
|
|
if os.path.exists(config.SOURCES_FILE):
|
|
try:
|
|
os.remove(config.SOURCES_FILE)
|
|
except Exception:
|
|
pass
|
|
if os.path.exists(config.GAMES_FOLDER):
|
|
shutil.rmtree(config.GAMES_FOLDER, ignore_errors=True)
|
|
if os.path.exists(config.IMAGES_FOLDER):
|
|
shutil.rmtree(config.IMAGES_FOLDER, ignore_errors=True)
|
|
# Renommer legacy pour éviter boucle
|
|
try:
|
|
os.replace(legacy_path, legacy_path + ".bak")
|
|
except Exception:
|
|
pass
|
|
# Préparer popup redémarrage si contexte graphique chargé
|
|
config.popup_message = _("popup_redownload_success") if hasattr(config, 'popup_message') else "Cache jeux réinitialisé"
|
|
config.popup_timer = 5000 if hasattr(config, 'popup_timer') else 0
|
|
config.menu_state = "restart_popup" if hasattr(config, 'menu_state') else getattr(config, 'menu_state', 'platform')
|
|
config.needs_redraw = True
|
|
logger.info("Redownload cache déclenché automatiquement (legacy sources.json)")
|
|
return [] # On sort pour laisser le processus de redémarrage gérer le rechargement
|
|
except Exception as e:
|
|
logger.error(f"Échec redownload automatique depuis legacy sources.json: {e}")
|
|
sources = []
|
|
if os.path.exists(config.SOURCES_FILE):
|
|
with open(config.SOURCES_FILE, 'r', encoding='utf-8') as f:
|
|
sources = json.load(f)
|
|
if not isinstance(sources, list):
|
|
logger.error("systems_list.json n'est pas une liste JSON valide")
|
|
sources = []
|
|
else:
|
|
logger.warning(f"Fichier systems_list absent: {config.SOURCES_FILE}")
|
|
|
|
# S'assurer que chaque entrée possède la clé platform_image (vide si absente)
|
|
for s in sources:
|
|
if "platform_image" not in s:
|
|
# Supporter ancienne clé system_image -> platform_image si présente
|
|
legacy = s.pop("system_image", "") if isinstance(s, dict) else ""
|
|
s["platform_image"] = legacy or ""
|
|
# Normaliser clé dossier -> folder si besoin (legacy francophone)
|
|
if isinstance(s, dict) and "folder" not in s:
|
|
legacy_folder = s.get("dossier") or s.get("folder_name")
|
|
if legacy_folder:
|
|
s["folder"] = legacy_folder
|
|
|
|
existing_names = {s.get("platform_name", "") for s in sources}
|
|
added = []
|
|
if os.path.isdir(config.GAMES_FOLDER):
|
|
for fname in sorted(os.listdir(config.GAMES_FOLDER)):
|
|
if not fname.lower().endswith('.json'):
|
|
continue
|
|
pname = os.path.splitext(fname)[0]
|
|
if not pname or pname in existing_names:
|
|
continue
|
|
new_entry = {"platform_name": pname, "folder": pname, "platform_image": ""}
|
|
sources.append(new_entry)
|
|
added.append(pname)
|
|
existing_names.add(pname)
|
|
|
|
# Déterminer les plateformes orphelines (fichier manquant)
|
|
existing_files = set()
|
|
if os.path.isdir(config.GAMES_FOLDER):
|
|
existing_files = {os.path.splitext(f)[0] for f in os.listdir(config.GAMES_FOLDER) if f.lower().endswith('.json')}
|
|
removed = []
|
|
filtered_sources = []
|
|
for entry in sources:
|
|
pname = entry.get("platform_name", "")
|
|
# Garder seulement si un fichier existe
|
|
if pname in existing_files:
|
|
filtered_sources.append(entry)
|
|
else:
|
|
# Ne retirer que si ce n'est pas un nom vide
|
|
if pname:
|
|
removed.append(pname)
|
|
sources = filtered_sources
|
|
|
|
if added:
|
|
logger.info(f"Plateformes ajoutées automatiquement: {', '.join(added)}")
|
|
if removed:
|
|
logger.info(f"Plateformes supprimées (fichiers absents): {', '.join(removed)}")
|
|
|
|
# Persister si modifications (ajouts ou suppressions)
|
|
if added or removed:
|
|
try:
|
|
# Pas de tri avant persistance: conserver ordre d'origine + ajouts fins
|
|
os.makedirs(os.path.dirname(config.SOURCES_FILE), exist_ok=True)
|
|
with open(config.SOURCES_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump(sources, f, ensure_ascii=False, indent=2)
|
|
logger.info("systems_list.json mis à jour (ajouts/suppressions, ordre conservé)")
|
|
except Exception as e:
|
|
logger.error(f"Échec écriture systems_list.json après maj auto: {e}")
|
|
|
|
# Pour l'affichage on veut un tri alphabétique sans toucher l'ordre de persistance
|
|
sorted_for_display = sorted(sources, key=lambda x: x.get("platform_name", "").lower())
|
|
|
|
# Construire structures config: platform_dicts = ordre fichier, platforms = tri (avec filtre masqués)
|
|
config.platform_dicts = sources # ordre brut fichier
|
|
settings = load_rgsx_settings()
|
|
hidden = set(settings.get("hidden_platforms", [])) if isinstance(settings, dict) else set()
|
|
all_sorted_names = [s.get("platform_name", "") for s in sorted_for_display]
|
|
visible_names = [n for n in all_sorted_names if n and n not in hidden]
|
|
config.platforms = visible_names
|
|
config.platform_names = {p: p for p in config.platforms}
|
|
# Nouveau mapping par nom pour éviter décalages index après tri d'affichage
|
|
try:
|
|
config.platform_dict_by_name = {d.get("platform_name", ""): d for d in config.platform_dicts}
|
|
except Exception:
|
|
config.platform_dict_by_name = {}
|
|
config.games_count = {}
|
|
for platform_name in config.platforms:
|
|
games = load_games(platform_name)
|
|
config.games_count[platform_name] = len(games)
|
|
|
|
write_unavailable_systems()
|
|
return sources
|
|
except Exception as e:
|
|
logger.error(f"Erreur fusion systèmes + détection jeux: {e}")
|
|
return []
|
|
|
|
def load_games(platform_id):
|
|
"""Charge la liste des jeux pour une plateforme.
|
|
|
|
Recherche des fichiers dans config.GAMES_FOLDER selon l'ordre:
|
|
1. <platform_id>.json (nom exact)
|
|
2. normalisé: normalize_platform_name(platform_id).json
|
|
3. <folder>.json (valeur 'folder' du dictionnaire de plateforme)
|
|
|
|
Format accepté du fichier JSON:
|
|
- liste de listes/tuples: [ [name, url], [name, url, size], ... ]
|
|
- liste de chaînes: [ name1, name2, ... ]
|
|
- liste de dicts: [ {"game_name"|"name"|"title": str, "url"|"download"|"link"|"href": str?, "size"|"filesize"|"length": str?}, ... ]
|
|
- dict contenant une clé 'games' avec un des formats ci-dessus
|
|
|
|
Retourne une liste normalisée de tuples (name, url_or_None, size_or_None).
|
|
"""
|
|
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
|
|
if not game_file:
|
|
logger.warning(f"Aucun fichier de jeux trouvé pour {platform_id} (candidats: {candidates})")
|
|
return []
|
|
|
|
with open(game_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
# Si dict avec clé 'games'
|
|
if isinstance(data, dict) and 'games' in data:
|
|
data = data['games']
|
|
|
|
normalized = [] # (name, url, size)
|
|
|
|
def extract_from_dict(d):
|
|
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')
|
|
if name:
|
|
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:
|
|
if isinstance(item, (list, tuple)):
|
|
if len(item) == 0:
|
|
continue
|
|
name = str(item[0])
|
|
url = item[1] if len(item) > 1 and isinstance(item[1], str) and item[1].strip() else None
|
|
size = item[2] if len(item) > 2 and isinstance(item[2], str) and item[2].strip() else None
|
|
normalized.append((name, url, size))
|
|
elif isinstance(item, dict):
|
|
extract_from_dict(item)
|
|
elif isinstance(item, str):
|
|
normalized.append((item, None, None))
|
|
else:
|
|
normalized.append((str(item), None, None))
|
|
elif isinstance(data, dict): # dict sans 'games'
|
|
extract_from_dict(data)
|
|
else:
|
|
logger.warning(f"Format de fichier jeux inattendu pour {platform_id}: {type(data)}")
|
|
|
|
logger.debug(f"Jeux chargés pour {platform_id} depuis {os.path.basename(game_file)}: {len(normalized)} entrées")
|
|
return normalized
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement des jeux pour {platform_id}: {e}")
|
|
return []
|
|
|
|
def write_unavailable_systems():
|
|
"""Écrit la liste des systèmes avec une erreur 404 dans un fichier texte."""
|
|
if not unavailable_systems:
|
|
logger.debug("Aucun système avec des liens HS, rien à écrire dans le fichier.")
|
|
return
|
|
|
|
# Formater la date et l'heure pour le nom du fichier
|
|
current_time = datetime.now()
|
|
timestamp = current_time.strftime("%d-%m-%Y-%H-%M")
|
|
log_file = os.path.join(config.log_dir, f"systemes_unavailable_{timestamp}.txt")
|
|
|
|
try:
|
|
# Créer le répertoire s'il n'existe pas
|
|
os.makedirs(config.log_dir, exist_ok=True)
|
|
|
|
# Écrire les systèmes dans le fichier
|
|
with open(log_file, 'w', encoding='utf-8') as f:
|
|
f.write("Systèmes avec une erreur 404 :\n")
|
|
for system in unavailable_systems:
|
|
f.write(f"{system}\n")
|
|
logger.debug(f"Fichier écrit : {log_file} avec {len(unavailable_systems)} systèmes")
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de l'écriture du fichier {log_file} : {str(e)}")
|
|
|
|
def truncate_text_middle(text, font, max_width, is_filename=True):
|
|
"""Tronque le texte en insérant '...' au milieu, en préservant le début et la fin.
|
|
Si is_filename=False, ne supprime pas l'extension."""
|
|
# Supprimer l'extension uniquement si is_filename est True
|
|
if is_filename:
|
|
text = text.rsplit('.', 1)[0] if '.' in text else text
|
|
text_width = font.size(text)[0]
|
|
if text_width <= max_width:
|
|
return text
|
|
ellipsis = "..."
|
|
ellipsis_width = font.size(ellipsis)[0]
|
|
max_text_width = max_width - ellipsis_width
|
|
if max_text_width <= 0:
|
|
return ellipsis
|
|
|
|
# Diviser la largeur disponible entre début et fin, en priorisant la fin
|
|
chars = list(text)
|
|
left = []
|
|
right = []
|
|
left_width = 0
|
|
right_width = 0
|
|
left_idx = 0
|
|
right_idx = len(chars) - 1
|
|
|
|
# Préserver plus de caractères à droite pour garder le '%'
|
|
while left_idx <= right_idx and (left_width + right_width) < max_text_width:
|
|
# Ajouter à droite en priorité
|
|
if left_idx <= right_idx:
|
|
right.insert(0, chars[right_idx])
|
|
right_width = font.size(''.join(right))[0]
|
|
if left_width + right_width > max_text_width:
|
|
right.pop(0)
|
|
break
|
|
right_idx -= 1
|
|
# Ajouter à gauche seulement si nécessaire
|
|
if left_idx < right_idx:
|
|
left.append(chars[left_idx])
|
|
left_width = font.size(''.join(left))[0]
|
|
if left_width + right_width > max_text_width:
|
|
left.pop()
|
|
break
|
|
left_idx += 1
|
|
|
|
# Reculer jusqu'à un espace pour éviter de couper un mot
|
|
while left and left[-1] != ' ' and left_width + right_width > max_text_width:
|
|
left.pop()
|
|
left_width = font.size(''.join(left))[0] if left else 0
|
|
while right and right[0] != ' ' and left_width + right_width > max_text_width:
|
|
right.pop(0)
|
|
right_width = font.size(''.join(right))[0] if right else 0
|
|
|
|
return ''.join(left).rstrip() + ellipsis + ''.join(right).lstrip()
|
|
|
|
def truncate_text_end(text, font, max_width):
|
|
"""Tronque le texte à la fin pour qu'il tienne dans max_width avec la police donnée."""
|
|
if not isinstance(text, str):
|
|
logger.error(f"Texte non valide: {text}")
|
|
return ""
|
|
if not isinstance(font, pygame.font.Font):
|
|
logger.error("Police non valide dans truncate_text_end")
|
|
return text # Retourne le texte brut si la police est invalide
|
|
|
|
try:
|
|
if font.size(text)[0] <= max_width:
|
|
return text
|
|
|
|
truncated = text
|
|
while len(truncated) > 0 and font.size(truncated + "...")[0] > max_width:
|
|
truncated = truncated[:-1]
|
|
return truncated + "..." if len(truncated) < len(text) else text
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du rendu du texte '{text}': {str(e)}")
|
|
return text # Retourne le texte brut en cas d'erreur
|
|
|
|
def sanitize_filename(name):
|
|
"""Sanitise les noms de fichiers en remplaçant les caractères interdits."""
|
|
return re.sub(r'[<>:"/\/\\|?*]', '_', name).strip()
|
|
|
|
def wrap_text(text, font, max_width):
|
|
"""Divise le texte en lignes pour respecter la largeur maximale, en coupant les mots longs si nécessaire."""
|
|
if not isinstance(text, str):
|
|
text = str(text) if text is not None else ""
|
|
|
|
words = text.split(' ')
|
|
lines = []
|
|
current_line = ''
|
|
|
|
for word in words:
|
|
# Si le mot seul dépasse max_width, le couper caractère par caractère
|
|
if font.render(word, True, (255, 255, 255)).get_width() > max_width:
|
|
temp_line = current_line
|
|
for char in word:
|
|
test_line = temp_line + (' ' if temp_line else '') + char
|
|
test_surface = font.render(test_line, True, (255, 255, 255))
|
|
if test_surface.get_width() <= max_width:
|
|
temp_line = test_line
|
|
else:
|
|
if temp_line:
|
|
lines.append(temp_line)
|
|
temp_line = char
|
|
current_line = temp_line
|
|
else:
|
|
# Comportement standard pour les mots normaux
|
|
test_line = current_line + (' ' if current_line else '') + word
|
|
test_surface = font.render(test_line, True, (255, 255, 255))
|
|
if test_surface.get_width() <= max_width:
|
|
current_line = test_line
|
|
else:
|
|
if current_line:
|
|
lines.append(current_line)
|
|
current_line = word
|
|
|
|
if current_line:
|
|
lines.append(current_line)
|
|
|
|
return lines
|
|
|
|
def load_system_image(platform_dict):
|
|
"""Charge une image système avec priorité:
|
|
1. Fichier nommé exactement <platform_name>.png
|
|
2. Champ platform_image si non vide
|
|
3. Fallback default.png"""
|
|
platform_name = platform_dict.get("platform_name", "unknown")
|
|
preferred_filename = f"{platform_name}.png"
|
|
preferred_path = os.path.join(config.IMAGES_FOLDER, preferred_filename)
|
|
|
|
# Normaliser platform_image pouvant être vide
|
|
platform_image_field = platform_dict.get("platform_image") or ""
|
|
explicit_image_path = os.path.join(config.IMAGES_FOLDER, platform_image_field) if platform_image_field else None
|
|
default_path = os.path.join(config.IMAGES_FOLDER, "default.png")
|
|
|
|
try:
|
|
if os.path.exists(preferred_path):
|
|
return pygame.image.load(preferred_path).convert_alpha()
|
|
if explicit_image_path and os.path.exists(explicit_image_path):
|
|
return pygame.image.load(explicit_image_path).convert_alpha()
|
|
if os.path.exists(default_path):
|
|
return pygame.image.load(default_path).convert_alpha()
|
|
logger.error(f"Aucune image trouvée pour {platform_name} (cherché: {preferred_path}, {explicit_image_path}, default.png)")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}")
|
|
return None
|
|
|
|
def extract_zip_data(zip_path, dest_dir, url):
|
|
"""Extrait le contenu du fichier ZIP dans le dossier config.APP_FOLDER sans progression a l'ecran"""
|
|
logger.debug(f"Extraction de {zip_path} dans {dest_dir}")
|
|
try:
|
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
zip_ref.testzip() # Vérifier l'intégrité de l'archive
|
|
for info in zip_ref.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
file_path = os.path.join(dest_dir, info.filename)
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
with zip_ref.open(info) as source, open(file_path, 'wb') as dest:
|
|
shutil.copyfileobj(source, dest)
|
|
logger.info(f"Extraction terminée de {zip_path}")
|
|
return True, "Extraction terminée avec succès"
|
|
except zipfile.BadZipFile as e:
|
|
logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}")
|
|
return False, _("utils_corrupt_zip").format(str(e))
|
|
|
|
|
|
|
|
def extract_zip(zip_path, dest_dir, url):
|
|
"""Extrait le contenu du fichier ZIP dans le dossier cible avec un suivi progressif de la progression."""
|
|
logger.debug(f"Extraction de {zip_path} dans {dest_dir}")
|
|
try:
|
|
lock = threading.Lock()
|
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
zip_ref.testzip() # Vérifier l'intégrité de l'archive
|
|
total_size = sum(info.file_size for info in zip_ref.infolist() if not info.is_dir())
|
|
logger.info(f"Taille totale à extraire: {total_size} octets")
|
|
lock = threading.Lock()
|
|
# Lister les ISO avant extraction
|
|
iso_before = set()
|
|
for root, dirs, files in os.walk(dest_dir):
|
|
for file in files:
|
|
if file.lower().endswith('.iso'):
|
|
iso_before.add(os.path.abspath(os.path.join(root, file)))
|
|
|
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
|
zip_ref.testzip() # Vérifier l'intégrité de l'archive
|
|
total_size = sum(info.file_size for info in zip_ref.infolist() if not info.is_dir())
|
|
logger.info(f"Taille totale à extraire: {total_size} octets")
|
|
if total_size == 0:
|
|
logger.warning("ZIP vide ou ne contenant que des dossiers")
|
|
return True, "ZIP vide extrait avec succès"
|
|
|
|
extracted_size = 0
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
chunk_size = 2048 # Réduire pour plus de mises à jour
|
|
last_save_time = time.time()
|
|
save_interval = 0.5 # Sauvegarder toutes les 0.5 secondes
|
|
for info in zip_ref.infolist():
|
|
if info.is_dir():
|
|
continue
|
|
file_path = os.path.join(dest_dir, info.filename)
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
with zip_ref.open(info) as source, open(file_path, 'wb') as dest:
|
|
file_size = info.file_size
|
|
file_extracted = 0
|
|
while True:
|
|
chunk = source.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
dest.write(chunk)
|
|
file_extracted += len(chunk)
|
|
extracted_size += len(chunk)
|
|
current_time = time.time()
|
|
with lock:
|
|
if isinstance(config.history, list):
|
|
for entry in config.history:
|
|
if "status" in entry and entry["status"] in ["Téléchargement", "Extracting", "downloading"]:
|
|
if "url" in entry and entry["url"] == url:
|
|
progress_percent = int(extracted_size / total_size * 100) if total_size > 0 else 0
|
|
progress_percent = max(0, min(100, progress_percent))
|
|
entry["status"] = "Extracting"
|
|
entry["progress"] = progress_percent
|
|
entry["message"] = "Extraction en cours"
|
|
if current_time - last_save_time >= save_interval:
|
|
save_history(config.history)
|
|
last_save_time = current_time
|
|
config.needs_redraw = True
|
|
break
|
|
os.chmod(file_path, 0o644)
|
|
# Vérifier si c'est un dossier xbox et le traiter si nécessaire
|
|
xbox_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), "xbox")
|
|
if dest_dir == xbox_dir:
|
|
# Lister les ISO après extraction
|
|
iso_after = set()
|
|
for root, dirs, files in os.walk(dest_dir):
|
|
for file in files:
|
|
if file.lower().endswith('.iso'):
|
|
iso_after.add(os.path.abspath(os.path.join(root, file)))
|
|
new_isos = list(iso_after - iso_before)
|
|
if new_isos:
|
|
success, error_msg = handle_xbox(dest_dir, new_isos)
|
|
if not success:
|
|
return False, error_msg
|
|
else:
|
|
logger.warning("Aucun nouvel ISO détecté après extraction pour conversion Xbox.")
|
|
# On ne retourne pas d'erreur fatale ici, on continue
|
|
|
|
try:
|
|
os.remove(zip_path)
|
|
logger.info(f"Fichier ZIP {zip_path} extrait dans {dest_dir} et supprimé")
|
|
|
|
# Mettre à jour le statut final dans l'historique
|
|
if isinstance(config.history, list):
|
|
for entry in config.history:
|
|
if "status" in entry and entry["status"] == "Extracting":
|
|
entry["status"] = "Download_OK"
|
|
entry["progress"] = 100
|
|
# Utiliser une variable intermédiaire pour stocker le message
|
|
message_text = _("utils_extracted").format(os.path.basename(zip_path))
|
|
entry["message"] = message_text
|
|
save_history(config.history)
|
|
config.needs_redraw = True
|
|
break
|
|
|
|
return True, _("utils_extracted").format(os.path.basename(zip_path))
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la finalisation de l'extraction: {str(e)}")
|
|
return True, _("utils_extracted").format(os.path.basename(zip_path))
|
|
except zipfile.BadZipFile as e:
|
|
logger.error(f"Erreur: Archive ZIP corrompue: {str(e)}")
|
|
return False, _("utils_corrupt_zip").format(str(e))
|
|
except PermissionError as e:
|
|
logger.error(f"Erreur: Permission refusée lors de l'extraction: {str(e)}")
|
|
return False, _("utils_permission_denied").format(str(e))
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de l'extraction de {zip_path}: {str(e)}")
|
|
return False, _("utils_extraction_failed").format(str(e))
|
|
|
|
|
|
# Fonction pour extraire le contenu d'un fichier RAR
|
|
def extract_rar(rar_path, dest_dir, url):
|
|
"""Extrait le contenu du fichier RAR dans le dossier cible."""
|
|
try:
|
|
lock = threading.Lock()
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
|
|
system_type = platform.system()
|
|
if system_type == "Windows":
|
|
# Sur Windows, utiliser directement config.UNRAR_EXE
|
|
unrar_exe = config.UNRAR_EXE
|
|
unrar_cmd = [unrar_exe]
|
|
else:
|
|
# Linux/Batocera: utiliser 'unrar' du système
|
|
unrar_cmd = ["unrar"]
|
|
|
|
# Reste du code pour la vérification de unrar
|
|
result = subprocess.run(unrar_cmd, capture_output=True, text=True)
|
|
if result.returncode not in [0, 1]:
|
|
logger.error("Commande unrar non disponible")
|
|
return False, _("utils_unrar_unavailable")
|
|
|
|
result = subprocess.run(unrar_cmd + ['l', '-v', rar_path], capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
error_msg = result.stderr.strip()
|
|
logger.error(f"Erreur lors de la liste des fichiers RAR: {error_msg}")
|
|
return False, _("utils_rar_list_failed").format(error_msg)
|
|
|
|
logger.debug(f"Sortie brute de 'unrar l -v {rar_path}':\n{result.stdout}")
|
|
|
|
total_size = 0
|
|
files_to_extract = []
|
|
root_dirs = set()
|
|
lines = result.stdout.splitlines()
|
|
in_file_list = False
|
|
for line in lines:
|
|
if line.startswith("----"):
|
|
in_file_list = not in_file_list
|
|
continue
|
|
if in_file_list:
|
|
match = re.match(r'^\s*(\S+)\s+(\d+)\s+\d*\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(.+)$', line)
|
|
if match:
|
|
attrs = match.group(1)
|
|
file_size = int(match.group(2))
|
|
file_date = match.group(3)
|
|
file_name = match.group(4).strip()
|
|
if 'D' not in attrs:
|
|
files_to_extract.append((file_name, file_size))
|
|
total_size += file_size
|
|
root_dir = file_name.split('/')[0] if '/' in file_name else ''
|
|
if root_dir:
|
|
root_dirs.add(root_dir)
|
|
logger.debug(f"Ligne parsée: {file_name}, taille: {file_size}, date: {file_date}")
|
|
else:
|
|
logger.debug(f"Dossier ignoré: {file_name}")
|
|
else:
|
|
logger.debug(f"Ligne ignorée (format inattendu): {line}")
|
|
|
|
logger.info(f"Taille totale à extraire (RAR): {total_size} octets")
|
|
logger.debug(f"Fichiers à extraire: {files_to_extract}")
|
|
logger.debug(f"Dossiers racines détectés: {root_dirs}")
|
|
if total_size == 0:
|
|
logger.warning("RAR vide, ne contenant que des dossiers, ou erreur de parsing")
|
|
return False, "RAR vide ou erreur lors de la liste des fichiers"
|
|
|
|
try:
|
|
with lock:
|
|
# Vérifier si l'URL existe dans config.download_progress
|
|
if url not in config.download_progress:
|
|
config.download_progress[url] = {}
|
|
config.download_progress[url]["downloaded_size"] = 0
|
|
config.download_progress[url]["total_size"] = total_size
|
|
config.download_progress[url]["status"] = "Extracting"
|
|
config.download_progress[url]["progress_percent"] = 0
|
|
config.needs_redraw = True
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la mise à jour de la progression: {str(e)}")
|
|
# Continuer l'extraction même en cas d'erreur de mise à jour de la progression
|
|
|
|
escaped_rar_path = rar_path.replace(" ", "\\ ")
|
|
escaped_dest_dir = dest_dir.replace(" ", "\\ ")
|
|
process = subprocess.Popen(unrar_cmd + ['x', '-y', escaped_rar_path, escaped_dest_dir],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
stdout, stderr = process.communicate()
|
|
|
|
if process.returncode != 0:
|
|
logger.error(f"Erreur lors de l'extraction de {rar_path}: {stderr}")
|
|
return False, f"Erreur lors de l'extraction: {stderr}"
|
|
|
|
extracted_size = 0
|
|
extracted_files = []
|
|
total_files = len(files_to_extract)
|
|
for i, (expected_file, file_size) in enumerate(files_to_extract):
|
|
file_path = os.path.join(dest_dir, expected_file)
|
|
if os.path.exists(file_path):
|
|
extracted_size += file_size
|
|
extracted_files.append(expected_file)
|
|
os.chmod(file_path, 0o644)
|
|
logger.debug(f"Fichier extrait: {expected_file}, taille: {file_size}, chemin: {file_path}")
|
|
try:
|
|
with lock:
|
|
if url in config.download_progress:
|
|
config.download_progress[url]["downloaded_size"] = extracted_size
|
|
config.download_progress[url]["status"] = "Extracting"
|
|
config.download_progress[url]["progress_percent"] = ((i + 1) / total_files * 100) if total_files > 0 else 0
|
|
config.needs_redraw = True
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la mise à jour de la progression d'extraction: {str(e)}")
|
|
# Continuer l'extraction même en cas d'erreur de mise à jour de la progression
|
|
else:
|
|
logger.warning(f"Fichier non trouvé après extraction: {expected_file}")
|
|
|
|
# Vérifier si c'est un dossier PS3 et le traiter si nécessaire
|
|
ps3_dir = os.path.join(os.path.dirname(os.path.dirname(config.APP_FOLDER)), "ps3")
|
|
if dest_dir == ps3_dir:
|
|
success, error_msg = handle_ps3(dest_dir)
|
|
if not success:
|
|
return False, error_msg
|
|
|
|
for root, dirs, files in os.walk(dest_dir):
|
|
for dir_name in dirs:
|
|
os.chmod(os.path.join(root, dir_name), 0o755)
|
|
|
|
os.remove(rar_path)
|
|
logger.info(f"Fichier RAR {rar_path} extrait dans {dest_dir} et supprimé")
|
|
return True, "RAR extrait avec succès"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de l'extraction de {rar_path}: {str(e)}")
|
|
return False, f"Erreur lors de l'extraction: {str(e)}"
|
|
finally:
|
|
if os.path.exists(rar_path):
|
|
try:
|
|
os.remove(rar_path)
|
|
logger.info(f"Fichier RAR {rar_path} supprimé après échec de l'extraction")
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la suppression de {rar_path}: {str(e)}")
|
|
|
|
def handle_ps3(dest_dir):
|
|
"""Gère le renommage spécifique des dossiers PS3 extraits."""
|
|
logger.debug(f"Traitement spécifique PS3 dans: {dest_dir}")
|
|
|
|
# Attendre un peu que tous les processus d'extraction se terminent
|
|
time.sleep(2)
|
|
|
|
# Rechercher le dossier extrait directement dans dest_dir
|
|
extracted_dirs = [d for d in os.listdir(dest_dir) if os.path.isdir(os.path.join(dest_dir, d))]
|
|
logger.debug(f"Dossiers trouvés dans {dest_dir}: {extracted_dirs}")
|
|
|
|
# Filtrer pour ne garder que les dossiers nouvellement extraits
|
|
ps3_dirs = [d for d in extracted_dirs if not d.endswith('.ps3')]
|
|
logger.debug(f"Dossiers PS3 à renommer: {ps3_dirs}")
|
|
|
|
if len(ps3_dirs) == 1:
|
|
old_path = os.path.join(dest_dir, ps3_dirs[0])
|
|
new_path = os.path.join(dest_dir, f"{ps3_dirs[0]}.ps3")
|
|
logger.debug(f"Tentative de renommage PS3: {old_path} -> {new_path}")
|
|
|
|
max_retries = 3
|
|
retry_delay = 2
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
# Fermer les handles potentiellement ouverts
|
|
for root, dirs, files in os.walk(old_path):
|
|
for f in files:
|
|
try:
|
|
os.chmod(os.path.join(root, f), 0o644)
|
|
except (OSError, PermissionError):
|
|
pass
|
|
for d in dirs:
|
|
try:
|
|
os.chmod(os.path.join(root, d), 0o755)
|
|
except (OSError, PermissionError):
|
|
pass
|
|
|
|
if os.path.exists(new_path):
|
|
shutil.rmtree(new_path, ignore_errors=True)
|
|
time.sleep(1)
|
|
|
|
os.rename(old_path, new_path)
|
|
logger.info(f"Dossier renommé avec succès: {old_path} -> {new_path}")
|
|
return True, None
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Tentative {attempt + 1}/{max_retries} échouée: {str(e)}")
|
|
if attempt < max_retries - 1:
|
|
time.sleep(retry_delay)
|
|
else:
|
|
error_msg = f"Erreur lors du renommage de {old_path} en {new_path}: {str(e)}"
|
|
logger.error(error_msg)
|
|
return False, error_msg
|
|
|
|
elif len(ps3_dirs) > 1:
|
|
logger.warning(f"Plusieurs dossiers PS3 détectés: {ps3_dirs}")
|
|
return True, None
|
|
else:
|
|
logger.warning("Aucun dossier PS3 à renommer trouvé")
|
|
return True, None
|
|
|
|
|
|
def handle_xbox(dest_dir, iso_files):
|
|
"""Gère la conversion des fichiers Xbox extraits."""
|
|
logger.debug(f"Traitement spécifique Xbox dans: {dest_dir}")
|
|
|
|
# Attendre un peu que tous les processus d'extraction se terminent
|
|
time.sleep(2)
|
|
system_type = platform.system()
|
|
if system_type == "Windows":
|
|
# Sur Windows; telecharger le fichier exe
|
|
XDVDFS_EXE = config.XDVDFS_EXE
|
|
xdvdfs_cmd = [XDVDFS_EXE, "pack"] # Liste avec 2 éléments
|
|
|
|
else:
|
|
# Linux/Batocera : télécharger le fichier xdvdfs
|
|
XDVDFS_LINUX = config.XDVDFS_LINUX
|
|
try:
|
|
stat_info = os.stat(XDVDFS_LINUX)
|
|
mode = stat_info.st_mode
|
|
logger.debug(f"Permissions de {XDVDFS_LINUX}: {oct(mode)}")
|
|
logger.debug(f"Propriétaire: {stat_info.st_uid}, Groupe: {stat_info.st_gid}")
|
|
|
|
# Vérifier si le fichier est exécutable
|
|
if not os.access(XDVDFS_LINUX, os.X_OK):
|
|
logger.error(f"Le fichier {XDVDFS_LINUX} n'est pas exécutable")
|
|
try:
|
|
os.chmod(XDVDFS_LINUX, 0o755)
|
|
logger.info(f"Permissions corrigées pour {XDVDFS_LINUX}")
|
|
except Exception as e:
|
|
logger.error(f"Impossible de modifier les permissions: {str(e)}")
|
|
return False, "Erreur de permissions sur xdvdfs"
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la vérification des permissions: {str(e)}")
|
|
|
|
xdvdfs_cmd = [XDVDFS_LINUX, "pack"] # Liste avec 2 éléments
|
|
|
|
try:
|
|
# Chercher les fichiers ISO à convertir
|
|
iso_files = []
|
|
for root, dirs, files in os.walk(dest_dir):
|
|
for file in files:
|
|
if file.lower().endswith('.iso'):
|
|
iso_files.append(os.path.join(root, file))
|
|
|
|
if not iso_files:
|
|
logger.warning("Aucun fichier ISO xbox trouvé")
|
|
return True, None
|
|
|
|
for iso_xbox_source in iso_files:
|
|
logger.debug(f"Traitement de l'ISO Xbox: {iso_xbox_source}")
|
|
xiso_dest = os.path.splitext(iso_xbox_source)[0] + "_xbox.iso"
|
|
|
|
# Construction de la commande avec des arguments distincts
|
|
cmd = xdvdfs_cmd + [iso_xbox_source, xiso_dest]
|
|
logger.debug(f"Exécution de la commande: {' '.join(cmd)}")
|
|
|
|
process = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
|
|
if process.returncode != 0:
|
|
logger.error(f"Erreur lors de la conversion de l'ISO: {process.stderr}")
|
|
return False, f"Erreur lors de la conversion de l'ISO: {process.stderr}"
|
|
|
|
# Vérifier que l'ISO converti a été créé
|
|
if os.path.exists(xiso_dest):
|
|
logger.info(f"ISO converti avec succès: {xiso_dest}")
|
|
# Remplacer l'ISO original par l'ISO converti
|
|
os.remove(iso_xbox_source)
|
|
os.rename(xiso_dest, iso_xbox_source)
|
|
logger.debug(f"ISO original remplacé par la version convertie")
|
|
else:
|
|
logger.error(f"L'ISO converti n'a pas été créé: {xiso_dest}")
|
|
return False, "Échec de la conversion de l'ISO"
|
|
|
|
return True, "Conversion Xbox terminée avec succès"
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la conversion Xbox: {str(e)}")
|
|
return False, f"Erreur lors de la conversion: {str(e)}"
|
|
|
|
|
|
|
|
def play_random_music(music_files, music_folder, current_music=None):
|
|
if not getattr(config, "music_enabled", True):
|
|
pygame.mixer.music.stop()
|
|
return current_music
|
|
if music_files:
|
|
# Éviter de rejouer la même musique consécutivement
|
|
available_music = [f for f in music_files if f != current_music]
|
|
if not available_music: # Si une seule musique, on la reprend
|
|
available_music = music_files
|
|
music_file = random.choice(available_music)
|
|
music_path = os.path.join(music_folder, music_file)
|
|
logger.debug(f"Lecture de la musique : {music_path}")
|
|
|
|
def load_and_play_music():
|
|
try:
|
|
pygame.mixer.music.load(music_path)
|
|
pygame.mixer.music.set_volume(0.5)
|
|
pygame.mixer.music.play(loops=0) # Jouer une seule fois
|
|
pygame.mixer.music.set_endevent(pygame.USEREVENT + 1) # Événement de fin
|
|
set_music_popup(music_file) # Afficher le nom de la musique dans la popup
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement de la musique {music_path}: {str(e)}")
|
|
|
|
# Charger et jouer la musique dans un thread séparé pour éviter le blocage
|
|
music_thread = threading.Thread(target=load_and_play_music, daemon=True)
|
|
music_thread.start()
|
|
|
|
return music_file # Retourner la nouvelle musique pour mise à jour
|
|
else:
|
|
logger.debug("Aucune musique trouvée dans /RGSX/assets/music")
|
|
return current_music
|
|
|
|
def set_music_popup(music_name):
|
|
"""Définit le nom de la musique à afficher dans la popup."""
|
|
config.current_music_name = f"♬ {os.path.splitext(music_name)[0]}" # Utilise l'emoji ♬ directement
|
|
config.music_popup_start_time = pygame.time.get_ticks() / 1000 # Temps actuel en secondes
|
|
config.needs_redraw = True # Forcer le redraw pour afficher le nom de la musique
|
|
|
|
def load_api_key_1fichier():
|
|
"""Charge la clé API 1fichier depuis le dossier de sauvegarde, crée le fichier si absent."""
|
|
|
|
logger.debug(f"Tentative de chargement de la clé API depuis: {config.API_KEY_1FICHIER}")
|
|
try:
|
|
# Vérifie si le fichier existe déjà
|
|
if not os.path.exists(config.API_KEY_1FICHIER):
|
|
# Crée le dossier parent si nécessaire
|
|
os.makedirs(config.SAVE_FOLDER, exist_ok=True)
|
|
# Crée le fichier vide si absent
|
|
with open(config.API_KEY_1FICHIER, "w") as f:
|
|
f.write("")
|
|
logger.info(f"Fichier de clé API créé : {config.API_KEY_1FICHIER}")
|
|
return ""
|
|
except OSError as e:
|
|
logger.error(f"Erreur lors de la création du fichier de clé API : {e}")
|
|
return ""
|
|
# Lit la clé API depuis le fichier
|
|
try:
|
|
with open(config.API_KEY_1FICHIER, "r", encoding="utf-8") as f:
|
|
api_key = f.read().strip()
|
|
logger.debug(f"Clé API 1fichier lue: '{api_key}' (longueur: {len(api_key)})")
|
|
if not api_key:
|
|
logger.warning("Clé API 1fichier vide, veuillez la renseigner dans le fichier pour pouvoir utiliser les fonctionnalités de téléchargement sur 1fichier.")
|
|
config.API_KEY_1FICHIER = api_key
|
|
return api_key
|
|
except OSError as e:
|
|
logger.error(f"Erreur lors de la lecture de la clé API : {e}")
|
|
return ""
|
|
|
|
def load_music_config():
|
|
"""Charge la configuration musique depuis rgsx_settings.json."""
|
|
try:
|
|
settings = load_rgsx_settings()
|
|
config.music_enabled = settings.get("music_enabled", True)
|
|
return config.music_enabled
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors du chargement de la configuration musique: {str(e)}")
|
|
config.music_enabled = True
|
|
return True
|
|
|
|
def save_music_config():
|
|
"""Sauvegarde la configuration musique dans rgsx_settings.json."""
|
|
try:
|
|
settings = load_rgsx_settings()
|
|
settings["music_enabled"] = config.music_enabled
|
|
save_rgsx_settings(settings)
|
|
logger.debug(f"Configuration musique sauvegardée: {config.music_enabled}")
|
|
except Exception as e:
|
|
logger.error(f"Erreur lors de la sauvegarde de la configuration musique: {str(e)}")
|
|
|
|
|
|
def normalize_platform_name(platform):
|
|
"""Normalise un nom de plateforme en supprimant espaces et convertissant en minuscules."""
|
|
return platform.lower().replace(" ", "")
|