Compare commits

...

16 Commits

Author SHA1 Message Date
skymike03
14aa7a1c12 ## v2.6.4.1 (2026.05.01)
- fix find_local_custom_sources_zip to only check for games.zip to avoid using support zip as sources
- fix ps3dec command for older powershell

Co-authored-by: Copilot <copilot@github.com>
2026-05-01 23:14:31 +02:00
skymike03
2c7f6d1751 ## v2.6.4.0 (2026.04.27)
- update torrent function to work better
- downloads can be resume after app restart
- add ability to change the ROMS Drive folder location on windows
2026-04-27 15:30:18 +02:00
skymike03
6a27dd0e40 ## v2.6.3.9 (2026.04.27)
- updating torrent function

Co-authored-by: Copilot <copilot@github.com>
2026-04-27 15:26:59 +02:00
skymike03
69c43d7922 ## v2.6.3.8
- disable upnp command not working for torrent as not working.
you need open port 6999 on your router
2026-04-26 10:07:47 +02:00
skymike03
1fe8bf6515 readme updates
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 02:31:36 +02:00
skymike03
6b9c3c8348 ## v2.6.3.7 (2026.04.25)
- Add EdgeEmu to sources list
- Refactor the platforms hide/show menu on Menu>Games and add ability to hide by Sources
- Update the Connectivity test menu in Menu>Settings to test all sources accessibility.
- Remove unused menu to hide premium systems

Co-authored-by: Copilot <copilot@github.com>
2026-04-25 02:12:32 +02:00
skymike03
92a8d8567d ##v2.6.3.6 (2026.04.24)
- correct history bug writting permissions / workaround

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 14:46:43 +02:00
skymike03
3e9aefc149 ## v2.6.3.5 (2026.05.22)
- Enhance download management and UI updates for torrent handling. Torrent download is now available for testing
- Add new aria2c Linux binary to assets and Update aria2c Linux path in config
- Improve download status display in history with active, completed, error, and canceled states
2026-04-22 21:03:24 +02:00
skymike03
d7449073d2 ## v2.6.3.4 (2026.04.10)
- Fix ps3 handle with uncompressed folder (rename folder to .ps3)
- show and save real name of vimm's file when downloading
2026-04-10 23:18:20 +02:00
skymike03
e0d34304d5 Replace timeout with ping for delay before exit in RGSX Retrobat 2026-04-10 18:02:39 +02:00
skymike03
65584e411a Improve logging for application closure in RGSX Retrobat 2026-04-10 17:43:46 +02:00
skymike03
142fffcfb1 ## v2.6.3.3 (2026.04.10)
- add vimms support
- fix scraper api
2026-04-10 02:06:24 +02:00
skymike03
ffd186f69b remove file 2026-04-08 18:25:52 +02:00
skymike03
fd3695f78d - Fix scraper API bug 2026-04-07 21:13:01 +02:00
skymike03
579d0a1c28 ## v2.6.3.2 (2026.04.07)
- Add little badge on platform icon to identify the source of games (add icons files)
- Fix sorting size bug with commas
- Fix history locate/extract/delete game options not showing after some downloads
2026-04-07 18:52:28 +02:00
skymike03
60ca7bc375 - Fix history locate/extract/delete game options not showing after some downloads 2026-04-07 00:03:05 +02:00
28 changed files with 3344 additions and 916 deletions

114
README.md
View File

@@ -57,31 +57,44 @@ Download latest release : [RGSX_update_latest.zip](https://github.com/RetroGameS
### Pause Menu Structure
**Root categories**
- Games (downloads, scans, platform visibility)
- Language (switch UI language)
- Controls (help and remap)
- Display (layout, fonts, monitor/mode, visual options)
- Settings (music, symlink, auto extract, network and API status)
- Support (generate support ZIP/log bundle)
- Quit (exit or restart)
**Controls**
- View Controls Help
- Remap Controls
- View Controls Help (shows current mapped actions)
- Remap Controls (reconfigure keyboard/controller mapping)
**Display**
- Layout (3×3, 3×4, 4×3, 4×4)
- Font Size (general UI)
- Footer Font Size (controls/version text)
- Font Family (pixel fonts)
- Hide Unknown Extension Warning
- Font Size submenu (general UI + footer text)
- Font Family (Pixel or DejaVu)
- Monitor selection (when multiple monitors are detected)
- Screen Mode (Windows only)
- Light Mode (performance-friendly rendering)
- Hide Unknown Extension Warning (toggle unsupported extension warnings)
**Games**
- Download History
- Source Mode (RGSX / Custom)
- Update Game Cache
- Show Unsupported Platforms
- Hide Premium Systems
- Filter Platforms
- Update Game Cache (redownload systems/games data)
- Scan Owned ROMs (add locally owned ROMs to history)
- Download History (view/manage download entries)
- Show Unsupported Platforms (toggle platforms without local ROM folders)
- Filter Platforms (source/platform visibility menu)
**Settings**
- Background Music Toggle
- Symlink Options (Batocera)
- Web Service (Batocera)
- API Keys Management
- Language Selection
- Background Music Toggle (enable/disable music)
- Symlink Options (choose copy/symlink behavior)
- Auto Extract Toggle (automatic archive extraction)
- ROMs Folder Selector (set custom ROM root folder)
- Web Service (Batocera/Knulli) (start web UI at boot)
- Custom DNS (Batocera/Knulli) (workaround for ISP/domain blocking)
- API Keys Status (check provider key presence)
- Connection Status (test required updates/sources sites)
---
@@ -89,18 +102,18 @@ Download latest release : [RGSX_update_latest.zip](https://github.com/RetroGameS
- 🎯 **Smart System Detection** Auto-discovers supported systems from `es_systems.cfg`
- 📦 **Intelligent Archive Handling** Auto-extracts archives when systems don't support ZIP files
- 🔑 **Premium Unlocking** 1Fichier API + AllDebrid/Real-Debrid fallback for unlimited downloads
- 🔑 **Premium Unlocking** 1Fichier API + AllDebrid/Debrid-Link/Real-Debrid fallback for unlimited downloads
- 🎨 **Fully Customizable** Layout (3×3 to 4×4), fonts, font sizes (UI + footer), languages (EN/FR/DE/ES/IT/PT)
- 🎮 **Controller-First Design** Auto-mapping for popular controllers + custom remapping support
- 🔍 **Advanced Filtering** Search by name, hide/show unsupported systems, filter platforms
- 📊 **Download Management** Queue system, history tracking, progress notifications
- 🌐 **Custom Sources** Use your own game repository URLs
-**Accessibility** Separate font scaling for UI and footer, keyboard-only mode support
> ### 🔑 API Keys Setup
> For unlimited 1Fichier downloads, add your API key(s) to `/saves/ports/rgsx/`:
> - `1FichierAPI.txt` 1Fichier API key (recommended)
> - `AllDebridAPI.txt` AllDebrid fallback (optional)
> - `DebridLinkAPI.txt` Debrid-Link fallback (optional)
> - `RealDebridAPI.txt` Real-Debrid fallback (optional)
>
> **Each file must contain ONLY the key, no extra text.**
@@ -112,23 +125,6 @@ Download latest release : [RGSX_update_latest.zip](https://github.com/RetroGameS
3. **Queue Download**: Press `X` (West button)
4. Track progress in **History** menu or via popup notifications
### Custom Game Sources
Switch to custom sources via **Pause Menu > Games > Source Mode**.
Configure in `/saves/ports/rgsx/rgsx_settings.json`:
```json
{
"sources": {
"mode": "custom",
"custom_url": "https://example.com/my-sources.zip"
}
}
```
**Note**: If custom mode activated but Invalid/empty URL = using /saves/ports/rgsx/games.zip . You need to update games cache on RGSX menu after fixing URL.
---
## 🌐 Web Interface (Batocera/Knulli Only)
RGSX includes a web interface that launched automatically when using RGSX for remote browsing and downloading games from any device on your network.
@@ -169,30 +165,40 @@ RGSX includes a web interface that launched automatically when using RGSX for re
## 📁 File Structure
```
/roms/ports/RGSX/
├── __main__.py # Entry point
├── controls.py # Input handling
├── display.py # Rendering engine
├── network.py # Download manager
├── rgsx_settings.py # Settings manager
├── assets/controls/ # Controller profiles
├── languages/ # Translations (EN/FR/DE/ES/IT/PT)
└── logs/RGSX.log # Runtime logs
/roms/windows/RGSX/
└── RGSX Retrobat.bat # RetroBat launcher
/roms/
├── ports/
│ ├── RGSX/
│ │ ├── __main__.py # Entry point
│ │ ├── controls.py # Input handling
│ │ ├── display.py # Rendering engine
│ │ ├── network.py # Download manager
├── rgsx_settings.py # Settings manager
│ │ ├── assets/controls/ # Controller profiles
│ │ ├── languages/ # Translations (EN/FR/DE/ES/IT/PT)
│ │ └── logs/RGSX.log # Runtime logs
│ ├── gamelist.xml
│ ├── images/
│ └── videos/
└── windows/
├── RGSX Retrobat.bat # Launcher for Windows only (can be used without retrobat too)
├── gamelist.xml
├── images/
└── videos/
/saves/ports/rgsx/
├── rgsx_settings.json # User preferences
├── controls.json # Control mapping
├── history.json # Download history
├── rom_extensions.json # Supported extensions cache
├── systems_list.json # Detected systems
├── global_search_index.json # Global search index cache
├── platform_games_count_cache.json
├── torrent_manifest_cache.json
├── games/ # Game databases (per platform)
├── images/ # Platform images
├── 1FichierAPI.txt # 1Fichier API key
├── AllDebridAPI.txt # AllDebrid API key
── RealDebridAPI.txt # Real-Debrid API key
├── 1FichierAPI.txt # 1Fichier API key
├── AllDebridAPI.txt # AllDebrid API key
── DebridLinkAPI.txt # Debrid-Link API key
└── RealDebridAPI.txt # Real-Debrid API key
```
---
@@ -202,11 +208,11 @@ RGSX includes a web interface that launched automatically when using RGSX for re
| Issue | Solution |
|-------|----------|
| Controls not working | Delete `/saves/ports/rgsx/controls.json` + restart app, you can try delete /roms/ports/RGSX/assets/controls/xx.json too |
| No games ? | Pause Menu > Games > Update Game Cache |
| No games ? | Pause Menu > Games > Update Game Cache, then check Pause Menu > Games > Filter Platforms and Show Unsupported Platforms |
| Missing systems on the list? | RGSX read es_systems.cfg to show only supported systems, if you want all systems : Pause Menu > Games > Show unsupported systems |
| App crashes | Check `/roms/ports/RGSX/logs/RGSX.log` or `/roms/windows/logs/Retrobat_RGSX_log.txt` |
| Layout change not applied | Restart RGSX after changing layout |
| Downloading BIOS file is ok but you can't download any games? | Activate custom DNS on Pause Menu> Settings and reboot , server can be blocked by your ISP. check any threat/website protection on your router too, especially on ASUS one|
| Problem downloading some Games ? | Open Pause Menu > Settings > Connection Status. If one or more required sites are red, enable Custom DNS in Settings and reboot. Also check ISP/router protections (especially ASUS web threat blocking). |
**Need help?** Share logs from `/roms/ports/RGSX/logs/` on [Discord](https://discord.gg/Vph9jwg3VV).

View File

@@ -1,16 +1,16 @@
# 🎮 Retro Game Sets Xtra (RGSX)
**[Support / Aide Discord](https://discord.gg/Vph9jwg3VV)** • **[Installation](#-installation)** • **[Documentation anglaise](https://github.com/RetroGameSets/RGSX/blob/main/README.md)**
**[Support Discord](https://discord.gg/Vph9jwg3VV)** • **[Installation](#-installation)** • **[Documentation anglaise](https://github.com/RetroGameSets/RGSX/blob/main/README.md)****[Dépannage / Erreurs courantes](https://github.com/RetroGameSets/RGSX/blob/main/README_FR.md#%EF%B8%8F-d%C3%A9pannage)** •
Un téléchargeur de ROMs gratuit et facile à utiliser pour Batocera, Knulli et RetroBat avec support multi-sources.
Un téléchargeur de ROMs gratuit et simple d'utilisation pour Batocera, Knulli et RetroBat, avec support multi-sources.
<p align="center">
<img width="69%" alt="menu plateformes" src="https://github.com/user-attachments/assets/4464b57b-06a8-45e9-a411-cc12b421545a" />
<img width="69%" alt="main" src="https://github.com/user-attachments/assets/a98f1189-9a50-4cc3-b588-3f85245640d8" />
<img width="30%" alt="aide contrôles" src="https://github.com/user-attachments/assets/38cac7e6-14f2-4e83-91da-0679669822ee" />
</p>
<p align="center">
<img width="49%" alt="interface web" src="https://github.com/user-attachments/assets/71f8bd39-5901-45a9-82b2-91426b3c31a7" />
<img width="49%" alt="menu API" src="https://github.com/user-attachments/assets/5bae018d-b7d9-4a95-9f1b-77db751ff24f" />
<img width="49%" alt="menu api" src="https://github.com/user-attachments/assets/5bae018d-b7d9-4a95-9f1b-77db751ff24f" />
</p>
@@ -25,9 +25,9 @@ Un téléchargeur de ROMs gratuit et facile à utiliser pour Batocera, Knulli et
curl -L bit.ly/rgsx-install | sh
```
Après l'installation :
Après installation :
1. Mettez à jour les listes de jeux : `Menu > Paramètres des jeux > Mettre à jour la liste des jeux`
2. Trouvez RGSX dans **PORTS** ou **Jeux amateurs et portages**
2. Trouvez RGSX dans **PORTS** ou **Homebrew and ports**
### Installation manuelle (Tous systèmes)
1. **Télécharger** : [RGSX_full_latest.zip](https://github.com/RetroGameSets/RGSX/releases/latest/download/RGSX_full_latest.zip)
@@ -37,9 +37,9 @@ Après l'installation :
3. **Rafraîchir** : `Menu > Paramètres des jeux > Mettre à jour la liste des jeux`
### Mise à jour manuelle (si la mise à jour automatique a échoué)
Téléchargez la dernière version : [RGSX_update_latest.zip](https://github.com/RetroGameSets/RGSX/releases/latest/download/RGSX_full_latest.zip)
Télécharger la dernière release : [RGSX_update_latest.zip](https://github.com/RetroGameSets/RGSX/releases/latest/download/RGSX_full_latest.zip)
**Chemins d'installation :**
**Chemins installés :**
- `/roms/ports/RGSX` (tous systèmes)
- `/roms/windows/RGSX` (RetroBat uniquement)
@@ -49,106 +49,102 @@ Téléchargez la dernière version : [RGSX_update_latest.zip](https://github.com
### Premier lancement
- Téléchargement automatique des images systèmes et des listes de jeux
- Configuration automatique des contrôles si votre manette est reconnue
- **Contrôles cassés ?** Supprimez `/saves/ports/rgsx/controls.json` puis relancez
- Télécharge automatiquement les images systèmes et les listes de jeux
- Configure automatiquement les contrôles si votre manette est reconnue
- **Contrôles cassés ?** Supprimez `/saves/ports/rgsx/controls.json` puis redémarrez
**Mode clavier** : lorsqu'aucune manette n'est détectée, les contrôles s'affichent sous forme de `[Touche]` au lieu d'icônes.
### Structure du menu pause
**Catégories racine**
- Jeux (téléchargements, scans, visibilité des plateformes)
- Langue (changer la langue de l'interface)
- Contrôles (aide et remap)
- Affichage (layout, polices, moniteur/mode, options visuelles)
- Paramètres (musique, symlink, extraction auto, réseau et statut API)
- Support (génération d'une archive support ZIP/logs)
- Quitter (quitter ou redémarrer)
**Contrôles**
- Voir l'aide des contrôles
- Remapper les contrôles
- Voir l'aide des contrôles (affiche les actions actuellement mappées)
- Remapper les contrôles (reconfigurer clavier/manette)
**Affichage**
- Disposition (3×3, 3×4, 4×3, 4×4)
- Taille de police (UI générale)
- Taille de police du footer (texte des contrôles/version)
- Famille de police (polices pixel)
- Masquer l'avertissement d'extension inconnue
- Layout (3×3, 3×4, 4×3, 4×4)
- Sous-menu taille de police (UI générale + texte du footer)
- Famille de police (Pixel ou DejaVu)
- Sélection du moniteur (quand plusieurs moniteurs sont détectés)
- Mode d'écran (Windows uniquement)
- Mode léger (rendu plus performant)
- Masquer l'avertissement d'extension inconnue (toggle des warnings d'extensions non supportées)
**Jeux**
- Historique des téléchargements
+- Mode des sources (RGSX / Personnalisé)
- Mettre à jour le cache des jeux
- Afficher les plateformes non supportées
- Masquer les systèmes premium
- Filtrer les plateformes
- Mettre à jour le cache des jeux (retélécharger les données systèmes/jeux)
- Scanner les ROMs possédées (ajouter vos ROMs locales à l'historique)
- Historique des téléchargements (consulter/gérer les entrées)
- Afficher les plateformes non supportées (toggle des plateformes sans dossier ROM local)
- Filtrer les plateformes (menu de visibilité source/plateforme)
**Paramètres**
- Musique de fond (on/off)
- Options de symlink (Batocera)
- Service web (Batocera)
- Gestion des clés API
- Sélection de la langue
- Musique de fond (activer/désactiver)
- Options de symlink (choisir copie/symlink)
- Extraction auto (activation/désactivation)
- Sélecteur du dossier ROMs (définir un dossier ROM racine personnalisé)
- Service Web (Batocera/Knulli) (démarrer l'interface web au boot)
- DNS personnalisé (Batocera/Knulli) (contourner certains blocages ISP/domaine)
- Statut des clés API (vérifier la présence des clés providers)
- Statut de connexion (tester les sites requis updates/sources)
---
## ✨ Fonctionnalités
- 🎯 **Détection intelligente des systèmes** couverte automatique des systèmes supportés depuis `es_systems.cfg`
- 📦 **Gestion intelligente des archives** Extraction automatique quand un système ne supporte pas les fichiers ZIP
- 🔑 **Débloquage premium** API 1Fichier + fallback AllDebrid/Real-Debrid pour des téléchargements illimités
- 🎨 **Entièrement personnalisable** Disposition (3×3 à 4×4), polices, tailles de police (UI + footer), langues (EN/FR/DE/ES/IT/PT)
- 🎮 **Pensé manette d'abord** Auto-mapping pour les manettes populaires + remapping personnalisé
- 🔍 **Filtrage avancé** Recherche par nom, affichage/masquage des systèmes non supportés, filtre de plateformes
- 🎯 **Détection intelligente des systèmes** tecte automatiquement les systèmes supportés depuis `es_systems.cfg`
- 📦 **Gestion intelligente des archives** Extrait automatiquement les archives quand un système ne supporte pas les ZIP
- 🔑 **Déblocage premium** API 1Fichier + fallback AllDebrid/Debrid-Link/Real-Debrid pour des téléchargements illimités
- 🎨 **Personnalisation complète** Layout (3×3 à 4×4), polices, tailles de police (UI + footer), langues (EN/FR/DE/ES/IT/PT)
- 🎮 **Pensé manette avant tout** Auto-mapping pour les manettes populaires + remapping personnalisé
- 🔍 **Filtrage avancé** Recherche par nom, afficher/masquer les systèmes non supportés, filtre de plateformes
- 📊 **Gestion des téléchargements** File d'attente, historique, notifications de progression
- 🌐 **Sources personnalisées** Utilisez vos propres URLs de dépôt de jeux
-**Accessibilité** Échelles de police séparées pour l'UI et le footer, support du mode clavier seul
- **Accessibilité** Échelle de police séparée pour l'UI et le footer, support du mode clavier uniquement
> ### 🔑 Configuration des clés API
> Pour des téléchargements 1Fichier illimités, ajoutez vos clés API dans `/saves/ports/rgsx/` :
> - `1FichierAPI.txt` Clé API 1Fichier (recommandé)
> - `AllDebridAPI.txt` Fallback AllDebrid (optionnel)
> - `RealDebridAPI.txt` Fallback Real-Debrid (optionnel)
> - `1FichierAPI.txt` clé API 1Fichier (recommandé)
> - `AllDebridAPI.txt` fallback AllDebrid (optionnel)
> - `DebridLinkAPI.txt` fallback Debrid-Link (optionnel)
> - `RealDebridAPI.txt` fallback Real-Debrid (optionnel)
>
> **Chaque fichier ne doit contenir QUE la clé, sans texte supplémentaire.**
> **Chaque fichier doit contenir UNIQUEMENT la clé, sans texte supplémentaire.**
### Télécharger des jeux
1. Parcourez les plateformes → sélectionnez un jeu
1. Parcourez les plateformes → Sélectionnez un jeu
2. **Téléchargement direct** : appuyez sur `Confirmer`
3. **Ajout à la file d'attente** : appuyez sur `X` (bouton Ouest)
4. Suivez la progression dans le menu **Historique** ou via les popups de notification
3. **File d'attente** : appuyez sur `X` (bouton Ouest)
4. Suivez la progression dans le menu **Historique** ou via les notifications popup
### Sources de jeux personnalisées
## 🌐 Interface Web (Batocera/Knulli uniquement)
Basculez vers les sources personnalisées via **Menu pause > Jeux > Mode des sources**.
Configurez dans `/saves/ports/rgsx/rgsx_settings.json` :
```json
{
"sources": {
"mode": "custom",
"custom_url": "https://example.com/my-sources.zip"
}
}
```
**Note** : si le mode personnalisé est activé mais que l'URL est invalide/vide = utilisation de `/saves/ports/rgsx/games.zip`. Vous devez mettre à jour le cache des jeux dans le menu RGSX après avoir corrigé l'URL.
---
## 🌐 Interface web (Batocera/Knulli uniquement)
RGSX inclut une interface web qui se lance automatiquement avec RGSX pour parcourir et télécharger des jeux à distance depuis n'importe quel appareil de votre réseau.
RGSX inclut une interface web qui se lance automatiquement quand vous utilisez RGSX, pour parcourir et télécharger des jeux à distance depuis n'importe quel appareil de votre réseau.
### Accéder à l'interface web
1. **Trouvez l'adresse IP de votre Batocera** :
- Dans le menu Batocera : `Paramètres réseau`
1. **Trouvez l'IP de votre Batocera** :
- Vérifiez dans le menu Batocera : `Paramètres réseau`
- Ou depuis un terminal : `ip addr show`
2. **Ouvrez dans un navigateur** : `http://[IP_BATO]:5000` ou `http://BATOCERA:5000`
2. **Ouvrez dans un navigateur** : `http://[IP_BATOCERA]:5000` ou `http://BATOCERA:5000`
- Exemple : `http://192.168.1.100:5000`
3. **Accessible depuis n'importe quel appareil** : téléphone, tablette, PC sur le même réseau
3. **Disponible depuis n'importe quel appareil** : téléphone, tablette, PC sur le même réseau
### Fonctionnalités de l'interface web
- 📱 **Compatible mobile** Design responsive qui fonctionne sur tous les écrans
- 🔍 **Parcourir tous les systèmes** Voir toutes les plateformes et les jeux
- ⬇️ **Téléchargements à distance** Ajouter des téléchargements directement sur votre Batocera
- 📱 **Compatible mobile** Design responsive sur tous les formats d'écran
- 🔍 **Parcourir tous les systèmes** Voir toutes les plateformes et jeux
- ⬇️ **Téléchargements à distance** Ajouter des téléchargements directement vers Batocera
- 📊 **Statut en temps réel** Voir les téléchargements actifs et l'historique
- 🎮 **Même liste de jeux** Utilise les mêmes sources que l'application principale
@@ -157,42 +153,52 @@ RGSX inclut une interface web qui se lance automatiquement avec RGSX pour parcou
**Depuis le menu RGSX**
1. Ouvrez le **menu pause** (Start/ALTGr)
2. Allez dans **Paramètres > Service web**
3. Basculez sur **Activer au démarrage**
2. Allez dans **Paramètres > Service Web**
3. Activez/Désactivez **Activer au démarrage**
4. Redémarrez votre appareil
**Configuration du port** : le service web utilise le port `5000` par défaut. Assurez-vous qu'il n'est pas bloqué par un pare-feu.
**Configuration du port** : le service web utilise le port `5000` par défaut. Assurez-vous que ce port n'est pas bloqué par votre pare-feu.
---
## 📁 Structure des fichiers
```
/roms/ports/RGSX/
├── __main__.py # Point d'entrée
├── controls.py # Gestion des entrées
├── display.py # Moteur de rendu
├── network.py # Gestionnaire de téléchargements
├── rgsx_settings.py # Gestionnaire de paramètres
├── assets/controls/ # Profils de manettes
├── languages/ # Traductions (EN/FR/DE/ES/IT/PT)
└── logs/RGSX.log # Logs d'exécution
/roms/windows/RGSX/
└── RGSX Retrobat.bat # Lanceur RetroBat
/roms/
├── ports/
│ ├── RGSX/
│ │ ├── __main__.py # Point d'entrée
│ │ ├── controls.py # Gestion des entrées
│ │ ├── display.py # Moteur de rendu
│ │ ├── network.py # Gestionnaire de téléchargements
├── rgsx_settings.py # Gestionnaire des paramètres
│ │ ├── assets/controls/ # Profils de manettes
│ │ ├── languages/ # Traductions (EN/FR/DE/ES/IT/PT)
│ │ └── logs/RGSX.log # Logs d'exécution
│ ├── gamelist.xml
│ ├── images/
│ └── videos/
└── windows/
├── RGSX Retrobat.bat # Lanceur Windows uniquement (utilisable même sans RetroBat)
├── gamelist.xml
├── images/
└── videos/
/saves/ports/rgsx/
├── rgsx_settings.json # Préférences utilisateur
├── controls.json # Mappage des contrôles
├── controls.json # Mapping des contrôles
├── history.json # Historique des téléchargements
├── rom_extensions.json # Cache des extensions supportées
├── systems_list.json # Systèmes détectés
├── games/ # Bases de données de jeux (par plateforme)
├── images/ # Images des plateformes
├── 1FichierAPI.txt # Clé API 1Fichier
├── AllDebridAPI.txt # Clé API AllDebrid
── RealDebridAPI.txt # Clé API Real-Debrid
├── global_search_index.json # Cache de l'index de recherche globale
├── platform_games_count_cache.json
├── torrent_manifest_cache.json
├── games/ # Bases de données des jeux (par plateforme)
── images/ # Images de plateformes
├── 1FichierAPI.txt # Clé API 1Fichier
├── AllDebridAPI.txt # Clé API AllDebrid
├── DebridLinkAPI.txt # Clé API Debrid-Link
└── RealDebridAPI.txt # Clé API Real-Debrid
```
---
@@ -201,36 +207,37 @@ RGSX inclut une interface web qui se lance automatiquement avec RGSX pour parcou
| Problème | Solution |
|----------|----------|
| Contrôles qui ne répondent plus | Supprimer `/saves/ports/rgsx/controls.json` + redémarrer |
| Jeux non affichés | Menu pause > Jeux > Mettre à jour le cache des jeux |
| Téléchargement bloqué | Vérifier les clés API dans `/saves/ports/rgsx/` |
| Crash de l'application | Vérifier `/roms/ports/RGSX/logs/RGSX.log` |
| Changement de layout non pris en compte | Redémarrer RGSX après modification du layout |
| Les contrôles ne fonctionnent pas | Supprimez `/saves/ports/rgsx/controls.json` puis redémarrez, vous pouvez aussi supprimer `/roms/ports/RGSX/assets/controls/xx.json` |
| Aucun jeu ? | Menu Pause > Jeux > Mettre à jour le cache des jeux, puis vérifier Menu Pause > Jeux > Filtrer les plateformes et Afficher les plateformes non supportées |
| Des systèmes manquent dans la liste ? | RGSX lit `es_systems.cfg` pour afficher uniquement les systèmes supportés. Si vous voulez tous les systèmes : Menu Pause > Jeux > Afficher les plateformes non supportées |
| L'application crash | Vérifiez `/roms/ports/RGSX/logs/RGSX.log` ou `/roms/windows/logs/Retrobat_RGSX_log.txt` |
| Changement de layout non appliqué | Redémarrez RGSX après modification du layout |
| Problème de téléchargement de certains jeux ? | Ouvrez Menu Pause > Paramètres > Statut de connexion. Si un ou plusieurs sites requis sont en rouge, activez DNS personnalisé dans Paramètres et redémarrez. Vérifiez aussi les protections ISP/routeur (notamment ASUS web threat blocking). |
**Besoin d'aide ?** Partagez les logs depuis `/roms/ports/RGSX/logs/` sur [Discord](https://discord.gg/Vph9jwg3VV).
**Besoin d'aide ?** Partagez les logs de `/roms/ports/RGSX/logs/` sur [Discord](https://discord.gg/Vph9jwg3VV).
---
## 🤝 Contribution
- **Rapports de bugs** : ouvrez une issue GitHub avec les logs ou postez sur Discord
- **Rapports de bugs** : ouvrez une issue GitHub avec les logs, ou postez sur Discord
- **Demandes de fonctionnalités** : discutez d'abord sur Discord, puis ouvrez une issue
- **Contributions de code** :
- **Contributions code** :
```bash
git checkout -b feature/your-feature
# Testez sur Batocera/RetroBat
# Soumettez une Pull Request
# Tester sur Batocera/RetroBat
# Soumettre une Pull Request
```
---
## 📝 Licence
Logiciel libre et open-source. Utilisation, modification et distribution autorisées librement.
Logiciel gratuit et open-source. Utilisation, modification et distribution libres.
## Merci à tous les contributeurs et aux personnes qui suivent l'application
## Merci à tous les contributeurs et suiveurs du projet
**Si vous voulez soutenir mon projet, vous pouvez m'offrir une bière : https://bit.ly/donate-to-rgsx**
[![Stargazers over time](https://starchart.cc/RetroGameSets/RGSX.svg?variant=adaptive)](https://starchart.cc/RetroGameSets/RGSX)
**Développé avec ❤️ pour la communauté du retrogaming.**
**Développé avec ❤️ pour la communauté retrogaming.**

View File

@@ -83,7 +83,7 @@ from display import (
draw_toast, show_toast, THEME_COLORS, sync_display_metrics
)
from language import _
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, apply_pending_update, cancel_all_downloads, download_queue_worker
from network import test_internet, download_rom, is_1fichier_url, download_from_1fichier, check_for_updates, apply_pending_update, cancel_all_downloads, shutdown_downloads, download_queue_worker
from controls import handle_controls, validate_menu_state, process_key_repeats, get_emergency_controls
from controls_mapper import map_controls, draw_controls_mapping, get_actions
from controls import load_controls_config
@@ -91,19 +91,27 @@ from utils import (
load_sources, check_extension_before_download, extract_data,
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 history import load_history, save_history, load_downloaded_games, check_history_write_access, get_history_write_status
from config import OTA_data_ZIP
from rgsx_settings import get_sources_mode, get_custom_sources_url, get_sources_zip_url, get_display_fullscreen
from accessibility import load_accessibility_settings
# Configuration du logging
# RotatingFileHandler : 20 MB max par fichier, 2 backups → 60 MB total maximum.
# Évite les fichiers RGSX.log de 1.5 GB causés par le flood aria2c debug (résolu
# par aria2_trace_enabled=False dans network.py, mais la rotation sert de garde-fou).
try:
os.makedirs(config.log_dir, exist_ok=True)
logging.basicConfig(
filename=config.log_file,
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
from logging.handlers import RotatingFileHandler as _RotatingFileHandler
_log_handler = _RotatingFileHandler(
config.log_file,
maxBytes=20 * 1024 * 1024, # 20 MB
backupCount=2,
encoding='utf-8',
)
_log_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
logging.root.setLevel(logging.DEBUG)
logging.root.addHandler(_log_handler)
except Exception as e:
logging.basicConfig(
level=logging.DEBUG,
@@ -209,6 +217,14 @@ except Exception:
# fallback: si l'import ou la lecture échoue, conserver la valeur par défaut dans config
logger.debug("Impossible de charger nintendo_layout depuis rgsx_settings")
# Charger la limite de téléchargements simultanés depuis les settings
try:
from rgsx_settings import get_max_simultaneous_downloads
config.max_simultaneous_downloads = get_max_simultaneous_downloads()
logger.debug(f"max_simultaneous_downloads initial: {config.max_simultaneous_downloads}")
except Exception:
logger.debug("Impossible de charger max_simultaneous_downloads depuis rgsx_settings")
# Détection du système grace a une commande windows / linux (on oublie is non-pc c'est juste pour connaitre le materiel et le systeme d'exploitation)
def detect_system_info():
"""Détecte les informations système (OS, architecture) via des commandes appropriées."""
@@ -335,6 +351,23 @@ config.current_music = current_music # Met à jour la musique en cours dans con
config.history = load_history()
logger.debug(f"Historique de téléchargement : {len(config.history)} entrées")
# Vérifier explicitement la capacité d'écriture de history.json
history_write_ok, history_write_error_probe = check_history_write_access(force=True)
if not history_write_ok:
history_write_status = get_history_write_status() or {}
history_write_message = history_write_status.get("message") or (
"Erreur ecriture history.json. "
"Le telechargement continue sans historique temps reel."
)
logger.error(history_write_message)
config.popup_message = history_write_message
config.popup_timer = max(int(getattr(config, 'popup_timer', 0) or 0), 5000)
config.needs_redraw = True
try:
show_toast(history_write_message, duration=5000)
except Exception:
pass
# Chargement des jeux téléchargés
config.downloaded_games = load_downloaded_games()
@@ -508,7 +541,34 @@ async def main():
# Démarrer le worker de la queue de téléchargement
queue_worker_thread = threading.Thread(target=download_queue_worker, daemon=True)
queue_worker_thread.start()
# Reprendre les téléchargements interrompus (statut "Téléchargement"/"Downloading")
# Ces entrées proviennent d'une session précédente fermée proprement sans annulation.
try:
interrupted = [
e for e in config.history
if e.get("status") in ("Téléchargement", "Downloading") and e.get("url")
]
if interrupted:
logger.info(f"[RESUME] {len(interrupted)} téléchargement(s) interrompu(s) détecté(s), reprise...")
for entry in interrupted:
_url = entry["url"]
_platform = entry.get("platform", "")
_game_name = entry.get("game_name", "")
_task_id = f"resume_{pygame.time.get_ticks()}_{id(entry)}"
_is_zip = entry.get("is_zip_non_supported", False)
logger.info(f"[RESUME] Reprise: {_game_name} ({_platform}) task_id={_task_id}")
if is_1fichier_url(_url):
_coro = download_from_1fichier(_url, _platform, _game_name, _is_zip, _task_id)
else:
_coro = download_rom(_url, _platform, _game_name, _is_zip, _task_id)
config.download_tasks[_task_id] = (
asyncio.ensure_future(_coro),
_url, _game_name, _platform
)
except Exception as _e:
logger.error(f"[RESUME] Erreur lors de la reprise des téléchargements: {_e}")
running = True
loading_step = "none"
ota_update_task = None
@@ -1724,11 +1784,12 @@ async def main():
pygame.mixer.music.stop()
except (AttributeError, NotImplementedError):
pass
# Cancel any ongoing downloads to prevent lingering background threads
# Quitter proprement : vider la queue sans annuler les téléchargements actifs
# (les threads daemon s'arrêtent avec Python, l'historique reste en 'Téléchargement')
try:
cancel_all_downloads()
shutdown_downloads()
except Exception as e:
logger.debug(f"Erreur lors de l'annulation globale des téléchargements: {e}")
logger.debug(f"Erreur lors du shutdown des téléchargements: {e}")
# Arrêter le serveur web
stop_web_server()

View File

@@ -0,0 +1 @@
ea388d0d46cd18c3606b1abdba68790b6d7f66ee19ce3bb4f99a26fadafcc77a

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,20 @@
<svg width="128" height="128" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc" shape-rendering="geometricPrecision">
<title id="title">EdgeEmu icon</title>
<desc id="desc">EdgeEmu favicon-inspired glyph with bold stacked blocks and cyan accents.</desc>
<g fill="#1f1f1f">
<rect x="6" y="8" width="42" height="24" rx="3"/>
<rect x="6" y="38" width="42" height="24" rx="3"/>
<rect x="6" y="68" width="42" height="24" rx="3"/>
<rect x="56" y="10" width="38" height="16" rx="3"/>
<rect x="56" y="42" width="38" height="16" rx="3"/>
<rect x="56" y="74" width="38" height="16" rx="3"/>
</g>
<g fill="#0388ef">
<rect x="12" y="14" width="30" height="8" rx="1.5"/>
<rect x="12" y="44" width="30" height="12" rx="1.5"/>
<rect x="12" y="76" width="30" height="8" rx="1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

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

After

Width:  |  Height:  |  Size: 1006 B

View File

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

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -27,7 +27,7 @@ except Exception:
pygame = None # type: ignore
# Version actuelle de l'application
app_version = "2.6.3.1"
app_version = "2.6.4.1"
# Nombre de jours avant de proposer la mise à jour de la liste des jeux
GAMELIST_UPDATE_DAYS = 1
@@ -151,8 +151,12 @@ logger = logging.getLogger(__name__)
# File d'attente de téléchargements (jobs en attente)
download_queue = [] # Liste de dicts: {url, platform, game_name, ...}
pending_download_is_queue = False # Indique si pending_download doit être ajouté à la queue
# Indique si un téléchargement est en cours
# Indique si un téléchargement est en cours (True quand active_download_count > 0)
download_active = False
# Nombre de téléchargements actuellement actifs (mis à jour par controls.py et network.py)
active_download_count = 0
# Limite de téléchargements simultanés (chargée depuis rgsx_settings au démarrage, défaut 5)
max_simultaneous_downloads = 5
# Cache status de connexion (menu pause > settings)
connection_status = {}
@@ -205,6 +209,7 @@ API_KEY_ALLDEBRID_PATH = os.path.join(SAVE_FOLDER, "AllDebridAPI.txt")
API_KEY_DEBRIDLINK_PATH = os.path.join(SAVE_FOLDER, "DebridLinkAPI.txt")
API_KEY_REALDEBRID_PATH = os.path.join(SAVE_FOLDER, "RealDebridAPI.txt")
ARCHIVE_ORG_COOKIE_PATH = os.path.join(APP_FOLDER, "assets", "ArchiveOrgCookie.txt")
THEGAMESDB_API_KEY_PATH = os.path.join(APP_FOLDER, "assets", "TheGamesDBAPI.txt")
@@ -221,6 +226,54 @@ OTA_VERSION_ENDPOINT = "https://raw.githubusercontent.com/RetroGameSets/RGSX/ref
OTA_SERVER_URL = "https://retrogamesets.fr/softs/"
OTA_data_ZIP = os.path.join(OTA_SERVER_URL, "games.zip")
# Sites testés dans le menu pause_connection_status.
# Pour personnaliser: modifier cette liste (ajouter/supprimer/changer URL, label, catégorie).
# Catégories conseillées: "updates" et "sources".
CONNECTION_STATUS_TARGETS = [
{
"key": "retrogamesets",
"label": "RetroGameSets",
"url": "https://retrogamesets.fr",
"category": "updates",
},
{
"key": "github",
"label": "GitHub",
"url": "https://github.com",
"category": "updates",
},
{
"key": "archive",
"label": "Archive.org",
"url": "https://archive.org",
"category": "sources",
},
{
"key": "1fichier",
"label": "1fichier",
"url": "https://1fichier.com",
"category": "sources",
},
{
"key": "lolroms",
"label": "LolRoms",
"url": "https://lolroms.com",
"category": "sources",
},
{
"key": "vimms",
"label": "Vimms Lair",
"url": "https://vimm.net/",
"category": "sources",
},
{
"key": "edgeemu",
"label": "EdgeEmu",
"url": "https://edgeemu.net",
"category": "sources",
},
]
#CHEMINS DES EXECUTABLES
UNRAR_EXE = os.path.join(APP_FOLDER,"assets","progs","unrar.exe")
XISO_EXE = os.path.join(APP_FOLDER,"assets", "progs", "extract-xiso_win.exe")
@@ -230,7 +283,7 @@ 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")
ARIA2C_LINUX = os.path.join(APP_FOLDER,"assets", "progs", "aria2c_linux")
# Détection du système d'exploitation (une seule fois au démarrage)
OPERATING_SYSTEM = platform.system()
@@ -406,7 +459,9 @@ platform_dicts = [] # Liste des dictionnaires de plateformes
selected_filter_index = 0 # index dans la liste visible triée
filter_platforms_scroll_offset = 0 # défilement si liste longue
filter_platforms_dirty = False # indique si modifications non sauvegardées
filter_platforms_selection = [] # copie de travail des plateformes visibles (bool masque?) structure: list of (name, hidden_bool)
filter_platforms_selection = [] # copie de travail: list[(platform_name, is_hidden)]
filter_platforms_source_map = {} # mapping source -> liste des plateformes associées
filter_platforms_expanded_sources = [] # sources ouvertes dans la vue collapsible
# Affichage des jeux et sélection
games: list[Game] = [] # Liste des jeux pour la plateforme actuelle
@@ -472,6 +527,11 @@ history = [] # Liste des entrées d'historique avec platform, game_name, status
pending_download = None # Objet de téléchargement en attente
download_progress = {} # Dictionnaire de progression des téléchargements actifs
download_tasks = {} # Dictionnaire pour les tâches de téléchargement
history_write_ok = True # Etat courant de l'ecriture de history.json
history_write_error = "" # Message explicite si l'ecriture de history.json echoue
history_write_failure_count = 0 # Nombre d'echecs consecutifs d'ecriture
history_write_last_failure_ts = 0.0 # Timestamp du dernier echec d'ecriture
history_write_last_toast_at = 0.0 # Anti-spam pour les toasts d'erreur d'ecriture
download_result_message = "" # Message de résultat du dernier téléchargement
download_result_error = False # Indicateur d'erreur pour le résultat de téléchargement
download_result_start_time = 0 # Timestamp de début du résultat affiché

View File

@@ -10,7 +10,7 @@ import logging
import config
from config import REPEAT_DELAY, REPEAT_INTERVAL, REPEAT_ACTION_DEBOUNCE, CONTROLS_CONFIG_PATH, Game
from display import draw_validation_transition, show_toast
from network import download_rom, download_from_1fichier, is_1fichier_url, request_cancel
from network import download_rom, download_from_1fichier, is_1fichier_url, request_cancel, cleanup_torrent_temp
from utils import (
load_games, check_extension_before_download, is_extension_supported,
load_extensions_json, play_random_music, sanitize_filename,
@@ -18,7 +18,7 @@ from utils import (
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,
start_connection_status_check, get_clean_display_name, get_existing_history_matches, remember_history_local_match,
clear_torrent_manifest_cache,
request_torrent_manifest_refresh,
clear_platform_game_count_cache,
@@ -32,8 +32,7 @@ from language import _, get_available_languages, set_language
from rgsx_settings import (
get_allow_unknown_extensions, set_display_grid, get_font_family, set_font_family,
get_show_unsupported_platforms, set_show_unsupported_platforms,
set_allow_unknown_extensions, get_hide_premium_systems, set_hide_premium_systems,
get_sources_mode, set_sources_mode, set_symlink_option, get_symlink_option,
set_allow_unknown_extensions, set_symlink_option, get_symlink_option,
get_global_sort_option, set_global_sort_option,
load_rgsx_settings, save_rgsx_settings
)
@@ -56,24 +55,14 @@ GLOBAL_SORT_OPTIONS = [
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'}")
# Fonction devenue inutile, ne fait plus rien
pass
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
@@ -129,6 +118,72 @@ def _apply_sorted_active_filters() -> list[Game]:
return config.games
def _is_windows_os() -> bool:
return str(getattr(config, 'OPERATING_SYSTEM', '') or '').lower() == "windows" or os.name == 'nt'
def _is_windows_drive_root(path: str) -> bool:
if not _is_windows_os() or not path:
return False
normalized = os.path.normpath(path)
drive, tail = os.path.splitdrive(normalized)
return bool(drive) and tail in ('\\', '/')
def _get_available_windows_drives() -> list[str]:
drives = []
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
drive = f"{letter}:\\"
if os.path.isdir(drive):
drives.append(drive)
return drives
def _load_folder_browser_items(path: str) -> list[str]:
if _is_windows_os() and not path:
return _get_available_windows_drives()
target_path = path
if not target_path:
target_path = "/"
items = [".."]
try:
for item in sorted(os.listdir(target_path)):
full_path = os.path.join(target_path, item)
if os.path.isdir(full_path):
items.append(item)
except Exception as e:
logger.error(f"Erreur lecture dossier {target_path}: {e}")
return [".."] if target_path else []
return items
def _set_folder_browser_location(path: str | None, reset_selection: bool = True) -> None:
if _is_windows_os():
normalized_path = os.path.normpath(path) if path else ""
if normalized_path in ('\\', '/'):
normalized_path = ""
if normalized_path and not os.path.isdir(normalized_path):
normalized_path = ""
else:
normalized_path = path or "/"
if not os.path.isdir(normalized_path):
normalized_path = "/"
config.folder_browser_path = normalized_path
config.folder_browser_items = _load_folder_browser_items(normalized_path)
if reset_selection:
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
else:
max_index = max(0, len(config.folder_browser_items) - 1)
config.folder_browser_selection = max(0, min(config.folder_browser_selection, max_index))
max_scroll = max(0, len(config.folder_browser_items) - max(1, int(getattr(config, 'folder_browser_visible_items', 10) or 10)))
config.folder_browser_scroll_offset = max(0, min(config.folder_browser_scroll_offset, max_scroll))
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")
@@ -491,23 +546,29 @@ def is_global_search_input_matched(event, action_name):
return False
def _launch_next_queued_download():
"""Lance le prochain téléchargement de la queue si aucun n'est actif.
Gère la liaison entre le système Desktop et le système de download_rom/download_from_1fichier.
def _launch_next_queued_download(force: bool = False):
"""Lance le(s) prochain(s) téléchargement(s) de la queue selon les slots disponibles.
Si force=True, ignore la limite (Force Download depuis l'UI).
Peut être appelée plusieurs fois pour remplir tous les slots libres.
"""
if config.download_active or not config.download_queue:
max_dl = getattr(config, 'max_simultaneous_downloads', 5)
active = getattr(config, 'active_download_count', 0)
if not force and active >= max_dl:
return
if not config.download_queue:
return
queue_item = config.download_queue.pop(0)
config.active_download_count = active + 1
config.download_active = True
url = queue_item['url']
platform = queue_item['platform']
game_name = queue_item['game_name']
is_zip_non_supported = queue_item['is_zip_non_supported']
is_1fichier = queue_item['is_1fichier']
task_id = queue_item['task_id']
# Mettre à jour le statut dans l'historique: queued -> Downloading
for entry in config.history:
if entry.get('task_id') == task_id and entry.get('status') == 'Queued':
@@ -515,9 +576,9 @@ def _launch_next_queued_download():
entry['message'] = _("download_in_progress")
save_history(config.history)
break
logger.info(f"📋 Lancement du téléchargement de la queue: {game_name} (task_id={task_id})")
logger.info(f"📋 Lancement téléchargement (slot {config.active_download_count}/{max_dl}): {game_name} (task_id={task_id})")
# Lancer le téléchargement de manière asynchrone avec callback
try:
if is_1fichier:
@@ -537,17 +598,18 @@ def _launch_next_queued_download():
except Exception as e:
logger.error(f"Erreur tâche download {game_name}: {e}")
finally:
# Toujours marquer comme inactif et lancer le prochain
config.download_active = False
if config.download_queue:
_launch_next_queued_download()
# Décrémenter le compteur et lancer le prochain si queue non vide
config.active_download_count = max(0, getattr(config, 'active_download_count', 1) - 1)
config.download_active = config.active_download_count > 0
_launch_next_queued_download()
# Ajouter le callback à la tâche
task.add_done_callback(on_task_done)
except Exception as e:
logger.error(f"Erreur lancement queue download: {e}")
config.download_active = False
config.active_download_count = max(0, getattr(config, 'active_download_count', 1) - 1)
config.download_active = config.active_download_count > 0
# Mettre à jour l'historique en erreur
for entry in config.history:
if entry.get('task_id') == task_id:
@@ -556,8 +618,7 @@ def _launch_next_queued_download():
save_history(config.history)
break
# Relancer le suivant
if config.download_queue:
_launch_next_queued_download()
_launch_next_queued_download()
def filter_games_by_search_query() -> list[Game]:
base_games = config.games
@@ -936,8 +997,43 @@ def trigger_global_search_download(queue_only: bool = False) -> None:
config.needs_redraw = True
logger.debug(f"{game_name} ajoute a la file d'attente depuis la recherche globale. Queue size: {len(config.download_queue)}")
if not config.download_active and config.download_queue:
_launch_next_queued_download()
_launch_next_queued_download()
return
# Téléchargement direct, démarrer immédiatement,
# sinon mettre automatiquement en queue (même comportement que queue_only).
max_dl = getattr(config, 'max_simultaneous_downloads', 5)
active = getattr(config, 'active_download_count', 0)
if active >= max_dl:
# Plus de slots disponibles → auto-queue
task_id = str(pygame.time.get_ticks())
queue_item = {
'url': url,
'platform': platform,
'game_name': game_name,
'is_zip_non_supported': pending_download[3],
'is_1fichier': is_1fichier_url(url),
'task_id': task_id,
'status': 'Queued'
}
config.download_queue.append(queue_item)
config.history.append({
'platform': platform,
'game_name': game_name,
'display_name': display_name,
'status': 'Queued',
'url': url,
'progress': 0,
'message': _("download_queued"),
'timestamp': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'downloaded_size': 0,
'total_size': 0,
'task_id': task_id
})
save_history(config.history)
show_toast(f"{display_name}\n{_('download_queued')}")
config.needs_redraw = True
logger.info(f"{game_name} auto-queue (slots {active}/{max_dl} utilisés)")
return
if is_1fichier_url(url):
@@ -950,11 +1046,12 @@ def trigger_global_search_download(queue_only: bool = False) -> None:
task_id = str(pygame.time.get_ticks())
task = asyncio.create_task(download_rom(url, platform, game_name, pending_download[3], task_id))
config.active_download_count = getattr(config, 'active_download_count', 0) + 1
config.download_active = True
config.download_tasks[task_id] = (task, url, game_name, platform)
show_toast(f"{_('download_started')}: {display_name}")
config.needs_redraw = True
logger.debug(f"Telechargement demarre depuis la recherche globale: {game_name} pour {platform}, task_id={task_id}")
...
def handle_controls(event, sources, joystick, screen):
"""Gère un événement clavier/joystick/souris et la répétition automatique.
@@ -1512,8 +1609,7 @@ def handle_controls(event, sources, joystick, screen):
logger.debug(f"{game_name} ajouté à la file d'attente. Queue size: {len(config.download_queue)}")
# Si aucun téléchargement actif, lancer le premier de la queue
if not config.download_active and config.download_queue:
_launch_next_queued_download()
_launch_next_queued_download()
else:
logger.error(f"config.pending_download est None pour {game_name}")
config.needs_redraw = True
@@ -1726,6 +1822,14 @@ def handle_controls(event, sources, joystick, screen):
except Exception as e:
logger.debug(f"Erreur lors de l'envoi du signal d'annulation: {e}")
# Supprimer le dossier temp torrent (cas où cancel_events est vide :
# téléchargement déjà terminé côté thread mais UI encore "Téléchargement").
try:
if cleanup_torrent_temp(task_id):
logger.debug(f"Dossier temp torrent supprimé pour task_id={task_id}")
except Exception as e:
logger.debug(f"Erreur nettoyage temp torrent: {e}")
# Annuler aussi la tâche asyncio si elle existe (pour les téléchargements directs)
for tid, (task, task_url, tname, tplatform) in list(config.download_tasks.items()):
if tid == task_id or task_url == url:
@@ -1808,11 +1912,29 @@ def handle_controls(event, sources, joystick, screen):
base_path = os.path.join(config.ROMS_FOLDER, dest_folder)
file_exists, actual_filename, actual_path = find_file_with_or_without_extension(base_path, game_name)
actual_matches = find_matching_files(base_path, game_name)
local_path = entry.get("local_path")
local_filename = entry.get("local_filename")
if not file_exists and local_path and os.path.isfile(local_path):
actual_filename = os.path.basename(local_path)
actual_path = local_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS] direct local_path match used: %s", actual_path)
elif not file_exists and local_filename:
local_filename_path = os.path.join(base_path, str(local_filename))
if os.path.isfile(local_filename_path):
actual_filename = os.path.basename(local_filename_path)
actual_path = local_filename_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS] direct local_filename match used: %s", actual_path)
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
if actual_matches:
actual_filename, actual_path = actual_matches[0]
file_exists = True
if file_exists and actual_path:
remember_history_local_match(entry, actual_filename, actual_path)
config.history_actual_matches = actual_matches
# Stocker les informations pour les autres handlers
@@ -1828,6 +1950,7 @@ def handle_controls(event, sources, joystick, screen):
# Options selon statut
if status == "Queued":
# En attente dans la queue
options.append("force_download")
options.append("remove_from_queue")
elif status in ["Downloading", "Téléchargement", "Extracting", "Paused"]:
# Téléchargement en cours ou en pause - ajouter pause/resume avant cancel
@@ -1852,6 +1975,31 @@ def handle_controls(event, sources, joystick, screen):
# Option commune: retour
options.append("back")
diagnostics_signature = (
entry.get("url", ""),
status,
file_exists,
actual_filename or "",
actual_path or "",
tuple(options),
)
if getattr(config, 'history_options_diagnostics_signature', None) != diagnostics_signature:
config.history_options_diagnostics_signature = diagnostics_signature
logger.debug(
"[HISTORY_OPTIONS] platform=%s game=%s status=%s dest_folder=%s base_path=%s file_exists=%s actual_filename=%s actual_path=%s local_path=%s moved_paths=%s options=%s",
platform,
game_name,
status,
dest_folder,
base_path,
file_exists,
actual_filename,
actual_path,
entry.get("local_path"),
entry.get("moved_paths"),
options,
)
total_options = len(options)
sel = getattr(config, 'history_game_option_selection', 0)
@@ -1876,7 +2024,22 @@ def handle_controls(event, sources, joystick, screen):
selected_option = options[sel]
logger.debug(f"history_game_options: CONFIRM option={selected_option}")
if selected_option == "remove_from_queue":
if selected_option == "force_download":
# Forcer le démarrage immédiat d'un téléchargement en file d'attente,
# en ignorant la limite de slots simultanés.
task_id = entry.get("task_id")
url = entry.get("url")
# Retirer cet item de la queue (où qu'il soit)
for i, qi in enumerate(config.download_queue):
if qi.get("task_id") == task_id or qi.get("url") == url:
config.download_queue.insert(0, config.download_queue.pop(i))
break
_launch_next_queued_download(force=True)
config.menu_state = "history"
config.needs_redraw = True
logger.info(f"Force Download: {game_name} (task_id={task_id})")
elif selected_option == "remove_from_queue":
# Retirer de la queue
task_id = entry.get("task_id")
url = entry.get("url")
@@ -2103,16 +2266,7 @@ def handle_controls(event, sources, joystick, screen):
config.folder_browser_mode = "history_move"
config.platform_config_name = entry.get("display_name") or get_clean_display_name(entry.get("game_name", ""), entry.get("platform", ""))
try:
items = [".."]
for item in sorted(os.listdir(start_path)):
full_path = os.path.join(start_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {start_path}: {e}")
config.folder_browser_items = [".."]
_set_folder_browser_location(start_path, reset_selection=True)
config.menu_state = "folder_browser"
config.needs_redraw = True
@@ -2362,16 +2516,8 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
if config.confirm_selection == 0: # Quit RGSX
# Mark all in-progress downloads as canceled in history
try:
for entry in getattr(config, 'history', []) or []:
if entry.get("status") in ["Downloading", "Téléchargement", "Extracting"]:
entry["status"] = "Canceled"
entry["progress"] = 0
entry["message"] = _("download_canceled") if _ else "Download canceled"
save_history(config.history)
except Exception:
pass
# Les téléchargements actifs restent en état "Téléchargement" dans l'historique
# pour permettre la reprise au prochain démarrage (fichiers .aria2 conservés).
return "quit"
elif config.confirm_selection == 1: # Restart RGSX
restart_application(2000)
@@ -2763,7 +2909,7 @@ def handle_controls(event, sources, joystick, screen):
# Sous-menu Games
elif config.menu_state == "pause_games_menu":
sel = getattr(config, 'pause_games_selection', 0)
total = 8 # update cache, scan roms, history, source, unsupported, hide premium, filter, back
total = 6 # update cache, scan roms, history, unsupported, filter, back
if is_input_matched(event, "up"):
config.pause_games_selection = (sel - 1) % total
config.needs_redraw = True
@@ -2794,22 +2940,7 @@ def handle_controls(event, sources, joystick, screen):
config.previous_menu_state = "pause_games_menu"
config.menu_state = "history"
config.needs_redraw = True
elif sel == 3 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # source mode
try:
current_mode = get_sources_mode()
new_mode = set_sources_mode('custom' if current_mode == 'rgsx' else 'rgsx')
config.sources_mode = new_mode
if new_mode == 'custom':
config.popup_message = _("sources_mode_custom_select_info").format(config.RGSX_SETTINGS_PATH)
config.popup_timer = 10000
else:
config.popup_message = _("sources_mode_rgsx_select_info")
config.popup_timer = 4000
config.needs_redraw = True
logger.info(f"Changement du mode des sources vers {new_mode}")
except Exception as e:
logger.error(f"Erreur changement mode sources: {e}")
elif sel == 4 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # unsupported toggle
elif sel == 3 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # unsupported toggle
try:
current = get_show_unsupported_platforms()
new_val = set_show_unsupported_platforms(not current)
@@ -2819,22 +2950,15 @@ def handle_controls(event, sources, joystick, screen):
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle unsupported: {e}")
elif sel == 5 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")): # hide premium
try:
cur = get_hide_premium_systems()
new_val = set_hide_premium_systems(not cur)
config.popup_message = ("Premium hidden" if new_val else "Premium visible") if _ is None else (_("popup_hide_premium_on") if new_val else _("popup_hide_premium_off"))
config.popup_timer = 2500
config.needs_redraw = True
except Exception as e:
logger.error(f"Erreur toggle hide_premium_systems: {e}")
elif sel == 6 and is_input_matched(event, "confirm"): # filter platforms
elif sel == 4 and is_input_matched(event, "confirm"): # filter platforms
config.filter_return_to = "pause_games_menu"
config.menu_state = "filter_platforms"
config.selected_filter_index = 0
config.filter_platforms_scroll_offset = 0
config.filter_platforms_source_map = {}
config.filter_platforms_expanded_sources = []
config.needs_redraw = True
elif sel == 7 and is_input_matched(event, "confirm"): # back
elif sel == 5 and is_input_matched(event, "confirm"): # back
config.menu_state = "pause_menu"
config.last_state_change_time = pygame.time.get_ticks()
config.needs_redraw = True
@@ -2846,24 +2970,24 @@ def handle_controls(event, sources, joystick, screen):
# Sous-menu Settings
elif config.menu_state == "pause_settings_menu":
sel = getattr(config, 'pause_settings_selection', 0)
# Calculer le nombre total d'options selon le système
# Liste des options : music, symlink, auto_extract, roms_folder, [web_service], [custom_dns], api keys, connection_status, back
total = 7 # music, symlink, auto_extract, roms_folder, api keys, connection_status, back (Windows)
# Liste des options : music, symlink, auto_extract, roms_folder, max_dl_slots, [web_service], [custom_dns], api keys, connection_status, back
total = 8 # music, symlink, auto_extract, roms_folder, max_dl_slots, api keys, connection_status, back (Windows)
auto_extract_index = 2
roms_folder_index = 3
max_dl_index = 4
web_service_index = -1
custom_dns_index = -1
api_keys_index = 4
connection_status_index = 5
back_index = 6
api_keys_index = 5
connection_status_index = 6
back_index = 7
if config.OPERATING_SYSTEM == "Linux":
total = 9 # music, symlink, auto_extract, roms_folder, web_service, custom_dns, api keys, connection_status, back
web_service_index = 4
custom_dns_index = 5
api_keys_index = 6
connection_status_index = 7
back_index = 8
total = 10 # music, symlink, auto_extract, roms_folder, max_dl_slots, web_service, custom_dns, api keys, connection_status, back
web_service_index = 5
custom_dns_index = 6
api_keys_index = 7
connection_status_index = 8
back_index = 9
if is_input_matched(event, "up"):
config.pause_settings_selection = (sel - 1) % total
@@ -2917,17 +3041,7 @@ def handle_controls(event, sources, joystick, screen):
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
config.folder_browser_mode = "roms_root"
# Charger la liste des dossiers
try:
items = [".."]
for item in sorted(os.listdir(start_path)):
full_path = os.path.join(start_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {start_path}: {e}")
config.folder_browser_items = [".."]
_set_folder_browser_location(start_path, reset_selection=True)
config.menu_state = "folder_browser"
config.needs_redraw = True
logger.info("Ouverture navigateur dossier ROMs principal")
@@ -2941,7 +3055,21 @@ def handle_controls(event, sources, joystick, screen):
config.popup_timer = 5000
logger.info("Dossier ROMs réinitialisé par défaut")
config.needs_redraw = True
# Option 4: Web Service toggle (seulement si Linux)
# Option 4: Max simultaneous downloads (left/right pour -/+)
elif sel == max_dl_index and (is_input_matched(event, "left") or is_input_matched(event, "right") or is_input_matched(event, "confirm")):
from rgsx_settings import get_max_simultaneous_downloads, set_max_simultaneous_downloads
current_max = get_max_simultaneous_downloads()
if is_input_matched(event, "left"):
new_max = max(1, current_max - 1)
else:
new_max = min(10, current_max + 1)
if new_max != current_max:
set_max_simultaneous_downloads(new_max)
config.popup_message = _("settings_max_dl_changed").format(new_max) if _ else f"Max simultaneous downloads: {new_max}"
config.popup_timer = 1500
config.needs_redraw = True
logger.info(f"max_simultaneous_downloads réglé à {new_max}")
# Option 5: Web Service toggle (seulement si Linux)
elif sel == web_service_index and web_service_index >= 0 and (is_input_matched(event, "confirm") or is_input_matched(event, "left") or is_input_matched(event, "right")):
current_status = check_web_service_status()
@@ -3131,6 +3259,8 @@ def handle_controls(event, sources, joystick, screen):
config.menu_state = "filter_platforms"
config.selected_filter_index = 0
config.filter_platforms_scroll_offset = 0
config.filter_platforms_source_map = {}
config.filter_platforms_expanded_sources = []
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
config.menu_state = "pause_menu"
@@ -3241,17 +3371,7 @@ def handle_controls(event, sources, joystick, screen):
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
config.folder_browser_mode = "platform"
# Charger la liste des dossiers
try:
items = [".."]
for item in sorted(os.listdir(current_path)):
full_path = os.path.join(current_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {current_path}: {e}")
config.folder_browser_items = [".."]
_set_folder_browser_location(current_path, reset_selection=True)
config.menu_state = "folder_browser"
config.needs_redraw = True
elif config.platform_folder_selection == 2: # Reset
@@ -3307,44 +3427,31 @@ def handle_controls(event, sources, joystick, screen):
if config.folder_browser_items:
selected_item = config.folder_browser_items[config.folder_browser_selection]
if selected_item == "..":
# Remonter d'un niveau
parent = os.path.dirname(config.folder_browser_path)
if parent and parent != config.folder_browser_path:
config.folder_browser_path = parent
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
try:
items = [".."]
for item in sorted(os.listdir(parent)):
full_path = os.path.join(parent, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {parent}: {e}")
config.folder_browser_items = [".."]
current_path = config.folder_browser_path
if _is_windows_os() and _is_windows_drive_root(current_path):
parent = ""
else:
parent = os.path.dirname(current_path) if current_path else ("" if _is_windows_os() else "/")
if parent != current_path:
_set_folder_browser_location(parent, reset_selection=True)
else:
# Entrer dans le dossier sélectionné
new_path = os.path.join(config.folder_browser_path, selected_item)
if _is_windows_os() and not config.folder_browser_path:
new_path = selected_item
else:
new_path = os.path.join(config.folder_browser_path, selected_item)
if os.path.isdir(new_path):
config.folder_browser_path = new_path
config.folder_browser_selection = 0
config.folder_browser_scroll_offset = 0
try:
items = [".."]
for item in sorted(os.listdir(new_path)):
full_path = os.path.join(new_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
except Exception as e:
logger.error(f"Erreur lecture dossier {new_path}: {e}")
config.folder_browser_items = [".."]
_set_folder_browser_location(new_path, reset_selection=True)
config.needs_redraw = True
elif is_input_matched(event, "history"):
# Valider et sélectionner le dossier actuel (touche X/Y)
browser_mode = getattr(config, 'folder_browser_mode', 'platform')
selected_path = config.folder_browser_path
if not selected_path or not os.path.isdir(selected_path):
config.popup_message = "Please enter a drive/folder first"
config.popup_timer = 2200
config.needs_redraw = True
return action
if browser_mode == "roms_root":
# Mode dossier ROMs principal
@@ -3471,22 +3578,11 @@ def handle_controls(event, sources, joystick, screen):
logger.info(f"Dossier créé: {new_folder_path}")
config.popup_message = _("folder_created").format(folder_name) if _ else f"Folder created: {folder_name}"
config.popup_timer = 2000
# Rafraîchir la liste des dossiers et sélectionner le nouveau
try:
items = [".."]
for item in sorted(os.listdir(config.folder_browser_path)):
full_path = os.path.join(config.folder_browser_path, item)
if os.path.isdir(full_path):
items.append(item)
config.folder_browser_items = items
# Sélectionner le nouveau dossier
if folder_name in items:
config.folder_browser_selection = items.index(folder_name)
# Ajuster le scroll si nécessaire
if config.folder_browser_selection >= config.folder_browser_visible_items:
config.folder_browser_scroll_offset = config.folder_browser_selection - config.folder_browser_visible_items + 1
except Exception as e:
logger.error(f"Erreur rafraîchissement liste: {e}")
config.folder_browser_items = _load_folder_browser_items(config.folder_browser_path)
if folder_name in config.folder_browser_items:
config.folder_browser_selection = config.folder_browser_items.index(folder_name)
if config.folder_browser_selection >= config.folder_browser_visible_items:
config.folder_browser_scroll_offset = config.folder_browser_selection - config.folder_browser_visible_items + 1
except Exception as e:
logger.error(f"Erreur création dossier {new_folder_path}: {e}")
config.popup_message = _("folder_create_error").format(str(e)) if _ else f"Error: {e}"
@@ -3944,109 +4040,89 @@ def handle_controls(event, sources, joystick, screen):
# Menu filtre plateformes
elif config.menu_state == "filter_platforms":
total_items = len(config.filter_platforms_selection)
action_buttons = 4
# Indices: 0-3 = boutons, 4+ = liste des systèmes
extended_max = action_buttons + total_items - 1
if is_input_matched(event, "up"):
if config.selected_filter_index > 0:
config.selected_filter_index -= 1
config.needs_redraw = True
else:
# Wrap vers le bas (dernière ligne de la liste)
config.selected_filter_index = extended_max
config.needs_redraw = True
# Activer la répétition automatique
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)
elif is_input_matched(event, "down"):
if config.selected_filter_index < extended_max:
config.selected_filter_index += 1
config.needs_redraw = True
else:
# Wrap retour en haut (premier bouton)
def _extract_source(platform_name: str) -> str:
match = re.search(r'\(([^()]+)\)\s*$', str(platform_name).strip())
if match:
return match.group(1).strip()
fallback = _("games_source_rgsx") if _ else "RGSX"
return fallback if fallback != "games_source_rgsx" else "RGSX"
def _build_source_map() -> dict:
source_map_local = {}
for entry in config.platform_dicts:
platform_name = entry.get("platform_name", "") if isinstance(entry, dict) else ""
platform_name = str(platform_name).strip()
if not platform_name:
continue
source_name = _extract_source(platform_name)
source_map_local.setdefault(source_name, []).append(platform_name)
normalized = {}
for source_name in sorted(source_map_local.keys(), key=lambda s: str(s).lower()):
unique_names = sorted(set(source_map_local.get(source_name, [])), key=lambda s: str(s).lower())
normalized[source_name] = unique_names
return normalized
def _all_platform_names(source_map_local: dict) -> list:
names = []
for source_name in sorted(source_map_local.keys(), key=lambda s: str(s).lower()):
names.extend(source_map_local.get(source_name, []))
return names
def _ensure_working_selection(source_map_local: dict) -> None:
all_platform_names = _all_platform_names(source_map_local)
current = getattr(config, 'filter_platforms_selection', [])
current_map = {}
if isinstance(current, list):
for item in current:
if isinstance(item, (list, tuple)) and len(item) == 2:
name = str(item[0]).strip()
if name:
current_map[name] = bool(item[1])
expected_set = set(all_platform_names)
if set(current_map.keys()) != expected_set:
settings_local = load_rgsx_settings()
hidden_platforms = set(settings_local.get("hidden_platforms", [])) if isinstance(settings_local, dict) else set()
config.filter_platforms_selection = [(name, name in hidden_platforms) for name in all_platform_names]
config.selected_filter_index = 0
config.needs_redraw = True
# Activer la répétition automatique
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
event.value)
elif is_input_matched(event, "left"):
# Navigation gauche/droite uniquement pour les boutons (indices 0-3)
if config.selected_filter_index < action_buttons:
if config.selected_filter_index > 0:
config.selected_filter_index -= 1
config.needs_redraw = True
# sinon ignorer (dans la liste)
elif is_input_matched(event, "right"):
# Navigation gauche/droite uniquement pour les boutons (indices 0-3)
if config.selected_filter_index < action_buttons:
if config.selected_filter_index < action_buttons - 1:
config.selected_filter_index += 1
config.needs_redraw = True
# sinon ignorer (dans la liste)
elif is_input_matched(event, "confirm"):
# Indices 0-3 = boutons, 4+ = liste
if config.selected_filter_index < action_buttons:
# Action sur un bouton
btn_idx = config.selected_filter_index
settings = load_rgsx_settings()
if btn_idx == 0: # all visible
config.filter_platforms_selection = [(n, False) for n, _ in config.filter_platforms_selection]
config.filter_platforms_dirty = True
elif btn_idx == 1: # none visible
config.filter_platforms_selection = [(n, True) for n, _ in config.filter_platforms_selection]
config.filter_platforms_dirty = True
elif btn_idx == 2: # apply
hidden_list = [n for n, h in config.filter_platforms_selection if h]
settings["hidden_platforms"] = hidden_list
save_rgsx_settings(settings)
load_sources()
# Recalibrer la sélection et la page courante si elles dépassent la nouvelle liste visible
try:
systems_per_page = config.GRID_COLS * config.GRID_ROWS
if config.current_page * systems_per_page >= len(config.platforms):
config.current_page = 0
if config.selected_platform >= len(config.platforms):
config.selected_platform = 0
except Exception:
# Sécurité: en cas d'erreur on remet simplement à 0
config.current_page = 0
config.selected_platform = 0
config.filter_platforms_dirty = False
# Return either to display menu or pause menu depending on origin
target = getattr(config, 'filter_return_to', 'pause_menu')
config.menu_state = target
if target == 'display_menu': # ancien cas (fallback)
config.display_menu_selection = 3
elif target == 'pause_display_menu': # nouveau sous-menu hiérarchique
config.pause_display_selection = 4 # positionner sur Filter
else:
config.selected_option = 5 # keep pointer on Filter in pause menu
config.filter_return_to = None
elif btn_idx == 3: # back
target = getattr(config, 'filter_return_to', 'pause_menu')
config.menu_state = target
if target == 'display_menu':
config.display_menu_selection = 3
elif target == 'pause_display_menu':
config.pause_display_selection = 4
else:
config.selected_option = 5
config.filter_return_to = None
config.needs_redraw = True
config.filter_platforms_scroll_offset = 0
config.filter_platforms_dirty = False
else:
# Action sur un élément de la liste (indices >= action_buttons)
list_index = config.selected_filter_index - action_buttons
if list_index < total_items:
name, hidden = config.filter_platforms_selection[list_index]
config.filter_platforms_selection[list_index] = (name, not hidden)
config.filter_platforms_dirty = True
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
config.filter_platforms_selection = [(name, current_map.get(name, False)) for name in all_platform_names]
def _hidden_map() -> dict:
return {name: bool(is_hidden) for name, is_hidden in config.filter_platforms_selection}
def _set_hidden_map(hidden_map_local: dict, source_map_local: dict) -> None:
ordered_names = _all_platform_names(source_map_local)
config.filter_platforms_selection = [(name, bool(hidden_map_local.get(name, False))) for name in ordered_names]
def _build_rows(source_map_local: dict, hidden_map_local: dict, expanded_sources_local: set) -> list:
rows_local = []
for source_name in sorted(source_map_local.keys(), key=lambda s: str(s).lower()):
platforms = source_map_local.get(source_name, [])
total = len(platforms)
hidden_count = sum(1 for platform_name in platforms if hidden_map_local.get(platform_name, False))
rows_local.append({
"type": "source",
"source": source_name,
"platforms": platforms,
"total": total,
"hidden_count": hidden_count,
"expanded": source_name in expanded_sources_local,
})
if source_name in expanded_sources_local:
for platform_name in platforms:
rows_local.append({
"type": "platform",
"source": source_name,
"platform": platform_name,
"hidden": bool(hidden_map_local.get(platform_name, False)),
})
return rows_local
def _return_from_filter() -> None:
target = getattr(config, 'filter_return_to', 'pause_menu')
config.menu_state = target
if target == 'display_menu':
@@ -4056,6 +4132,144 @@ def handle_controls(event, sources, joystick, screen):
else:
config.selected_option = 5
config.filter_return_to = None
def _show_unsaved_exit_toast() -> None:
if not getattr(config, 'filter_platforms_dirty', False):
return
try:
msg_tpl = _("filter_unsaved_toast") if _ else ""
if msg_tpl and msg_tpl != "filter_unsaved_toast":
show_toast(msg_tpl, 3000)
else:
show_toast("Unsaved changes\nApply with History/Start before leaving", 3000)
except Exception as toast_error:
logger.debug(f"Impossible d'afficher le toast unsaved filter: {toast_error}")
def _apply_filter() -> None:
settings_local = load_rgsx_settings()
hidden_map_local = _hidden_map()
hidden_list = sorted({name for name, is_hidden in hidden_map_local.items() if is_hidden}, key=lambda s: str(s).lower())
settings_local["hidden_platforms"] = hidden_list
save_rgsx_settings(settings_local)
load_sources()
try:
systems_per_page = config.GRID_COLS * config.GRID_ROWS
if config.current_page * systems_per_page >= len(config.platforms):
config.current_page = 0
if config.selected_platform >= len(config.platforms):
config.selected_platform = 0
except Exception:
config.current_page = 0
config.selected_platform = 0
config.filter_platforms_dirty = False
_return_from_filter()
source_map = _build_source_map()
config.filter_platforms_source_map = source_map
_ensure_working_selection(source_map)
expanded_raw = getattr(config, 'filter_platforms_expanded_sources', [])
expanded_sources = set(expanded_raw if isinstance(expanded_raw, list) else [])
expanded_sources = {source for source in expanded_sources if source in source_map}
config.filter_platforms_expanded_sources = sorted(expanded_sources, key=lambda s: str(s).lower())
rows = _build_rows(source_map, _hidden_map(), expanded_sources)
total_rows = len(rows)
if total_rows <= 0:
config.selected_filter_index = 0
else:
if config.selected_filter_index < 0:
config.selected_filter_index = 0
elif config.selected_filter_index >= total_rows:
config.selected_filter_index = total_rows - 1
if is_input_matched(event, "up"):
if total_rows > 0:
if config.selected_filter_index > 0:
config.selected_filter_index -= 1
else:
config.selected_filter_index = total_rows - 1
config.needs_redraw = True
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)
elif is_input_matched(event, "down"):
if total_rows > 0:
if config.selected_filter_index < total_rows - 1:
config.selected_filter_index += 1
else:
config.selected_filter_index = 0
config.needs_redraw = True
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
event.value)
elif is_input_matched(event, "left"):
if total_rows > 0:
current_row = rows[config.selected_filter_index]
if current_row.get("type") == "source":
source_name = current_row.get("source")
if source_name in expanded_sources:
expanded_sources.discard(source_name)
config.filter_platforms_expanded_sources = sorted(expanded_sources, key=lambda s: str(s).lower())
config.needs_redraw = True
elif current_row.get("type") == "platform":
source_name = current_row.get("source")
if source_name in expanded_sources:
expanded_sources.discard(source_name)
config.filter_platforms_expanded_sources = sorted(expanded_sources, key=lambda s: str(s).lower())
refreshed_rows = _build_rows(source_map, _hidden_map(), expanded_sources)
for idx, row in enumerate(refreshed_rows):
if row.get("type") == "source" and row.get("source") == source_name:
config.selected_filter_index = idx
break
config.needs_redraw = True
elif is_input_matched(event, "right"):
if total_rows > 0:
current_row = rows[config.selected_filter_index]
source_name = current_row.get("source")
if current_row.get("type") == "source" and source_name not in expanded_sources:
expanded_sources.add(source_name)
config.filter_platforms_expanded_sources = sorted(expanded_sources, key=lambda s: str(s).lower())
config.needs_redraw = True
elif is_input_matched(event, "confirm"):
if total_rows > 0:
current_row = rows[config.selected_filter_index]
hidden_map = _hidden_map()
if current_row.get("type") == "source":
platforms = current_row.get("platforms", [])
all_visible = all(not hidden_map.get(platform_name, False) for platform_name in platforms)
new_hidden = True if all_visible else False
for platform_name in platforms:
hidden_map[platform_name] = new_hidden
else:
platform_name = current_row.get("platform")
hidden_map[platform_name] = not hidden_map.get(platform_name, False)
_set_hidden_map(hidden_map, source_map)
config.filter_platforms_dirty = True
config.needs_redraw = True
elif is_input_matched(event, "page_up"):
hidden_map = _hidden_map()
for platform_name in list(hidden_map.keys()):
hidden_map[platform_name] = False
_set_hidden_map(hidden_map, source_map)
config.filter_platforms_dirty = True
config.needs_redraw = True
elif is_input_matched(event, "page_down"):
hidden_map = _hidden_map()
for platform_name in list(hidden_map.keys()):
hidden_map[platform_name] = True
_set_hidden_map(hidden_map, source_map)
config.filter_platforms_dirty = True
config.needs_redraw = True
elif is_input_matched(event, "history") or is_input_matched(event, "start"):
_apply_filter()
config.needs_redraw = True
elif is_input_matched(event, "cancel"):
_show_unsaved_exit_toast()
_return_from_filter()
config.needs_redraw = True

View File

@@ -12,16 +12,16 @@ from utils import (truncate_text_middle, wrap_text, load_system_image, truncate_
check_web_service_status, check_custom_dns_status, load_api_keys,
_get_dest_folder_name, find_file_with_or_without_extension, find_matching_files,
get_connection_status_targets, get_connection_status_snapshot,
get_clean_display_name, get_existing_history_matches,
sort_games_list)
get_clean_display_name, get_existing_history_matches, remember_history_local_match,
sort_games_list, get_platform_source_badge_key, get_platform_source_badge_surface)
import logging
import math
import re
from history import load_history, is_game_downloaded
from language import _, get_size_units, get_speed_unit, get_available_languages, get_language_name
from rgsx_settings import (load_rgsx_settings, get_light_mode, get_show_unsupported_platforms,
get_allow_unknown_extensions, get_display_monitor, get_display_fullscreen,
get_available_monitors, get_font_family, get_sources_mode,
get_hide_premium_systems, get_symlink_option)
get_available_monitors, get_font_family, get_symlink_option)
from game_filters import GameFilters
import json
@@ -1317,6 +1317,22 @@ def get_display_resolution_line():
return ""
def draw_platform_source_badge(screen, platform_name, container_rect):
source_key = get_platform_source_badge_key(platform_name)
if not source_key:
return
badge_size = max(20, min(int(min(container_rect.width, container_rect.height) * 0.24), 44))
badge_surface = get_platform_source_badge_surface(source_key, badge_size)
if badge_surface is None:
return
inset = max(5, badge_size // 6)
badge_x = container_rect.right - badge_size - inset
badge_y = container_rect.top + inset
screen.blit(badge_surface, (badge_x, badge_y))
def draw_platform_header_info(screen, light_mode=False, badge_x=None, max_badge_width=None, include_details=True):
"""Affiche version, controleur connecte et IP reseau dans un cartouche en haut a droite."""
lines = get_platform_header_info_lines(max_badge_width, include_details=include_details)
@@ -1420,17 +1436,7 @@ def draw_platform_grid(screen):
# Effet de pulsation subtil pour le titre - calculé une seule fois par frame
current_time = pygame.time.get_ticks()
# Filtrage éventuel des systèmes premium selon réglage
try:
from rgsx_settings import get_hide_premium_systems
hide_premium = get_hide_premium_systems()
except Exception:
hide_premium = False
premium_markers = getattr(config, 'PREMIUM_HOST_MARKERS', [])
if hide_premium and premium_markers:
visible_platforms = [p for p in config.platforms if not any(m.lower() in p.lower() for m in premium_markers)]
else:
visible_platforms = list(config.platforms)
visible_platforms = list(config.platforms)
# Ajuster selected_platform et current_platform/page si liste réduite
if config.selected_platform >= len(visible_platforms):
@@ -1796,6 +1802,8 @@ def draw_platform_grid(screen):
screen.blit(temp_image, centered_image_rect)
else:
screen.blit(scaled_image, centered_image_rect)
draw_platform_source_badge(screen, display_name, border_rect)
# Nettoyer le cache périodiquement (garder seulement les images utilisées récemment)
if len(platform_images_cache) > 50: # Limite arbitraire pour éviter une croissance excessive
@@ -2441,6 +2449,32 @@ def format_size(size):
return f"{size:.1f} {units[-1]}" # Dernier niveau (Po/PB)
def format_speed_adaptive(speed_mib_s):
"""Formate une vitesse stockée en MiB/s avec une unité lisible selon son ordre de grandeur."""
try:
speed_mib_s = float(speed_mib_s or 0.0)
except Exception:
speed_mib_s = 0.0
if speed_mib_s <= 0:
units = get_size_units()
base = units[0] if units else "B"
return f"0 {base}/s"
bytes_per_second = speed_mib_s * 1024.0 * 1024.0
units = get_size_units()
if not units or len(units) < 4:
units = ["B", "KB", "MB", "GB"]
if bytes_per_second < 1024.0:
return f"{bytes_per_second:.0f} {units[0]}/s"
if bytes_per_second < (1024.0 ** 2):
return f"{bytes_per_second / 1024.0:.1f} {units[1]}/s"
if bytes_per_second < (1024.0 ** 3):
return f"{bytes_per_second / (1024.0 ** 2):.2f} {units[2]}/s"
return f"{bytes_per_second / (1024.0 ** 3):.2f} {units[3]}/s"
def draw_history_list(screen):
# logger.debug(f"Dessin historique, history={config.history}, needs_redraw={config.needs_redraw}")
history = config.history if hasattr(config, 'history') else load_history()
@@ -2459,17 +2493,51 @@ def draw_history_list(screen):
else:
current_history_item_inverted = 0
# Cherche une entrée en cours de téléchargement pour afficher la vitesse
speed_str = ""
for entry in history:
if entry.get("status") in ["Téléchargement", "Downloading"]:
speed = entry.get("speed", 0.0)
if speed and speed > 0:
speed_str = f" - {speed:.2f} {get_speed_unit()}"
break
active_statuses = {"Téléchargement", "Downloading", "Extracting", "Converting", "Connecting", "Queued"}
completed_statuses = {"Download_OK", "Completed"}
error_statuses = {"Erreur", "Error"}
canceled_statuses = {"Canceled", "Cancelled", "Annulé", "Annule"}
selected_entry = history[current_history_item_inverted] if history and 0 <= current_history_item_inverted < len(history) else None
selected_status = str((selected_entry or {}).get("status") or "")
if selected_entry and selected_status in active_statuses:
downloaded_size = int(selected_entry.get("downloaded_size", 0) or 0)
size_text = format_size(downloaded_size)
try:
selected_speed = float(selected_entry.get("speed", 0.0) or 0.0)
except Exception:
selected_speed = 0.0
speed_text = format_speed_adaptive(selected_speed)
title_text = _("history_title_downloading_active").format(size_text, speed_text)
# Afficher SD/CN dans le titre
_sd = int(selected_entry.get("seeds", 0) or 0)
_cn = int(selected_entry.get("connections", 0) or 0)
title_text = f"{title_text} [{_sd}SD/{_cn}CN]"
# Afficher l'étape aria2c courante dans le titre (connecting / verifying / waiting).
# On ne montre rien quand on télécharge activement (speed > 0) car l'info de vitesse suffit.
_aria2_phase = str(selected_entry.get("aria2_phase") or "")
_phase_labels = {
"connecting": _("aria2_phase_connecting"),
"verifying": _("aria2_phase_verifying"),
"waiting": _("aria2_phase_waiting"),
}
_phase_label = _phase_labels.get(_aria2_phase, "")
if _phase_label:
title_text = f"{title_text} [{_phase_label}]"
elif selected_entry and selected_status in completed_statuses:
completed_count = sum(1 for item in history if str(item.get("status") or "") in completed_statuses)
title_text = _("history_title_completed_count").format(completed_count)
elif selected_entry and selected_status in error_statuses:
error_count = sum(1 for item in history if str(item.get("status") or "") in error_statuses)
title_text = _("history_title_error_count").format(error_count)
elif selected_entry and selected_status in canceled_statuses:
canceled_count = sum(1 for item in history if str(item.get("status") or "") in canceled_statuses)
title_text = _("history_title_canceled_count").format(canceled_count)
else:
title_text = _("history_title").format(history_count)
screen.blit(OVERLAY, (0, 0))
title_text = _("history_title").format(history_count) + speed_str
title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 20))
title_rect_inflated = title_rect.inflate(60, 30)
@@ -2510,17 +2578,6 @@ def draw_history_list(screen):
else:
current_history_item_inverted = 0
speed = 0.0
if history and history[current_history_item_inverted].get("status") in ["Téléchargement", "Downloading"]:
speed = history[current_history_item_inverted].get("speed", 0.0)
if speed > 0:
speed_str = f"{speed:.2f} {get_speed_unit()}"
title_text = _("history_title").format(history_count) + f" {speed_str}"
else:
title_text = _("history_title").format(history_count)
title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"])
if not history:
logger.debug("Aucun historique disponible")
message = _("history_empty")
@@ -2616,6 +2673,8 @@ def draw_history_list(screen):
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)
seeds_value = int(entry.get("seeds", 0) or 0)
connections_value = int(entry.get("connections", 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
@@ -2624,7 +2683,9 @@ def draw_history_list(screen):
status_text = str(status)
else:
# Comportement normal: afficher le pourcentage
status_text = _("history_status_downloading").format(progress)
display_progress = "<1" if (progress <= 0 and total_size_value > 0 and downloaded_size_value > 0) else progress
status_text = _("history_status_downloading").format(display_progress)
# SD/CN sont maintenant affichés dans le titre, pas ici
# Coerce to string and prefix provider when relevant
status_text = str(status_text or "")
if provider_prefix and not status_text.startswith(provider_prefix):
@@ -3055,6 +3116,13 @@ def draw_controls(screen, menu_state, current_music_name=None, music_popup_start
"pause_connection_status": [
("cancel", _("controls_cancel_back")),
],
"filter_platforms": [
("confirm", _("controls_confirm_select")),
(("left", "right"), (_("filter_expand_collapse") if _ and _("filter_expand_collapse") != "filter_expand_collapse" else "Expand/Collapse")),
(("page_up", "page_down"), f"{_('filter_all')} / {_('filter_none')}"),
("history", _("filter_apply")),
("cancel", _("controls_cancel_back")),
],
"support_dialog": [
("start", _("controls_cancel_back")),
],
@@ -3894,9 +3962,6 @@ def draw_pause_display_font_menu(screen, selected_index):
_draw_submenu_generic(screen, _("submenu_display_font_size") if _ else "Font Size", options, selected_index, instruction_text)
def draw_pause_games_menu(screen, selected_index):
mode = get_sources_mode()
source_label = _("games_source_rgsx") if mode == "rgsx" else _("games_source_custom")
source_txt = f"{_('menu_games_source_prefix')}: < {source_label} >"
update_txt = _("menu_redownload_cache")
scan_txt = _("menu_scan_owned_roms") if _ else "Scan owned ROMs"
history_txt = _("menu_history") if _ else "History"
@@ -3909,24 +3974,16 @@ def draw_pause_games_menu(screen, selected_index):
raw_unsupported_label = raw_unsupported_label.split('{status}')[0].rstrip(' :')
unsupported_txt = f"{raw_unsupported_label}: < {status_unsupported} >"
# Hide premium systems
hide_premium = get_hide_premium_systems()
status_hide_premium = _('status_on') if hide_premium else _('status_off')
hide_premium_label = _('menu_hide_premium_systems') if _ else 'Hide Premium systems'
hide_premium_txt = f"{hide_premium_label}: < {status_hide_premium} >"
# 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]
options = [update_txt, scan_txt, history_txt, unsupported_txt, filter_txt, back_txt]
instruction_keys = [
"instruction_games_update_cache",
"instruction_games_scan_owned",
"instruction_games_history",
"instruction_games_source_mode",
"instruction_display_show_unsupported",
"instruction_display_hide_premium",
"instruction_display_filter_platforms",
"instruction_generic_back",
]
@@ -3934,34 +3991,11 @@ def draw_pause_games_menu(screen, selected_index):
instruction_text = None
if key:
instruction_text = _(key)
if key == "instruction_display_hide_premium":
# Inject dynamic list of premium providers from config.PREMIUM_HOST_MARKERS
try:
from config import PREMIUM_HOST_MARKERS
# Clean, preserve order, remove duplicates (case-insensitive)
seen = set()
providers_clean = []
for p in PREMIUM_HOST_MARKERS:
p_lower = p.lower()
if p_lower not in seen:
seen.add(p_lower)
providers_clean.append(p)
providers_str = ", ".join(providers_clean)
if not providers_str:
providers_str = "1fichier, etc."
if "{providers}" in instruction_text:
instruction_text = instruction_text.format(providers=providers_str)
else:
# fallback si placeholder absent
instruction_text = f"{instruction_text} ({providers_str})"
except Exception:
pass
_draw_submenu_generic(screen, _("menu_games") if _ else "Games", options, selected_index, instruction_text)
def draw_pause_settings_menu(screen, selected_index):
from rgsx_settings import get_auto_extract, get_roms_folder
from rgsx_settings import get_auto_extract, get_roms_folder, get_max_simultaneous_downloads
# Music
if config.music_enabled:
music_name = config.current_music_name or ""
@@ -3994,6 +4028,10 @@ def draw_pause_settings_menu(screen, selected_index):
roms_folder_txt = f"{_('settings_roms_folder')} : {display_path}"
else:
roms_folder_txt = f"{_('settings_roms_folder')} : < {_('settings_roms_folder_default')} >"
# Max simultaneous downloads option
max_dl = get_max_simultaneous_downloads()
max_dl_txt = f"{_('settings_max_simultaneous_dl')} : < {max_dl} >"
# Web Service at boot (only on Linux/Batocera)
web_service_txt = ""
@@ -4013,22 +4051,23 @@ def draw_pause_settings_menu(screen, selected_index):
back_txt = _("menu_back") if _ else "Back"
# Construction de la liste des options
options = [music_option, symlink_option, auto_extract_txt, roms_folder_txt]
options = [music_option, symlink_option, auto_extract_txt, roms_folder_txt, max_dl_txt]
if web_service_txt: # Ajouter seulement si Linux/Batocera
options.append(web_service_txt)
if custom_dns_txt: # Ajouter seulement si Linux/Batocera
options.append(custom_dns_txt)
options.extend([api_keys_txt, connection_status_txt, back_txt])
# Index de l'option Dossier ROMs
roms_folder_index = 3
# Instructions textuelles pour chaque option
instruction_keys = [
"instruction_settings_music",
"instruction_settings_symlink",
"instruction_settings_auto_extract",
"instruction_settings_roms_folder",
"instruction_settings_max_simultaneous_dl",
]
if web_service_txt:
instruction_keys.append("instruction_settings_web_service")
@@ -4159,17 +4198,29 @@ def draw_pause_connection_status(screen):
cat_sources = _("connection_status_category_sources") if _ else "Sources"
# Group rows by category
categories_order = ["updates", "sources"]
category_labels = {
category_labels_map = {
"updates": cat_updates,
"sources": cat_sources,
}
categories_order = []
for target in targets:
cat = str(target.get("category", "sources")).strip().lower() or "sources"
if cat not in categories_order:
categories_order.append(cat)
def _category_label(cat_key: str) -> str:
if cat_key in category_labels_map:
return category_labels_map[cat_key]
cleaned = cat_key.replace("_", " ").strip()
return cleaned.title() if cleaned else cat_sources
rows = [] # list of (type, data)
for cat in categories_order:
cat_items = [t for t in targets if t.get("category") == cat]
cat_items = [t for t in targets if str(t.get("category", "sources")).strip().lower() == cat]
if not cat_items:
continue
rows.append(("header", category_labels.get(cat, cat)))
rows.append(("header", _category_label(cat)))
for item in cat_items:
rows.append(("item", item))
@@ -4291,24 +4342,94 @@ def draw_pause_connection_status(screen):
def draw_filter_platforms_menu(screen):
"""Affiche le menu de filtrage des plateformes (afficher/masquer)."""
"""Affiche le menu de filtrage des plateformes (sources + plateformes collapsibles)."""
screen.blit(OVERLAY, (0, 0))
settings = load_rgsx_settings()
hidden = set(settings.get("hidden_platforms", [])) if isinstance(settings, dict) else set()
# Initialiser la copie de travail si vide ou taille différente
if not config.filter_platforms_selection or len(config.filter_platforms_selection) != len(config.platform_dicts):
# Liste alphabétique complète (sans filtrer hidden existant)
all_names = sorted([d.get("platform_name", "") for d in config.platform_dicts if d.get("platform_name")])
config.filter_platforms_selection = [(name, name in hidden) for name in all_names]
def _extract_source(platform_name: str) -> str:
match = re.search(r'\(([^()]+)\)\s*$', str(platform_name).strip())
if match:
return match.group(1).strip()
fallback = _("games_source_rgsx") if _ else "RGSX"
return fallback if fallback != "games_source_rgsx" else "RGSX"
def _strip_source_suffix(platform_name: str) -> str:
return re.sub(r'\s*\([^()]+\)\s*$', '', str(platform_name)).strip()
# Construire mapping source -> plateformes (trié, sans doublons)
source_to_platforms = {}
for entry in config.platform_dicts:
platform_name = entry.get("platform_name", "") if isinstance(entry, dict) else ""
platform_name = str(platform_name).strip()
if not platform_name:
continue
source_name = _extract_source(platform_name)
source_to_platforms.setdefault(source_name, []).append(platform_name)
for source_name in list(source_to_platforms.keys()):
source_to_platforms[source_name] = sorted(set(source_to_platforms[source_name]), key=lambda s: str(s).lower())
source_to_platforms = dict(sorted(source_to_platforms.items(), key=lambda kv: str(kv[0]).lower()))
config.filter_platforms_source_map = source_to_platforms
all_platform_names = []
for source_name in source_to_platforms:
all_platform_names.extend(source_to_platforms[source_name])
# Initialiser/synchroniser la copie de travail par plateforme
current_map = {}
if isinstance(config.filter_platforms_selection, list):
for item in config.filter_platforms_selection:
if isinstance(item, (list, tuple)) and len(item) == 2:
name = str(item[0]).strip()
if name:
current_map[name] = bool(item[1])
expected_set = set(all_platform_names)
if set(current_map.keys()) != expected_set:
config.filter_platforms_selection = [(name, name in hidden) for name in all_platform_names]
config.selected_filter_index = 0
config.filter_platforms_scroll_offset = 0
config.filter_platforms_dirty = False
else:
config.filter_platforms_selection = [(name, current_map.get(name, False)) for name in all_platform_names]
hidden_map = {name: bool(is_hidden) for name, is_hidden in config.filter_platforms_selection}
expanded_raw = getattr(config, 'filter_platforms_expanded_sources', [])
expanded_sources = set(expanded_raw if isinstance(expanded_raw, list) else [])
expanded_sources = {source_name for source_name in expanded_sources if source_name in source_to_platforms}
config.filter_platforms_expanded_sources = sorted(expanded_sources, key=lambda s: str(s).lower())
rows = []
for source_name, platforms in source_to_platforms.items():
total = len(platforms)
hidden_count = sum(1 for platform_name in platforms if hidden_map.get(platform_name, False))
rows.append({
"type": "source",
"source": source_name,
"platforms": platforms,
"total": total,
"hidden_count": hidden_count,
"expanded": source_name in expanded_sources,
})
if source_name in expanded_sources:
for platform_name in platforms:
rows.append({
"type": "platform",
"source": source_name,
"platform": platform_name,
"hidden": bool(hidden_map.get(platform_name, False)),
})
if rows:
config.selected_filter_index = max(0, min(config.selected_filter_index, len(rows) - 1))
else:
config.selected_filter_index = 0
title_text = _("filter_platforms_title")
title_surface = config.title_font.render(title_text, True, THEME_COLORS["text"])
title_rect = title_surface.get_rect(center=(config.screen_width // 2, title_surface.get_height() // 2 + 14))
# Padding responsive réduit
hpad = max(36, min(64, int(config.screen_width * 0.06)))
vpad = max(10, min(20, int(title_surface.get_height() * 0.45)))
title_rect_inflated = title_rect.inflate(hpad, vpad)
@@ -4317,86 +4438,90 @@ def draw_filter_platforms_menu(screen):
pygame.draw.rect(screen, THEME_COLORS["border"], title_rect_inflated, 2, border_radius=12)
screen.blit(title_surface, title_rect)
# Boutons d'action en haut (avant la liste)
btn_width = 220
btn_height = int(config.screen_height * 0.0463)
spacing = 30
buttons_y = title_rect_inflated.bottom + 20
center_x = config.screen_width // 2
actions = [
("filter_all", 0),
("filter_none", 1),
("filter_apply", 2),
("filter_back", 3)
]
total_items = len(config.filter_platforms_selection)
action_buttons = len(actions)
for idx, (key, btn_idx) in enumerate(actions):
btn_x = center_x - (len(actions) * (btn_width + spacing) - spacing) // 2 + idx * (btn_width + spacing)
is_selected = (config.selected_filter_index == btn_idx)
label = _(key)
draw_stylized_button(screen, label, btn_x, buttons_y, btn_width, btn_height, selected=is_selected)
# Zone liste (après les boutons)
list_width = int(config.screen_width * 0.7)
list_height = int(config.screen_height * 0.5)
# Zone liste: laisser de la place au footer de controls + infos
footer_reserved = max(95, int(config.screen_height * 0.15))
list_width = int(config.screen_width * 0.78)
list_x = (config.screen_width - list_width) // 2
list_y = buttons_y + btn_height + 20
list_y = title_rect_inflated.bottom + 16
list_bottom_limit = config.screen_height - footer_reserved - 38
list_height = max(140, list_bottom_limit - list_y)
pygame.draw.rect(screen, THEME_COLORS["button_idle"], (list_x, list_y, list_width, list_height), border_radius=12)
pygame.draw.rect(screen, THEME_COLORS["border"], (list_x, list_y, list_width, list_height), 2, border_radius=12)
line_height = config.small_font.get_height() + 8
visible_items = list_height // line_height - 1 # laisser un peu d'espace bas
total_items = len(config.filter_platforms_selection)
if config.selected_filter_index < 0:
config.selected_filter_index = 0
# Ne pas forcer la réduction si on est sur les boutons (indices >= total_items)
# Laisser controls.py gérer la borne max étendue
# Ajuster scroll
visible_items = max(4, (list_height - 20) // line_height)
total_items = len(rows)
if config.selected_filter_index < config.filter_platforms_scroll_offset:
config.filter_platforms_scroll_offset = config.selected_filter_index
elif config.selected_filter_index >= config.filter_platforms_scroll_offset + visible_items:
config.filter_platforms_scroll_offset = config.selected_filter_index - visible_items + 1
config.filter_platforms_scroll_offset = max(0, min(config.filter_platforms_scroll_offset, max(0, total_items - visible_items)))
# Dessiner items (les indices de la liste commencent à action_buttons)
for i in range(config.filter_platforms_scroll_offset, min(config.filter_platforms_scroll_offset + visible_items, total_items)):
name, is_hidden = config.filter_platforms_selection[i]
idx_on_screen = i - config.filter_platforms_scroll_offset
# Dessin des lignes source + plateformes
start = config.filter_platforms_scroll_offset
end = min(start + visible_items, total_items)
for i in range(start, end):
row = rows[i]
idx_on_screen = i - start
y_center = list_y + 10 + idx_on_screen * line_height + line_height // 2
# Les éléments de la liste ont des indices à partir de action_buttons
selected = (config.selected_filter_index == action_buttons + i)
checkbox = "[ ]" if is_hidden else "[X]" # inversé: coché signifie visible
# Correction: on veut [X] si visible => is_hidden False
checkbox = "[X]" if not is_hidden else "[ ]"
display_text = f"{checkbox} {name}"
color = THEME_COLORS["fond_lignes"] if selected else THEME_COLORS["text"]
text_surface = config.small_font.render(display_text, True, color)
text_rect = text_surface.get_rect(midleft=(list_x + 20, y_center))
selected = (config.selected_filter_index == i)
if selected:
glow_surface = pygame.Surface((list_width - 40, line_height), pygame.SRCALPHA)
pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (0, 0, list_width - 40, line_height), border_radius=8)
screen.blit(glow_surface, (list_x + 20, y_center - line_height // 2))
glow_surface = pygame.Surface((list_width - 32, line_height), pygame.SRCALPHA)
pygame.draw.rect(glow_surface, THEME_COLORS["fond_lignes"] + (50,), (0, 0, list_width - 32, line_height), border_radius=8)
screen.blit(glow_surface, (list_x + 16, y_center - line_height // 2))
if row.get("type") == "source":
total = max(1, int(row.get("total", 0)))
hidden_count = int(row.get("hidden_count", 0))
visible_count = max(0, total - hidden_count)
if hidden_count == 0:
checkbox = "[X]"
elif hidden_count >= total:
checkbox = "[ ]"
else:
checkbox = "[-]"
collapse = "v" if row.get("expanded") else ">"
display_text = f"{checkbox} {collapse} {row.get('source', '')} ({visible_count}/{total})"
text_x = list_x + 20
else:
platform_name = row.get("platform", "")
checkbox = "[X]" if not row.get("hidden") else "[ ]"
clean_name = _strip_source_suffix(platform_name) or platform_name
display_text = f"{checkbox} {clean_name}"
text_x = list_x + 44
max_text_w = max(60, list_width - (text_x - list_x) - 38)
fitted_text = truncate_text_end(display_text, config.small_font, max_text_w)
color = THEME_COLORS["fond_lignes"] if selected else THEME_COLORS["text"]
text_surface = config.small_font.render(fitted_text, True, color)
text_rect = text_surface.get_rect(midleft=(text_x, y_center))
screen.blit(text_surface, text_rect)
# Scrollbar
if total_items > visible_items:
scroll_height = int((visible_items / total_items) * (list_height - 20))
scroll_y = int((config.filter_platforms_scroll_offset / max(1, total_items - visible_items)) * (list_height - 20 - scroll_height))
pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (list_x + list_width - 25, list_y + 10 + scroll_y, 10, scroll_height), border_radius=4)
scroll_track_height = list_height - 20
scroll_height = int((visible_items / total_items) * scroll_track_height)
scroll_height = max(20, scroll_height)
scroll_range = max(1, total_items - visible_items)
scroll_y = int((config.filter_platforms_scroll_offset / scroll_range) * (scroll_track_height - scroll_height))
pygame.draw.rect(screen, THEME_COLORS["fond_lignes"], (list_x + list_width - 22, list_y + 10 + scroll_y, 9, scroll_height), border_radius=4)
# Infos bas
hidden_count = sum(1 for _, h in config.filter_platforms_selection if h)
visible_count = total_items - hidden_count
info_text = _("filter_platforms_info").format(visible_count, hidden_count, total_items)
total_platforms = len(all_platform_names)
hidden_count = sum(1 for _, is_hidden in config.filter_platforms_selection if is_hidden)
visible_count = total_platforms - hidden_count
info_text = _("filter_platforms_info").format(visible_count, hidden_count, total_platforms)
info_surface = config.small_font.render(info_text, True, THEME_COLORS["text"])
info_rect = info_surface.get_rect(center=(config.screen_width // 2, list_y + list_height + 20))
info_rect = info_surface.get_rect(center=(config.screen_width // 2, list_y + list_height + 18))
screen.blit(info_surface, info_rect)
if config.filter_platforms_dirty:
dirty_text = _("filter_unsaved_warning")
dirty_surface = config.small_font.render(dirty_text, True, THEME_COLORS["warning_text"])
dirty_rect = dirty_surface.get_rect(center=(config.screen_width // 2, info_rect.bottom + 25))
dirty_rect = dirty_surface.get_rect(center=(config.screen_width // 2, info_rect.bottom + 22))
screen.blit(dirty_surface, dirty_rect)
# Menu aide contrôles
@@ -4842,6 +4967,8 @@ def draw_folder_browser(screen):
# Chemin actuel (tronqué si trop long)
path_max_width = panel_width - 40
path_display = current_path
if not path_display and os.name == 'nt':
path_display = "Available drives"
while config.small_font.size(path_display)[0] > path_max_width and len(path_display) > 10:
path_display = "..." + path_display[4:]
path_text = config.small_font.render(path_display, True, THEME_COLORS["highlight"])
@@ -4881,15 +5008,19 @@ def draw_folder_browser(screen):
pygame.draw.rect(screen, THEME_COLORS["highlight"], sel_rect, 2, border_radius=6)
# Icône dossier (texte simple au lieu d'emoji)
folder_icon = "[..]" if item == ".." else "[D]"
is_drive = isinstance(item, str) and len(item) >= 2 and item[1] == ':'
folder_icon = "[..]" if item == ".." else ("[DRV]" if is_drive else "[D]")
icon_text = config.small_font.render(folder_icon, True, THEME_COLORS["highlight"] if item == ".." else THEME_COLORS["text"])
screen.blit(icon_text, (panel_x + 30, item_y + (item_height - icon_text.get_height()) // 2))
icon_x = panel_x + 30
icon_y = item_y + (item_height - icon_text.get_height()) // 2
screen.blit(icon_text, (icon_x, icon_y))
# Nom du dossier
display_name = _("folder_browser_parent") if item == ".." and _ else (".." if item == ".." else item)
text_color = THEME_COLORS["highlight"] if is_selected else THEME_COLORS["text"]
item_text = config.small_font.render(display_name, True, text_color)
screen.blit(item_text, (panel_x + 70, item_y + (item_height - item_text.get_height()) // 2))
text_x = icon_x + icon_text.get_width() + 12
screen.blit(item_text, (text_x, item_y + (item_height - item_text.get_height()) // 2))
# Indicateur de scroll si nécessaire
if len(items) > visible_items:
@@ -5192,11 +5323,29 @@ def draw_history_game_options(screen):
base_path = os.path.join(config.ROMS_FOLDER, dest_folder)
file_exists, actual_filename, actual_path = find_file_with_or_without_extension(base_path, game_name)
actual_matches = find_matching_files(base_path, game_name)
local_path = entry.get("local_path")
local_filename = entry.get("local_filename")
if not file_exists and local_path and os.path.isfile(local_path):
actual_filename = os.path.basename(local_path)
actual_path = local_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS_RENDER] direct local_path match used: %s", actual_path)
elif not file_exists and local_filename:
local_filename_path = os.path.join(base_path, str(local_filename))
if os.path.isfile(local_filename_path):
actual_filename = os.path.basename(local_filename_path)
actual_path = local_filename_path
file_exists = True
actual_matches = [(actual_filename, actual_path)]
logger.debug("[HISTORY_OPTIONS_RENDER] direct local_filename match used: %s", actual_path)
if not actual_matches:
actual_matches = get_existing_history_matches(entry)
if actual_matches:
actual_filename, actual_path = actual_matches[0]
file_exists = True
if file_exists and actual_path:
remember_history_local_match(entry, actual_filename, actual_path)
# Déterminer les options disponibles selon le statut
options = []
@@ -5210,6 +5359,8 @@ def draw_history_game_options(screen):
# Options selon statut
if status == "Queued":
# En attente dans la queue
options.append("force_download")
option_labels.append(_("history_option_force_download"))
options.append("remove_from_queue")
option_labels.append(_("history_option_remove_from_queue"))
elif status in ["Downloading", "Téléchargement", "Extracting", "Paused"]:
@@ -5246,6 +5397,31 @@ def draw_history_game_options(screen):
option_labels.append(_("history_option_delete_game"))
options.append("back")
option_labels.append(_("history_option_back"))
diagnostics_signature = (
entry.get("url", ""),
status,
file_exists,
actual_filename or "",
actual_path or "",
tuple(options),
)
if getattr(config, 'history_options_render_signature', None) != diagnostics_signature:
config.history_options_render_signature = diagnostics_signature
logger.debug(
"[HISTORY_OPTIONS_RENDER] platform=%s game=%s status=%s dest_folder=%s base_path=%s file_exists=%s actual_filename=%s actual_path=%s local_path=%s moved_paths=%s options=%s",
platform,
game_name,
status,
dest_folder,
base_path,
file_exists,
actual_filename,
actual_path,
entry.get("local_path"),
entry.get("moved_paths"),
options,
)
# Calculer dimensions
title = _("history_game_options_title")

View File

@@ -9,6 +9,131 @@ from datetime import datetime
logger = logging.getLogger(__name__)
_history_write_state_lock = threading.Lock()
_history_write_failures = 0
_history_write_last_error = ""
_history_write_last_failure_ts = 0.0
_history_write_last_log_ts = 0.0
_history_write_last_probe_ts = 0.0
_history_write_ok = True
_HISTORY_WRITE_FAILURE_COOLDOWN_SEC = 1.5
_HISTORY_WRITE_PROBE_CACHE_SEC = 8.0
def _set_history_write_status(ok, error_message=""):
global _history_write_ok, _history_write_last_error
with _history_write_state_lock:
_history_write_ok = bool(ok)
_history_write_last_error = error_message or ""
failures = _history_write_failures
last_failure_ts = _history_write_last_failure_ts
setattr(config, "history_write_ok", bool(ok))
setattr(config, "history_write_error", error_message or "")
setattr(config, "history_write_failure_count", failures)
setattr(config, "history_write_last_failure_ts", last_failure_ts)
def _register_history_write_failure(exc):
global _history_write_failures, _history_write_last_failure_ts, _history_write_last_log_ts
now = time.time()
raw_error = str(exc)
message = (
f"Erreur ecriture history.json: {raw_error}. "
"Le telechargement continue sans sauvegarde temps reel."
)
with _history_write_state_lock:
_history_write_failures += 1
_history_write_last_failure_ts = now
_set_history_write_status(False, message)
should_log = False
with _history_write_state_lock:
if _history_write_failures in (1, 5, 20):
should_log = True
elif now - _history_write_last_log_ts >= 5.0:
should_log = True
if should_log:
_history_write_last_log_ts = now
if should_log:
logger.error(message)
def _register_history_write_success():
global _history_write_failures
recovered = False
with _history_write_state_lock:
if _history_write_failures > 0:
recovered = True
_history_write_failures = 0
_set_history_write_status(True, "")
if recovered:
logger.info("Ecriture history.json retablie")
def get_history_write_status():
with _history_write_state_lock:
return {
"ok": _history_write_ok,
"message": _history_write_last_error,
"failures": _history_write_failures,
"last_failure_ts": _history_write_last_failure_ts,
"last_probe_ts": _history_write_last_probe_ts,
}
def check_history_write_access(force=False):
global _history_write_last_probe_ts
history_path = getattr(config, 'HISTORY_PATH')
now = time.time()
with _history_write_state_lock:
use_cached_probe = (
not force
and (now - _history_write_last_probe_ts) < _HISTORY_WRITE_PROBE_CACHE_SEC
)
if use_cached_probe:
return _history_write_ok, _history_write_last_error
probe_a = f"{history_path}.probe.{os.getpid()}.{threading.get_ident()}.a"
probe_b = f"{history_path}.probe.{os.getpid()}.{threading.get_ident()}.b"
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
with open(probe_a, "w", encoding="utf-8") as handle:
handle.write("[]")
handle.flush()
os.fsync(handle.fileno())
os.replace(probe_a, probe_b)
os.remove(probe_b)
with _history_write_state_lock:
_history_write_last_probe_ts = now
_register_history_write_success()
return True, ""
except Exception as exc:
with _history_write_state_lock:
_history_write_last_probe_ts = now
_register_history_write_failure(exc)
return False, get_history_write_status().get("message", "")
finally:
for temp_path in (probe_a, probe_b):
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception:
pass
def _atomic_write_json(target_path, payload):
temp_path = f"{target_path}.{os.getpid()}.{threading.get_ident()}.tmp"
@@ -24,7 +149,7 @@ def _atomic_write_json(target_path, payload):
os.replace(temp_path, target_path)
last_error = None
break
except PermissionError as e:
except (PermissionError, OSError) as e:
last_error = e
time.sleep(0.15 * (attempt + 1))
@@ -53,6 +178,7 @@ def init_history():
logger.error(f"Erreur lors de la création du fichier d'historique : {e}")
else:
logger.info(f"Fichier d'historique trouvé : {history_path}")
check_history_write_access(force=True)
return history_path
def load_history():
@@ -102,14 +228,27 @@ def load_history():
logger.error(f"Erreur inattendue lors de la lecture de {history_path} : {e}")
return []
def save_history(history):
"""Sauvegarde l'historique dans history.json de manière atomique."""
def save_history(history, force=False):
"""Sauvegarde l'historique dans history.json de manière atomique (mode non-bloquant en cas d'erreur)."""
history_path = getattr(config, 'HISTORY_PATH')
now = time.time()
state = get_history_write_status()
if (
not force
and not state.get("ok", True)
and (now - float(state.get("last_failure_ts", 0.0) or 0.0)) < _HISTORY_WRITE_FAILURE_COOLDOWN_SEC
):
return False
try:
os.makedirs(os.path.dirname(history_path), exist_ok=True)
_atomic_write_json(history_path, history)
_register_history_write_success()
return True
except Exception as e:
logger.error(f"Erreur lors de l'écriture de {history_path} : {e}")
_register_history_write_failure(e)
return False
def add_to_history(platform, game_name, status, url=None, progress=0, message=None, timestamp=None):
"""Ajoute une entrée à l'historique."""

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Größe",
"history_title": "Downloads ({0})",
"history_title_downloading_active": "Download - {0} - {1}",
"history_title_completed_count": "Abgeschlossene Downloads ({0})",
"history_title_error_count": "Fehlgeschlagene Downloads ({0})",
"history_title_canceled_count": "Abgebrochene Downloads ({0})",
"aria2_phase_connecting": "Verbinden...",
"aria2_phase_verifying": "Überprüfen...",
"aria2_phase_waiting": "Warten...",
"history_empty": "Keine Downloads im Verlauf",
"history_column_system": "System",
"history_column_game": "Spielname",
@@ -109,6 +116,7 @@
"filter_platforms_title": "Systemsichtbarkeit",
"filter_platforms_info": "Sichtbar: {0} | Versteckt: {1} / Gesamt: {2}",
"filter_unsaved_warning": "Ungespeicherte Änderungen",
"filter_unsaved_toast": "Ungespeicherte Anderungen\nMit History/Start anwenden, bevor du zuruckgehst",
"menu_show_unsupported_enabled": "Sichtbarkeit nicht unterstützter Systeme aktiviert",
"menu_show_unsupported_disabled": "Sichtbarkeit nicht unterstützter Systeme deaktiviert",
"menu_allow_unknown_ext_on": "Warnung bei unbekannter Erweiterung ausblenden: Ja",
@@ -256,11 +264,14 @@
"instruction_settings_symlink": "Verwendung von Symlinks für Installationen umschalten",
"instruction_settings_auto_extract": "Automatische Archivextraktion nach Download aktivieren/deaktivieren",
"instruction_settings_roms_folder": "Standard-Download-Verzeichnis für ROMs ändern",
"instruction_settings_max_simultaneous_dl": "Max. gleichzeitige Downloads einstellen (links/rechts, 1-10)",
"instruction_settings_api_keys": "Gefundene Premium-API-Schlüssel ansehen",
"instruction_settings_connection_status": "Zugriff auf Update- und Quellen-Seiten prüfen",
"instruction_settings_web_service": "Web-Dienst Autostart beim Booten aktivieren/deaktivieren",
"instruction_settings_custom_dns": "Custom DNS (Cloudflare 1.1.1.1) beim Booten aktivieren/deaktivieren",
"settings_auto_extract": "Auto-Extraktion Archive",
"settings_max_simultaneous_dl": "Max. gleichzeitige Downloads",
"settings_max_dl_changed": "Max. gleichzeitige Downloads: {0}",
"settings_auto_extract_enabled": "Aktiviert",
"settings_auto_extract_disabled": "Deaktiviert",
"settings_roms_folder": "ROMs-Ordner",
@@ -312,6 +323,7 @@
"history_option_extract_archive": "Archivsextraktion erzwingen",
"history_option_open_file": "Datei öffnen",
"history_option_scraper": "Metadaten abrufen",
"history_option_force_download": "Jetzt herunterladen erzwingen",
"history_option_remove_from_queue": "Aus Warteschlange entfernen",
"history_option_cancel_download": "Download abbrechen",
"history_option_pause_download": "Download pausieren",

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Size",
"history_title": "Downloads ({0})",
"history_title_downloading_active": "Downloading - {0} - {1}",
"history_title_completed_count": "Completed downloads ({0})",
"history_title_error_count": "Failed downloads ({0})",
"history_title_canceled_count": "Canceled downloads ({0})",
"aria2_phase_connecting": "Connecting...",
"aria2_phase_verifying": "Verifying...",
"aria2_phase_waiting": "Waiting...",
"history_empty": "No downloads in history",
"history_column_system": "System",
"history_column_game": "Game name",
@@ -105,6 +112,7 @@
"filter_platforms_title": "Systems visibility",
"filter_platforms_info": "Visible: {0} | Hidden: {1} / Total: {2}",
"filter_unsaved_warning": "Unsaved changes",
"filter_unsaved_toast": "Unsaved changes\nApply with History/Start before leaving",
"menu_show_unsupported_enabled": "Unsupported systems visibility enabled",
"menu_show_unsupported_disabled": "Unsupported systems visibility disabled",
"menu_allow_unknown_ext_on": "Hide unknown extension warning: Yes",
@@ -258,11 +266,14 @@
"instruction_settings_symlink": "Toggle using filesystem symlinks for installs",
"instruction_settings_auto_extract": "Toggle automatic archive extraction after download",
"instruction_settings_roms_folder": "Change the default ROMs download directory",
"instruction_settings_max_simultaneous_dl": "Set the maximum number of simultaneous downloads (left/right, 1-10)",
"instruction_settings_api_keys": "See detected premium provider API keys",
"instruction_settings_connection_status": "Check access to update and source sites",
"instruction_settings_web_service": "Enable/disable web service auto-start at boot",
"instruction_settings_custom_dns": "Enable/disable custom DNS (Cloudflare 1.1.1.1) at boot",
"settings_auto_extract": "Auto Extract Archives",
"settings_max_simultaneous_dl": "Max Simultaneous Downloads",
"settings_max_dl_changed": "Max simultaneous downloads: {0}",
"settings_auto_extract_enabled": "Enabled",
"settings_auto_extract_disabled": "Disabled",
"settings_roms_folder": "ROMs Folder",
@@ -311,6 +322,7 @@
"history_option_extract_archive": "Force extract archive",
"history_option_open_file": "Open file",
"history_option_scraper": "Scrape metadata",
"history_option_force_download": "Force download now",
"history_option_remove_from_queue": "Remove from queue",
"history_option_cancel_download": "Cancel download",
"history_option_pause_download": "Pause download",

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Tamaño",
"history_title": "Descargas ({0})",
"history_title_downloading_active": "Descargando - {0} - {1}",
"history_title_completed_count": "Descargas completadas ({0})",
"history_title_error_count": "Descargas con error ({0})",
"history_title_canceled_count": "Descargas canceladas ({0})",
"aria2_phase_connecting": "Conectando...",
"aria2_phase_verifying": "Verificando...",
"aria2_phase_waiting": "Esperando...",
"history_empty": "No hay descargas en el historial",
"history_column_system": "Sistema",
"history_column_game": "Nombre del juego",
@@ -107,6 +114,7 @@
"filter_platforms_title": "Visibilidad de sistemas",
"filter_platforms_info": "Visibles: {0} | Ocultos: {1} / Total: {2}",
"filter_unsaved_warning": "Cambios no guardados",
"filter_unsaved_toast": "Cambios no guardados\nAplica con History/Start antes de salir",
"menu_show_unsupported_enabled": "Visibilidad de sistemas no soportados activada",
"menu_show_unsupported_disabled": "Visibilidad de sistemas no soportados desactivada",
"menu_allow_unknown_ext_on": "Ocultar aviso de extensión desconocida: Sí",
@@ -256,11 +264,14 @@
"instruction_settings_symlink": "Alternar uso de symlinks en instalaciones",
"instruction_settings_auto_extract": "Activar/desactivar extracción automática de archivos después de descargar",
"instruction_settings_roms_folder": "Cambiar el directorio de descarga de ROMs por defecto",
"instruction_settings_max_simultaneous_dl": "Establecer máx. descargas simultáneas (izq/der, 1-10)",
"instruction_settings_api_keys": "Ver claves API premium detectadas",
"instruction_settings_connection_status": "Comprobar acceso a sitios de actualizaciones y fuentes",
"instruction_settings_web_service": "Activar/desactivar inicio automático del servicio web",
"instruction_settings_custom_dns": "Activar/desactivar DNS personalizado (Cloudflare 1.1.1.1) al inicio",
"settings_auto_extract": "Extracción auto de archivos",
"settings_max_simultaneous_dl": "Máx. descargas simultáneas",
"settings_max_dl_changed": "Máx. descargas simultáneas: {0}",
"settings_auto_extract_enabled": "Activado",
"settings_auto_extract_disabled": "Desactivado",
"settings_roms_folder": "Carpeta ROMs",
@@ -312,6 +323,7 @@
"history_option_extract_archive": "Forzar extraccion del archivo",
"history_option_open_file": "Abrir archivo",
"history_option_scraper": "Obtener metadatos",
"history_option_force_download": "Forzar descarga ahora",
"history_option_remove_from_queue": "Quitar de la cola",
"history_option_cancel_download": "Cancelar descarga",
"history_option_pause_download": "Pausar descarga",

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Taille",
"history_title": "Téléchargements ({0})",
"history_title_downloading_active": "Téléchargement - {0} - {1}",
"history_title_completed_count": "Téléchargements terminés ({0})",
"history_title_error_count": "Téléchargements en erreur ({0})",
"history_title_canceled_count": "Téléchargements annulés ({0})",
"aria2_phase_connecting": "Connexion...",
"aria2_phase_verifying": "Vérification...",
"aria2_phase_waiting": "En attente...",
"history_empty": "Aucun téléchargement dans l'historique",
"history_column_system": "Système",
"history_column_game": "Nom du jeu",
@@ -105,6 +112,7 @@
"filter_platforms_title": "Affichage des systèmes",
"filter_platforms_info": "Visibles: {0} | Masqués: {1} / Total: {2}",
"filter_unsaved_warning": "Modifications non sauvegardées",
"filter_unsaved_toast": "Modifications non sauvegardees\nValider avec History/Start avant de quitter",
"menu_show_unsupported_enabled": "Affichage systèmes non supportés activé",
"support_dialog_title": "Fichier de support",
"support_dialog_message": "Un fichier de support a été créé avec tous vos fichiers de configuration et logs.\n\nFichier: {0}\n\nPour obtenir de l'aide :\n1. Rejoignez le Discord RGSX\n2. Décrivez votre problème\n3. Partagez ce fichier ZIP\n\nAppuyez sur {1} pour revenir au menu.",
@@ -258,11 +266,14 @@
"instruction_settings_symlink": "Basculer l'utilisation de symlinks pour l'installation",
"instruction_settings_auto_extract": "Activer/désactiver l'extraction automatique des archives après téléchargement",
"instruction_settings_roms_folder": "Changer le répertoire de téléchargement des ROMs par défaut",
"instruction_settings_max_simultaneous_dl": "Définir le nombre max de téléchargements simultanés (gauche/droite, 1-10)",
"instruction_settings_api_keys": "Voir les clés API détectées des services premium",
"instruction_settings_connection_status": "Vérifier l'accès aux sites d'update et de sources",
"instruction_settings_web_service": "Activer/désactiver le démarrage automatique du service web",
"instruction_settings_custom_dns": "Activer/désactiver les DNS personnalisés (Cloudflare 1.1.1.1) au démarrage",
"settings_auto_extract": "Extraction auto des archives",
"settings_max_simultaneous_dl": "Téléchargements simultanés max",
"settings_max_dl_changed": "Téléchargements simultanés max : {0}",
"settings_auto_extract_enabled": "Activé",
"settings_auto_extract_disabled": "Désactivé",
"settings_roms_folder": "Dossier ROMs",
@@ -311,6 +322,8 @@
"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_force_download": "Forcer le téléchargement maintenant",
"history_option_force_download": "Forcer le téléchargement maintenant",
"history_option_remove_from_queue": "Retirer de la file d'attente",
"history_option_cancel_download": "Annuler le téléchargement",
"history_option_pause_download": "Mettre en pause",

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Dimensione",
"history_title": "Download ({0})",
"history_title_downloading_active": "Download in corso - {0} - {1}",
"history_title_completed_count": "Download completati ({0})",
"history_title_error_count": "Download con errore ({0})",
"history_title_canceled_count": "Download annullati ({0})",
"aria2_phase_connecting": "Connessione...",
"aria2_phase_verifying": "Verifica...",
"aria2_phase_waiting": "In attesa...",
"history_empty": "Nessun download nella cronologia",
"history_column_system": "Sistema",
"history_column_game": "Nome del gioco",
@@ -105,6 +112,7 @@
"filter_platforms_title": "Visibilità sistemi",
"filter_platforms_info": "Visibili: {0} | Nascosti: {1} / Totale: {2}",
"filter_unsaved_warning": "Modifiche non salvate",
"filter_unsaved_toast": "Modifiche non salvate\nApplica con History/Start prima di uscire",
"menu_show_unsupported_enabled": "Visibilità sistemi non supportati abilitata",
"menu_show_unsupported_disabled": "Visibilità sistemi non supportati disabilitata",
"menu_allow_unknown_ext_on": "Nascondi avviso estensione sconosciuta: Sì",
@@ -251,11 +259,14 @@
"instruction_settings_symlink": "Abilitare/disabilitare uso symlink per installazioni",
"instruction_settings_auto_extract": "Attivare/disattivare estrazione automatica archivi dopo il download",
"instruction_settings_roms_folder": "Cambiare la directory di download ROMs predefinita",
"instruction_settings_max_simultaneous_dl": "Imposta il max di download simultanei (sinistra/destra, 1-10)",
"instruction_settings_api_keys": "Mostrare chiavi API premium rilevate",
"instruction_settings_connection_status": "Verifica accesso ai siti di aggiornamento e sorgenti",
"instruction_settings_web_service": "Attivare/disattivare avvio automatico servizio web all'avvio",
"instruction_settings_custom_dns": "Attivare/disattivare DNS personalizzato (Cloudflare 1.1.1.1) all'avvio",
"settings_auto_extract": "Estrazione auto archivi",
"settings_max_simultaneous_dl": "Max download simultanei",
"settings_max_dl_changed": "Max download simultanei: {0}",
"settings_auto_extract_enabled": "Attivato",
"settings_auto_extract_disabled": "Disattivato",
"settings_roms_folder": "Cartella ROMs",
@@ -307,6 +318,7 @@
"history_option_extract_archive": "Forza estrazione archivio",
"history_option_open_file": "Apri file",
"history_option_scraper": "Scraper metadati",
"history_option_force_download": "Forza il download ora",
"history_option_remove_from_queue": "Rimuovi dalla coda",
"history_option_cancel_download": "Annulla download",
"history_option_pause_download": "Pausa download",

View File

@@ -39,6 +39,13 @@
"game_header_ext": "Ext",
"game_header_size": "Tamanho",
"history_title": "Downloads ({0})",
"history_title_downloading_active": "Baixando - {0} - {1}",
"history_title_completed_count": "Downloads concluídos ({0})",
"history_title_error_count": "Downloads com erro ({0})",
"history_title_canceled_count": "Downloads cancelados ({0})",
"aria2_phase_connecting": "Conectando...",
"aria2_phase_verifying": "Verificando...",
"aria2_phase_waiting": "Aguardando...",
"history_empty": "Nenhum download no histórico",
"history_column_system": "Sistema",
"history_column_game": "Nome do jogo",
@@ -109,6 +116,7 @@
"filter_platforms_title": "Visibilidade dos sistemas",
"filter_platforms_info": "Visíveis: {0} | Ocultos: {1} / Total: {2}",
"filter_unsaved_warning": "Alterações não salvas",
"filter_unsaved_toast": "Alteracoes nao salvas\nAplique com History/Start antes de sair",
"menu_show_unsupported_enabled": "Visibilidade de sistemas não suportados ativada",
"menu_show_unsupported_disabled": "Visibilidade de sistemas não suportados desativada",
"menu_allow_unknown_ext_on": "Ocultar aviso de extensão desconhecida: Sim",
@@ -257,11 +265,14 @@
"instruction_settings_symlink": "Ativar/desativar uso de symlinks para instalações",
"instruction_settings_auto_extract": "Ativar/desativar extração automática de arquivos após download",
"instruction_settings_roms_folder": "Alterar o diretório de download de ROMs padrão",
"instruction_settings_max_simultaneous_dl": "Definir máx. downloads simultâneos (esq/dir, 1-10)",
"instruction_settings_api_keys": "Ver chaves API premium detectadas",
"instruction_settings_connection_status": "Verificar acesso a sites de atualização e fontes",
"instruction_settings_web_service": "Ativar/desativar início automático do serviço web na inicialização",
"instruction_settings_custom_dns": "Ativar/desativar DNS personalizado (Cloudflare 1.1.1.1) na inicialização",
"settings_auto_extract": "Extração auto de arquivos",
"settings_max_simultaneous_dl": "Máx. downloads simultâneos",
"settings_max_dl_changed": "Máx. downloads simultâneos: {0}",
"settings_auto_extract_enabled": "Ativado",
"settings_auto_extract_disabled": "Desativado",
"settings_roms_folder": "Pasta ROMs",
@@ -313,6 +324,7 @@
"history_option_extract_archive": "Forcar extracao do arquivo",
"history_option_open_file": "Abrir arquivo",
"history_option_scraper": "Obter metadados",
"history_option_force_download": "Forçar download agora",
"history_option_remove_from_queue": "Remover da fila",
"history_option_cancel_download": "Cancelar download",
"history_option_pause_download": "Pausar download",

File diff suppressed because it is too large Load Diff

View File

@@ -86,7 +86,8 @@ def load_rgsx_settings():
"last_gamelist_update": None,
"last_gamelist_prompt_remote_update": None,
"global_sort_option": "name_asc",
"platform_custom_paths": {} # Chemins personnalisés par plateforme
"platform_custom_paths": {}, # Chemins personnalisés par plateforme
"max_simultaneous_downloads": 5 # Limite de téléchargements simultanés
}
try:
@@ -342,33 +343,15 @@ def get_sources_zip_url(fallback_url):
def find_local_custom_sources_zip():
"""Recherche un fichier ZIP local à la racine de SAVE_FOLDER pour le mode custom.
Priorité sur quelques noms courants afin d'éviter toute ambiguïté.
Ne prend en compte QUE games.zip.
Retourne le chemin absolu du ZIP si trouvé, sinon None.
"""
try:
from config import SAVE_FOLDER
candidates = [
"games.zip",
"custom_sources.zip",
"rgsx_custom_sources.zip",
"data.zip",
]
if not os.path.isdir(SAVE_FOLDER):
return None
for name in candidates:
p = os.path.join(SAVE_FOLDER, name)
if os.path.isfile(p):
return p
# Option avancée: prendre le plus récent *.zip si aucun nom connu trouvé
try:
zips = [os.path.join(SAVE_FOLDER, f) for f in os.listdir(SAVE_FOLDER) if f.lower().endswith('.zip')]
zips = [z for z in zips if os.path.isfile(z)]
if zips:
newest = max(zips, key=lambda z: os.path.getmtime(z))
return newest
except Exception:
pass
return None
games_zip = os.path.join(SAVE_FOLDER, "games.zip")
return games_zip if os.path.isfile(games_zip) else None
except Exception as e:
logger.debug(f"find_local_custom_sources_zip error: {e}")
return None
@@ -689,3 +672,27 @@ def set_auto_extract(enabled: bool):
except Exception as e:
logger.error(f"Error setting auto_extract: {str(e)}")
return False
def get_max_simultaneous_downloads() -> int:
"""Récupère la limite de téléchargements simultanés (1-10, défaut 5)."""
try:
settings = load_rgsx_settings()
val = settings.get("max_simultaneous_downloads", 5)
return max(1, min(10, int(val)))
except Exception:
return 5
def set_max_simultaneous_downloads(value: int) -> int:
"""Définit la limite de téléchargements simultanés et met à jour config. Retourne la valeur appliquée."""
value = max(1, min(10, int(value)))
try:
settings = load_rgsx_settings()
settings["max_simultaneous_downloads"] = value
save_rgsx_settings(settings)
config.max_simultaneous_downloads = value
logger.info(f"max_simultaneous_downloads set to {value}")
except Exception as e:
logger.error(f"Error setting max_simultaneous_downloads: {e}")
return value

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, request_torrent_manifest_refresh
from utils import load_sources, load_games, extract_data, get_clean_display_name, parse_torrent_download_url, request_torrent_manifest_refresh, _resolve_platform_image_path
from network import download_rom, download_from_1fichier
from pathlib import Path
from rgsx_settings import get_language
@@ -464,8 +464,12 @@ class RGSXHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError):
pass
self._set_headers('application/json', status, etag=etag, last_modified=cached_dt)
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
try:
self._set_headers('application/json', status, etag=etag, last_modified=cached_dt)
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
except (ConnectionAbortedError, BrokenPipeError) as e:
logger.debug(f"Connexion fermée par le client pendant l'envoi JSON: {e}")
return
def _send_html(self, html, status=200, etag=None, last_modified=None):
"""Envoie une réponse HTML"""
@@ -791,6 +795,8 @@ class RGSXHandler(BaseHTTPRequestHandler):
'status': status,
'progress_percent': entry.get('progress', 0),
'speed': entry.get('speed', 0),
'seeds': entry.get('seeds', 0),
'connections': entry.get('connections', 0),
'game_name': entry.get('game_name', ''),
'platform': entry.get('platform', ''),
'timestamp': entry.get('timestamp', '')
@@ -1173,13 +1179,7 @@ class RGSXHandler(BaseHTTPRequestHandler):
}, 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
# Suppression du blocage torrent : on laisse passer les URLs rgsx+torrent
# Vérifier l'extension et déterminer si extraction nécessaire
from utils import check_extension_before_download
@@ -1782,86 +1782,28 @@ DO NOT share this file publicly as it may contain sensitive information.
platform_dict = pd
break
# Dossiers où chercher les images
image_folders = [
config.IMAGES_FOLDER, # Dossier utilisateur (saves/ports/rgsx/images)
os.path.join(config.APP_FOLDER, 'assets', 'images') # Dossier app
]
# Extensions possibles
extensions = ['.png', '.jpg', '.jpeg', '.gif']
# Construire la liste des noms de fichiers à chercher (ordre de priorité)
candidates = []
if platform_dict:
# 1. platform_image explicite (priorité max)
platform_image_field = (platform_dict.get('platform_image') or '').strip()
if platform_image_field:
candidates.append(platform_image_field)
# 2. platform_name.png
candidates.append(platform_name)
# 3. folder.png si disponible
folder_name = platform_dict.get('folder')
if folder_name:
candidates.append(folder_name)
else:
# Pas de platform_dict trouvé, juste essayer le nom
candidates.append(platform_name)
# Chercher le fichier image
image_path = None
for candidate in candidates:
# Retirer l'extension si déjà présente
candidate_base = os.path.splitext(candidate)[0]
for folder in image_folders:
if not os.path.exists(folder):
continue
# Essayer avec chaque extension
for ext in extensions:
test_path = os.path.join(folder, candidate_base + ext)
if os.path.exists(test_path):
image_path = test_path
break
if image_path:
break
if image_path:
break
# Si pas trouvé, chercher default.png
if not image_path:
for folder in image_folders:
default_path = os.path.join(folder, 'default.png')
if os.path.exists(default_path):
image_path = default_path
break
# Envoyer l'image
payload_platform = platform_dict or {'platform_name': platform_name}
image_path = _resolve_platform_image_path(payload_platform)
if image_path and os.path.exists(image_path):
# Déterminer le type MIME
ext = os.path.splitext(image_path)[1].lower()
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif'
'.gif': 'image/gif',
'.webp': 'image/webp',
}
content_type = mime_types.get(ext, 'image/png')
# Lire et envoyer l'image avec headers de cache
with open(image_path, 'rb') as f:
image_data = f.read()
# Ajouter les headers de cache (1 heure)
self.send_response(200)
self.send_header('Content-type', content_type)
self.send_header('Cache-Control', 'public, max-age=3600') # 1 heure
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(image_data)
@@ -1870,7 +1812,9 @@ DO NOT share this file publicly as it may contain sensitive information.
logger.warning(f"Aucune image trouvée pour {platform_name}, envoi PNG transparent")
self.send_response(404)
self.send_header('Content-type', 'image/png')
self.send_header('Cache-Control', 'public, max-age=3600')
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# PNG transparent 1x1 pixel
@@ -1881,7 +1825,9 @@ DO NOT share this file publicly as it may contain sensitive information.
logger.error(f"Erreur lors du chargement de l'image {platform_name}: {e}", exc_info=True)
self.send_response(500)
self.send_header('Content-type', 'image/png')
self.send_header('Cache-Control', 'public, max-age=60') # Cache court pour les erreurs
self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
self.send_header('Pragma', 'no-cache')
self.send_header('Expires', '0')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# PNG transparent en cas d'erreur

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
from pathlib import Path
import io
import shutil
import requests # type: ignore
import re
@@ -29,11 +30,391 @@ from language import _
from datetime import datetime
import sys
import tempfile
try:
from PIL import Image # type: ignore
except Exception:
Image = None # type: ignore
logger = logging.getLogger(__name__)
# Désactiver les logs DEBUG de urllib3 e requests pour supprimer les messages de connexion HTTP
_SOURCE_BADGE_CACHE = {}
def _platform_image_folders():
return [
config.IMAGES_FOLDER,
os.path.join(config.APP_FOLDER, "assets", "images"),
os.path.join(config.APP_FOLDER, "images"),
]
def _resolve_platform_image_path(platform_dict):
platform_name = platform_dict.get("platform_name", "unknown")
folder_name = platform_dict.get("folder") or ""
candidates = []
platform_image_field = (platform_dict.get("platform_image") or "").strip()
if platform_image_field:
candidates.append(platform_image_field)
candidates.append(platform_name)
if folder_name:
candidates.append(folder_name)
image_path = None
for candidate in candidates:
candidate_base = os.path.splitext(candidate)[0]
for folder in _platform_image_folders():
if not os.path.exists(folder):
continue
for ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp'):
test_path = os.path.join(folder, candidate_base + ext)
if os.path.exists(test_path):
image_path = test_path
break
if image_path:
break
if image_path:
break
if image_path:
return image_path
for folder in _platform_image_folders():
default_path = os.path.join(folder, 'default.png')
if os.path.exists(default_path):
return default_path
return None
def get_platform_source_badge_key(platform_name: str):
text = str(platform_name).strip()
if not text:
return None
match = re.search(r'\(([^()]+)\)\s*$', text)
if not match:
return None
source = match.group(1).strip().lower()
mapping = {
'archive': 'Archive',
'lolroms': 'LolRoms',
'torrent': 'Torrent',
'1fichier': '1Fichier',
'vimms': 'Vimms',
'edgeemu': 'EdgeEmu',
'edgeemu.net': 'EdgeEmu',
}
return mapping.get(source)
def _load_svg_badge_surface(svg_path: str, size: int):
if pygame is None:
return None
try:
surf = pygame.image.load(svg_path)
width, height = surf.get_size()
if width <= 0 or height <= 0:
return None
scale = min(size / width, size / height)
scaled = pygame.transform.smoothscale(
surf,
(max(1, int(width * scale)), max(1, int(height * scale))),
)
return _safe_convert_alpha(scaled)
except Exception as exc:
logger.debug(f"Badge SVG load failed for {svg_path}: {exc}")
return None
def _safe_convert_alpha(surface):
if pygame is None or surface is None:
return surface
try:
if pygame.display.get_surface() is not None:
return surface.convert_alpha()
except Exception:
pass
return surface.copy()
def _build_text_badge_icon(label: str, size: int, color):
if pygame is None:
return None
try:
if not pygame.font.get_init():
pygame.font.init()
font_size = max(11, int(size * (0.34 if len(label) <= 2 else 0.26)))
font = pygame.font.SysFont('arial', font_size, bold=True)
surface = font.render(label, True, color)
return _safe_convert_alpha(surface)
except Exception as exc:
logger.debug(f"Badge text render failed for {label}: {exc}")
return None
def _build_1fichier_badge_icon(size: int):
if pygame is None:
return None
try:
if not pygame.font.get_init():
pygame.font.init()
width = max(size, int(size * 0.98))
height = max(16, int(size * 0.90))
surface = pygame.Surface((width, height), pygame.SRCALPHA)
box_width = max(16, int(width * 0.88))
box_height = max(14, int(height * 0.78))
box_left = (width - box_width) // 2
box_top = (height - box_height) // 2
front = [
(box_left + box_width * 0.10, box_top + box_height * 0.24),
(box_left + box_width * 0.74, box_top + box_height * 0.24),
(box_left + box_width * 0.74, box_top + box_height * 0.86),
(box_left + box_width * 0.10, box_top + box_height * 0.86),
]
right = [
(box_left + box_width * 0.74, box_top + box_height * 0.24),
(box_left + box_width * 0.92, box_top + box_height * 0.12),
(box_left + box_width * 0.92, box_top + box_height * 0.70),
(box_left + box_width * 0.74, box_top + box_height * 0.86),
]
top = [
(box_left + box_width * 0.10, box_top + box_height * 0.24),
(box_left + box_width * 0.30, box_top + box_height * 0.04),
(box_left + box_width * 0.92, box_top + box_height * 0.12),
(box_left + box_width * 0.74, box_top + box_height * 0.24),
]
pygame.draw.polygon(surface, (229, 133, 20), [(int(x), int(y)) for x, y in front])
pygame.draw.polygon(surface, (191, 104, 8), [(int(x), int(y)) for x, y in right])
pygame.draw.polygon(surface, (248, 177, 63), [(int(x), int(y)) for x, y in top])
pygame.draw.lines(surface, (111, 61, 6), True, [(int(x), int(y)) for x, y in front], 1)
pygame.draw.lines(surface, (111, 61, 6), True, [(int(x), int(y)) for x, y in right], 1)
pygame.draw.lines(surface, (111, 61, 6), True, [(int(x), int(y)) for x, y in top], 1)
accent_radius = max(1, int(box_height * 0.08))
pygame.draw.circle(surface, (48, 48, 48), (int(box_left + box_width * 0.88), int(box_top + box_height * 0.08)), accent_radius)
pygame.draw.circle(surface, (48, 48, 48), (int(box_left + box_width * 0.95), int(box_top + box_height * 0.22)), accent_radius)
one_font = pygame.font.SysFont('arial', max(12, int(box_height * 0.60)), bold=True)
f_font = pygame.font.SysFont('arial', max(12, int(box_height * 0.64)), bold=True)
one_surface = one_font.render('1', True, (92, 92, 92))
f_outline_surface = f_font.render('F', True, (32, 32, 32))
f_surface = f_font.render('F', True, (236, 129, 18))
total_text_width = one_surface.get_width() + f_surface.get_width() - max(1, int(box_height * 0.06))
text_x = int(box_left + box_width * 0.16)
max_text_x = box_left + box_width - total_text_width - max(2, int(box_width * 0.08))
text_x = min(text_x, max_text_x)
text_y = int(box_top + (box_height - max(one_surface.get_height(), f_surface.get_height())) / 2)
surface.blit(one_surface, (text_x, text_y))
f_x = text_x + one_surface.get_width() - max(1, int(box_height * 0.06))
f_y = text_y - 1
for offset_x, offset_y in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
surface.blit(f_outline_surface, (f_x + offset_x, f_y + offset_y))
surface.blit(f_surface, (f_x, f_y))
return _safe_convert_alpha(surface)
except Exception as exc:
logger.debug(f"1Fichier badge render failed: {exc}")
return None
def get_platform_source_badge_surface(source_key: str, badge_size: int):
cache_key = (source_key, badge_size)
if cache_key in _SOURCE_BADGE_CACHE:
return _SOURCE_BADGE_CACHE[cache_key]
if pygame is None:
_SOURCE_BADGE_CACHE[cache_key] = None
return None
style_map = {
'Archive': {
'svg': 'archive.svg',
'bg': (255, 255, 255, 242),
'border': (48, 48, 48, 235),
'label': 'AR',
'text': (35, 35, 35),
},
'LolRoms': {
'svg': 'lolroms.svg',
'bg': (255, 255, 255, 242),
'border': (0, 255, 255, 230),
'label': 'LOL',
'text': (61, 19, 110),
},
'Vimms': {
'svg': 'vimms.svg',
'bg': (255, 255, 255, 242),
'border': (208, 208, 208, 235),
'label': 'VL',
'text': (24, 77, 176),
},
'Torrent': {
'svg': 'torrent.svg',
'bg': (255, 255, 255, 242),
'border': (97, 164, 64, 235),
'label': 'TOR',
'text': (37, 90, 24),
},
'1Fichier': {
'svg': None,
'bg': (255, 255, 255, 242),
'border': (208, 208, 208, 235),
'label': '1F',
'text': (24, 77, 176),
},
'EdgeEmu': {
'svg': 'edgeemu.svg',
'bg': (255, 255, 255, 242),
'border': (41, 126, 196, 235),
'label': 'EMU',
'text': (24, 77, 176),
'icon_scale': 0.90,
},
}
style = style_map.get(source_key)
if not style:
_SOURCE_BADGE_CACHE[cache_key] = None
return None
badge_surface = pygame.Surface((badge_size, badge_size), pygame.SRCALPHA)
border_radius = max(10, badge_size // 4)
pygame.draw.rect(badge_surface, style['bg'], (0, 0, badge_size, badge_size), border_radius=border_radius)
inner_margin = max(3, badge_size // 14)
inner_rect = pygame.Rect(inner_margin, inner_margin, badge_size - inner_margin * 2, badge_size - inner_margin * 2)
pygame.draw.rect(badge_surface, (255, 255, 255, 50), inner_rect, border_radius=max(8, border_radius - 3))
highlight_height = max(8, badge_size // 3)
highlight = pygame.Surface((badge_size - 8, highlight_height), pygame.SRCALPHA)
highlight.fill((255, 255, 255, 34))
badge_surface.blit(highlight, (4, 4))
pygame.draw.rect(badge_surface, style['border'], (0, 0, badge_size, badge_size), max(2, badge_size // 18), border_radius=border_radius)
icon_surface = None
svg_name = style.get('svg')
icon_scale = float(style.get('icon_scale', 0.76))
target_icon_size = max(22, int(badge_size * icon_scale))
if svg_name:
svg_path = os.path.join(config.APP_FOLDER, 'assets', 'images', svg_name)
if os.path.exists(svg_path):
icon_surface = _load_svg_badge_surface(svg_path, target_icon_size)
elif source_key == '1Fichier':
icon_surface = _build_1fichier_badge_icon(target_icon_size)
if icon_surface is None:
icon_surface = _build_text_badge_icon(style['label'], badge_size, style['text'])
if icon_surface is not None:
try:
icon_shadow = pygame.mask.from_surface(icon_surface).to_surface(
setcolor=(0, 0, 0, 90),
unsetcolor=(0, 0, 0, 0),
)
icon_shadow.set_colorkey((0, 0, 0))
shadow_rect = icon_shadow.get_rect(center=(badge_size // 2 + 2, badge_size // 2 + 2))
badge_surface.blit(icon_shadow, shadow_rect)
except Exception:
pass
icon_rect = icon_surface.get_rect(center=(badge_size // 2, badge_size // 2))
badge_surface.blit(icon_surface, icon_rect)
_SOURCE_BADGE_CACHE[cache_key] = badge_surface
return badge_surface
def _apply_platform_source_badge(base_surface, platform_name: str):
if pygame is None or base_surface is None:
return base_surface
source_key = get_platform_source_badge_key(platform_name)
if not source_key:
return base_surface
composed = _safe_convert_alpha(base_surface)
width, height = composed.get_size()
badge_size = max(34, min(int(min(width, height) * 0.34), 180))
badge_surface = get_platform_source_badge_surface(source_key, badge_size)
if badge_surface is None:
return composed
margin = max(5, badge_size // 10)
shadow = pygame.Surface((badge_size + 16, badge_size + 16), pygame.SRCALPHA)
pygame.draw.rect(shadow, (0, 0, 0, 115), (8, 8, badge_size, badge_size), border_radius=max(10, badge_size // 4))
shadow_x = max(0, width - badge_size - margin - 8)
shadow_y = margin - 3
composed.blit(shadow, (shadow_x, shadow_y))
badge_x = max(0, width - badge_size - margin)
badge_y = margin
composed.blit(badge_surface, (badge_x, badge_y))
return composed
def _surface_to_png_bytes(surface):
if pygame is None or surface is None:
return None
try:
raw_bytes = pygame.image.tobytes(surface, 'RGBA')
if Image is not None:
image = Image.frombytes('RGBA', surface.get_size(), raw_bytes)
buffer = io.BytesIO()
image.save(buffer, format='PNG')
return buffer.getvalue()
except Exception as exc:
logger.debug(f"Surface to PNG conversion failed: {exc}")
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(suffix='.png')
os.close(fd)
pygame.image.save(surface, tmp_path)
with open(tmp_path, 'rb') as handle:
return handle.read()
except Exception as exc:
logger.debug(f"Surface temp export failed: {exc}")
return None
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass
def get_platform_image_payload(platform_dict):
image_path = _resolve_platform_image_path(platform_dict)
if not image_path:
return None, None
platform_name = platform_dict.get('platform_name', 'unknown')
try:
if pygame is not None and get_platform_source_badge_key(platform_name):
surface = _safe_convert_alpha(pygame.image.load(image_path))
surface = _apply_platform_source_badge(surface, platform_name)
png_bytes = _surface_to_png_bytes(surface)
if png_bytes:
return png_bytes, 'image/png'
except Exception as exc:
logger.debug(f"Badged image payload failed for {platform_name}: {exc}")
ext = os.path.splitext(image_path)[1].lower()
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
}
try:
with open(image_path, 'rb') as handle:
return handle.read(), mime_types.get(ext, 'image/png')
except Exception as exc:
logger.error(f"Unable to read platform image payload {image_path}: {exc}")
return None, None
def parse_game_size_to_bytes(value) -> int:
if isinstance(value, (int, float)):
@@ -41,10 +422,21 @@ def parse_game_size_to_bytes(value) -> int:
if not isinstance(value, str):
return 0
text = value.strip().replace(',', '.')
text = value.strip()
if not text:
return 0
if ',' in text and '.' in text:
text = text.replace(',', '')
elif ',' in text:
numeric_part_match = re.match(r'^([0-9][0-9,]*)', text)
numeric_part = numeric_part_match.group(1) if numeric_part_match else ''
comma_groups = numeric_part.split(',')
if len(comma_groups) > 1 and all(group.isdigit() for group in comma_groups) and all(len(group) == 3 for group in comma_groups[1:]):
text = text.replace(',', '')
else:
text = text.replace(',', '.')
match = re.match(r'^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]+)?$', text)
if not match:
return 0
@@ -1425,7 +1817,7 @@ CONNECTION_STATUS_TTL_SECONDS = 120
def get_connection_status_targets():
"""Retourne la liste des sites à vérifier pour le status de connexion."""
return [
default_targets = [
{
"key": "retrogamesets",
"label": "Retrogamesets.fr",
@@ -1458,6 +1850,37 @@ def get_connection_status_targets():
},
]
configured = getattr(config, "CONNECTION_STATUS_TARGETS", None)
if not isinstance(configured, list):
return default_targets
normalized = []
seen_keys = set()
for index, item in enumerate(configured):
if not isinstance(item, dict):
continue
raw_key = str(item.get("key", "")).strip().lower()
key = raw_key if raw_key else f"target_{index + 1}"
if key in seen_keys:
continue
url = str(item.get("url", "")).strip()
if not url:
continue
label = str(item.get("label", "")).strip() or url
category = str(item.get("category", "sources")).strip().lower() or "sources"
normalized.append({
"key": key,
"label": label,
"url": url,
"category": category,
})
seen_keys.add(key)
return normalized if normalized else default_targets
def _check_url_connectivity(url: str, timeout: int = 6) -> bool:
"""Teste rapidement la connectivité à une URL (DNS + HTTPS)."""
@@ -2245,48 +2668,13 @@ def load_system_image(platform_dict):
Cela évite d'échouer lorsque le nom affiché ne correspond pas au fichier image
et respecte un mapping explicite fourni par systems_list.json."""
platform_name = platform_dict.get("platform_name", "unknown")
folder_name = platform_dict.get("folder") or ""
# Dossiers d'images
save_images = config.IMAGES_FOLDER
app_images = os.path.join(config.APP_FOLDER, "images")
# Candidats, par ordre de priorité
candidates = []
platform_image_field = (platform_dict.get("platform_image") or "").strip()
if platform_image_field:
candidates.append(os.path.join(save_images, platform_image_field))
candidates.append(os.path.join(save_images, f"{platform_name}.png"))
if folder_name:
candidates.append(os.path.join(save_images, f"{folder_name}.png"))
# Fallback: images packagées avec l'app
if platform_image_field:
candidates.append(os.path.join(app_images, platform_image_field))
candidates.append(os.path.join(app_images, f"{platform_name}.png"))
if folder_name:
candidates.append(os.path.join(app_images, f"{folder_name}.png"))
# Charger le premier fichier existant
try:
for path in candidates:
if path and os.path.exists(path):
return pygame.image.load(path).convert_alpha()
image_path = _resolve_platform_image_path(platform_dict)
if not image_path:
logger.error(f"Aucune image trouvée pour {platform_name}.")
return None
# default.png (save d'abord, sinon app)
default_save = os.path.join(save_images, "default.png")
if os.path.exists(default_save):
return pygame.image.load(default_save).convert_alpha()
default_app = os.path.join(app_images, "default.png")
if os.path.exists(default_app):
return pygame.image.load(default_app).convert_alpha()
logger.error(
f"Aucune image trouvée pour {platform_name}. Candidats: "
+ ", ".join(candidates)
+ f"; default cherchés: {default_save}, {default_app}"
)
return None
return _safe_convert_alpha(pygame.image.load(image_path))
except Exception as e:
logger.error(f"Erreur lors du chargement de l'image pour {platform_name} : {str(e)}")
return None
@@ -2851,7 +3239,28 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
# Chercher le fichier .iso déjà extrait
iso_files = [f for f in os.listdir(dest_dir) if f.endswith('.iso') and not f.endswith('_decrypted.iso')]
if not iso_files:
return False, "Aucun fichier .iso trouvé après extraction"
# Vérifier si le jeu est déjà extrait dans un dossier
game_folders = []
for f in os.listdir(dest_dir):
f_path = os.path.join(dest_dir, f)
if os.path.isdir(f_path):
if f.endswith('.ps3'):
game_folders.append(f)
elif os.path.exists(os.path.join(f_path, 'PS3_GAME')):
game_folders.append(f)
if game_folders:
game_folder = game_folders[0]
game_folder_path = os.path.join(dest_dir, game_folder)
if not game_folder.endswith('.ps3'):
new_name = game_folder + '.ps3'
new_path = os.path.join(dest_dir, new_name)
os.rename(game_folder_path, new_path)
logger.info(f"Dossier renommé: {game_folder} -> {new_name}")
game_folder = new_name
logger.info(f"Dossier de jeu PS3 déjà présent: {game_folder}")
return True, f"Jeu PS3 déjà extrait dans {game_folder}"
else:
return False, "Aucun fichier .iso trouvé après extraction"
iso_file = iso_files[0]
iso_path = os.path.join(dest_dir, iso_file)
@@ -2944,7 +3353,7 @@ def handle_ps3(dest_dir, new_dirs=None, extracted_basename=None, url=None, archi
cmd = [
"powershell", "-Command",
f'$key = (Get-Content "{dkey_escaped}" -Raw).Trim(); ' +
f'$key = [System.IO.File]::ReadAllText("{dkey_escaped}").Trim(); ' +
f'& "{ps3dec_escaped}" d key $key "{iso_escaped}" "{decrypted_escaped}"'
]
else: # Linux
@@ -3874,17 +4283,32 @@ def find_matching_files(base_path, filename):
if not base_path or not os.path.exists(base_path):
return []
candidate_name = Path(str(filename or "")).name
requested_stem, requested_ext = os.path.splitext(candidate_name)
requested_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', requested_stem)).strip().lower()
raw_filename = str(filename or "")
candidate_names = []
for candidate in (Path(raw_filename).name, sanitize_filename(raw_filename)):
if candidate and candidate not in candidate_names:
candidate_names.append(candidate)
if not candidate_names:
return []
requested_variants = []
for candidate_name in candidate_names:
requested_stem, requested_ext = os.path.splitext(candidate_name)
requested_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', requested_stem)).strip().lower()
requested_variants.append((candidate_name, requested_stem, requested_ext, requested_normalized))
archive_exts = {'.zip', '.7z', '.rar', '.tar', '.gz', '.xz', '.bz2'}
matches = []
seen_paths = set()
full_path = os.path.join(base_path, candidate_name)
if os.path.exists(full_path) and os.path.isfile(full_path):
seen_paths.add(os.path.normcase(full_path))
matches.append((1000, candidate_name, full_path))
for candidate_name, _, _, _ in requested_variants:
full_path = os.path.join(base_path, candidate_name)
if os.path.exists(full_path) and os.path.isfile(full_path):
normalized_path = os.path.normcase(full_path)
if normalized_path not in seen_paths:
seen_paths.add(normalized_path)
matches.append((1000, candidate_name, full_path))
for existing_file in os.listdir(base_path):
existing_path = os.path.join(base_path, existing_file)
@@ -3898,17 +4322,21 @@ def find_matching_files(base_path, filename):
existing_stem, existing_ext = os.path.splitext(existing_file)
score = None
if requested_stem and existing_stem == requested_stem:
score = 900
else:
existing_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', existing_stem)).strip().lower()
if requested_normalized and existing_normalized and existing_normalized == requested_normalized:
score = 0
existing_normalized = re.sub(r'\s+', ' ', re.sub(r'\s*[\[(][^\])]*[\])]', '', existing_stem)).strip().lower()
for _, requested_stem, requested_ext, requested_normalized in requested_variants:
candidate_score = None
if requested_stem and existing_stem == requested_stem:
candidate_score = 900
elif requested_normalized and existing_normalized and existing_normalized == requested_normalized:
candidate_score = 0
if requested_ext and existing_ext.lower() == requested_ext.lower():
score += 4
candidate_score += 4
if existing_ext.lower() not in archive_exts:
score += 3
score -= abs(len(existing_stem) - len(requested_stem))
candidate_score += 3
candidate_score -= abs(len(existing_stem) - len(requested_stem))
if candidate_score is not None:
score = candidate_score if score is None else max(score, candidate_score)
if score is not None:
seen_paths.add(normalized_path)
@@ -3924,24 +4352,115 @@ def get_existing_history_matches(entry):
return []
moved_paths = entry.get("moved_paths", []) or []
local_path = entry.get("local_path")
local_filename = entry.get("local_filename")
game_name = entry.get("game_name", "")
if local_path:
moved_paths = [local_path, *moved_paths]
direct_matches = []
direct_candidates = []
if local_path:
direct_candidates.append(str(local_path))
platform_name = (entry.get("platform") or "").strip()
base_path = None
if platform_name:
try:
base_path = os.path.join(config.ROMS_FOLDER, _get_dest_folder_name(platform_name))
except Exception:
base_path = None
if local_filename and base_path:
direct_candidates.append(os.path.join(base_path, str(local_filename)))
seen_direct = set()
for candidate in direct_candidates:
actual_path = os.path.abspath(str(candidate))
normalized_path = os.path.normcase(actual_path)
if normalized_path in seen_direct:
continue
seen_direct.add(normalized_path)
exists = os.path.isfile(actual_path)
if exists:
direct_matches.append((os.path.basename(actual_path), actual_path))
if direct_matches:
return direct_matches
candidate_paths = []
for raw_path in moved_paths:
if raw_path:
candidate_paths.append(str(raw_path))
if local_filename and base_path:
candidate_paths.insert(0, os.path.join(base_path, str(local_filename)))
matches = []
seen_paths = set()
for raw_path in moved_paths:
for raw_path in candidate_paths:
if not raw_path:
continue
actual_path = os.path.abspath(str(raw_path))
normalized_path = os.path.normcase(actual_path)
if normalized_path in seen_paths or not os.path.isfile(actual_path):
continue
raw_path = str(raw_path)
fallback_paths = [os.path.abspath(raw_path)]
if base_path:
fallback_paths.append(os.path.join(base_path, os.path.basename(raw_path)))
seen_paths.add(normalized_path)
matches.append((os.path.basename(actual_path), actual_path))
for actual_path in fallback_paths:
normalized_path = os.path.normcase(actual_path)
exists = os.path.isfile(actual_path)
if normalized_path in seen_paths or not exists:
continue
seen_paths.add(normalized_path)
matches.append((os.path.basename(actual_path), actual_path))
break
if not matches:
logger.debug(
"[HISTORY_MATCH_LOOKUP] no_match game=%s platform=%s",
game_name,
platform_name,
)
return matches
def remember_history_local_match(entry, actual_filename, actual_path):
"""Persist a resolved local path for a history entry so later lookups are exact."""
if not isinstance(entry, dict) or not actual_path:
return False
absolute_path = os.path.abspath(str(actual_path))
filename = actual_filename or os.path.basename(absolute_path)
changed = False
if entry.get("local_path") != absolute_path:
entry["local_path"] = absolute_path
changed = True
if entry.get("local_filename") != filename:
entry["local_filename"] = filename
changed = True
moved_paths = entry.get("moved_paths")
if not isinstance(moved_paths, list):
moved_paths = []
if absolute_path not in moved_paths:
moved_paths.insert(0, absolute_path)
changed = True
entry["moved_paths"] = moved_paths
if changed:
try:
from history import save_history
save_history(config.history)
except Exception as e:
logger.debug(f"Impossible de mémoriser le chemin local de l'historique: {e}")
return changed
def move_files_to_directory(file_paths, destination_dir):
"""Move files to a destination directory, avoiding name collisions."""
if not destination_dir:

View File

@@ -1,3 +1,3 @@
{
"version": "2.6.3.1"
"version": "2.6.4.1"
}

View File

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