Basculer le menu
Changer de menu des préférences
Basculer le menu personnel
Non connecté(e)
Votre adresse IP sera visible au public si vous faites des modifications.

ROMM

De Le Wiki de Lug

Prérequis

  • Alpine LXC avec Nesting et keyctl activé (Docker).

Ressource confortable :

  • 2 CPU
  • 2Gb de RAM
  • 16GB de Disque (Dépend du nombres de roms, peut monter très vite..)

Il est recommandé d'organiser les fichiers de la façon suivante :

/roms/{platform}/
/bios/{platform}/

Il est également nécessaire d'utiliser des noms de dossier reconnu par ROMM, exemple :

roms/
  nes
  snes
  n64
  gb
  gbc
  gba
  genesis
  segacd
  sega32
  neogeomvs
  neo-geo-pocket
  tg16
  pcenginecd
  wonderswan
  wonderswan-color

Metadata

Afin d'obtenir les jaquettes etc il est recommandé de :

* créer un compte chez screenscraper
* créer une clef API chez steamgriddb
* créer une application "twitch developers" (nécessite un mobile) pour certaines focntions, suivre ce tutoriel.

Installation

# apk update && apk upgrade
# apk add docker docker-cli-compose
# rc-update add docker default
# rc-service docker start
# mkdir -p /opt/romm /opt/romm/config /opt/romm/data /opt/romm/assets
# cd /opt/romm

Fichier de configuration du conteneur :

# vi docker-compose.yml
version: "3.8"

volumes:
  mysql_data:
  romm_resources:
  romm_redis_data:

services:
  romm:
    image: ghcr.io/rommapp/romm:latest
    container_name: romm
    restart: unless-stopped
    environment:
      - TZ=Europe/Madrid

      # Database (MariaDB)
      - DB_HOST=romm-db
      - DB_NAME=romm
      - DB_USER=romm-user
      - DB_PASSWD=CHANGE_MOI_ROMM_MARIADB_PASSWORD # mot de passe de "romm-user", identique plus bas!!

      # Required auth secret (IMPORTANT)
      - ROMM_AUTH_SECRET_KEY=CHANGE_MOI_AUTH_SECRET_KEY

      # Metadata providers (optionnel mais recommandé)
      - SCREENSCRAPER_USER=ton_user
      - SCREENSCRAPER_PASSWORD=ton_mdp
      # - RETROACHIEVEMENTS_API_KEY=ta_cle
      - STEAMGRIDDB_API_KEY=ta_cle
      - HASHEOUS_API_ENABLED=true
      - IGDB_CLIENT_ID=idapplitwitch
      - IGDB_CLIENT_SECRET=motdepassapplitwitch

      - SCAN_TIMEOUT=86400 # timeout de 24h
      - SCAN_WORKERS=4 # limite IGBD
      - TASK_TIMEOUT=3600 # plus gros timeout
      - WEB_SERVER_TIMEOUT=3600 # plus gros timeout
    volumes:
      # Cache / ressources / jobs
      #- romm_resources:/romm/resources
      - /mnt/Emulation/resources:/romm/resources:rw # stockage images, manuels etc..
      - romm_redis_data:/redis-data

      # Ta bibliothèque ROM (lecture seule)
      - /mnt/Emulation/roms:/romm/library/roms:rw # ro pour read-only
      - /mnt/Emulation/bios:/romm/library/bios:rw

      # Assets RomM (écriture)
      - /opt/romm/assets:/romm/assets

      # Config (config.yml)
      - /opt/romm/config:/romm/config:rw

    ports:
      - "8080:8080"

    depends_on:
      romm-db:
        condition: service_healthy

  romm-db:
    image: mariadb:11
    container_name: romm-db
    restart: unless-stopped
    environment:
      - MARIADB_ROOT_PASSWORD=CHANGE_MOI_ROOT_PASSWORD
      - MARIADB_DATABASE=romm
      - MARIADB_USER=romm-user
      - MARIADB_PASSWORD=CHANGE_MOI_ROMM_MARIADB_PASSWORD  # mot de passe de "romm-user", identique plus haut!!
    volumes:
      - mysql_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 10

Fichier de configuration de ROMM :

# vi /opt/romm/config/config.yml
filesystem:
  roms: /romm/library/roms
  bios: /romm/library/bios

scan:
  export_gamelist: true # optionnel
  priority:
    metadata:
      - "ss"
      - "hasheous"

    artwork:
      - "ss"
      - "steamgriddb"

    region:
      - "eu"
      - "us"
      - "jp"

    language:
      - "fr"
      - "en"
      - "es"

  media:
    - box2d
    - screenshot
    - marquee
    - fanart
    - manual
    - title_screen
    - bezel

emulatorjs:
  debug: false
  cache_limit: null
  disable_batch_bootup: false
  disable_auto_unload: false

  netplay:
    enabled: true
    ice_servers:
      - urls: "stun:stun.l.google.com:19302"
      - urls: "stun:stun1.l.google.com:19302"
      - urls: "stun:stun2.l.google.com:19302"
      - urls: "turn:openrelay.metered.ca:80"
        username: "openrelayproject"
        credential: "openrelayproject"
      - urls: "turn:openrelay.metered.ca:443"
        username: "openrelayproject"
        credential: "openrelayproject"
# docker compose up -d

vhost nginx

server {
    listen 80;
    listen [::]:80;
    server_name site.example.net;

    # Force HTTPS
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name site.example.net;

    # Uploads (RomM peut être "gros")
    client_max_body_size 0;

    # TLS (Certbot)
    ssl_certificate     /etc/letsencrypt/live/site.example.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/site.example.net/privkey.pem;
    include             /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam         /etc/letsencrypt/ssl-dhparams.pem;

    # HSTS (optionnel)
    add_header Strict-Transport-Security "max-age=31536000" always;

    # Sécurité / info
    server_tokens off;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        # WebSocket + keep-alive
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Headers reverse-proxy
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Port $server_port;

        # Timeouts (scans/transferts longs)
        proxy_read_timeout 3600;
        proxy_send_timeout 3600;

        # Backend RomM
        proxy_pass http://IP_SERVEUR_WEB:8080;

        # Optionnel: si tu as des soucis de buffering
        # proxy_buffering off;
    }
}

Augmenter le timeout du Nginx du conteneur pour grosse BDD de roms

On rentre dans le conteneur :

# docker exec -it romm sh

(Optionnel) On sauvegarde la configuration d'origine :

# cp /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak

On édite la configuration :

# vi /etc/nginx/conf.d/default.conf

Et on ajoute (en bleu) :

...
    # OpenAPI for swagger and redoc
    location /openapi.json {
        proxy_pass http://wsgi_server;
        proxy_connect_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_read_timeout 3600s;
    }

    # Backend api calls
    location /api {
        proxy_pass http://wsgi_server;
        proxy_request_buffering off;
        proxy_buffering off;
        proxy_connect_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_read_timeout 3600s;
    }

    location ~ ^/(ws|netplay) {
        proxy_pass http://wsgi_server;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_connect_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_read_timeout 3600s;
    }
...

Puis on redémarre le service :

# nginx -s reload
# exit

convertir les gamelist.xml en metadata.txt

Pour passer de ROMM à Pegasus : lien (déconseillé)

Mais le convertisseur officiel ne convertie pas les assets et il faut rajouter à la main les collections etc, on peut utiliser un script local plus optimisé :

# vi /mnt/Media/Media/Emulation/gamelist_to_pegasus.py
#!/usr/bin/env python3

import sys
import xml.etree.ElementTree as ET
from pathlib import Path

ROOT = Path("/mnt/Media/Media/Emulation/roms")
if len(sys.argv) > 1:
    ROOT = Path(sys.argv[1])

ADD_LAUNCH = True
RETROARCH = "retroarch --fullscreen"
CORE_DIR = "/home/retro/.config/retroarch/cores"


def clean(text):
    if text is None:
        return ""
    return text.replace("\r", " ").replace("\n", " ").strip()


def collection_info_from_dir(dirname: str):
    slug = dirname.strip().lower()

    mapping = {
        "arcade": ("Arcade", "arcade"),
        "neogeo": ("Neo Geo", "neogeo"),
        "neogeoaes": ("Neo Geo AES", "neogeo"),
        "neogeomvs": ("Neo Geo MVS", "neogeo"),
        "neo-geo-cd": ("Neo Geo CD", "neo-geo-cd"),
        "neo-geo-pocket": ("Neo Geo Pocket", "ngp"),
        "neo-geo-pocket-color": ("Neo Geo Pocket Color", "ngpc"),
        "genesis": ("Sega Mega Drive/Genesis", "megadrive"),
        "megadrive": ("Sega Mega Drive/Genesis", "megadrive"),
        "segacd": ("Sega CD", "segacd"),
        "segacd32": ("Sega CD 32X", "segacd32"),
        "sega32": ("Sega 32X", "sega32x"),
        "sms": ("Sega Master System/Mark III", "mastersystem"),
        "mastersystem": ("Sega Master System/Mark III", "sms"),
        "gamegear": ("Sega Game Gear", "gamegear"),
        "sg1000": ("SG-1000", "sg-1000"),
        "sc3000": ("SC-3000", "sc-3000"),
        "tg16": ("TurboGrafx-16/PC Engine", "tg16"),
        "pcengine": ("TurboGrafx-16/PC Engine", "tg16"),
        "pcenginecd": ("Turbografx-16/PC Engine CD", "tg-cd"),
        "pcecd": ("Turbografx-16/PC Engine CD", "tg-cd"),
        "turbografx-cd": ("Turbografx-16/PC Engine CD", "tg-cd"),
        "supergrafx": ("PC Engine SuperGrafx", "supergrafx"),
        "nes": ("Nintendo Entertainment System", "nes"),
        "snes": ("Super Nintendo Entertainment System", "snes"),
        "sfam": ("Super Famicom", "sfam"),
        "gb": ("Game Boy", "gb"),
        "gbc": ("Game Boy Color", "gbc"),
        "gba": ("Game Boy Advance", "gba"),
        "psx": ("PlayStation", "psx"),
        "ps1": ("PlayStation", "psx"),
        "saturn": ("Sega Saturn", "saturn"),
        "dc": ("Dreamcast", "dreamcast"),
        "n64": ("Nintendo 64", "n64"),
        "nds": ("Nintendo DS", "nds"),
        "ngc": ("Nintendo GameCube", "gc"),
        "wii": ("Wii", "wii"),
        "lynx": ("Atari Lynx", "atarilynx"),
        "jaguar": ("Atari Jaguar", "atarijaguar"),
        "atari-jaguar-cd": ("Atari Jaguar CD", "atarijaguarcd"),
        "wonderswan": ("WonderSwan", "wonderswan"),
        "wonderswan-color": ("WonderSwan Color", "wonderswancolor"),
        "msx": ("MSX", "msx"),
        "msx2": ("MSX2", "msx2"),
        "amiga": ("Amiga", "amiga"),
        "amiga-cd32": ("Amiga CD32", "amiga-cd32"),
        "scummvm": ("ScummVM", "scummvm"),
        "3do": ("3DO Interactive Multiplayer", "3do"),
        "psp": ("PlayStation Portable", "psp"),
        "psvita": ("PlayStation Vita", "psvita"),
        "vectrex": ("Vectrex", "vectrex"),
        "virtualboy": ("Virtual Boy", "virtualboy"),
        "zxs": ("ZX Spectrum", "zxs"),
        "c64": ("Commodore C64/128/MAX", "c64"),
        "c128": ("Commodore 128", "c128"),
        "vic-20": ("Commodore VIC-20", "vic-20"),
        "colecovision": ("ColecoVision", "colecovision"),
        "atari2600": ("Atari 2600", "atari2600"),
        "atari5200": ("Atari 5200", "atari5200"),
        "atari7800": ("Atari 7800", "atari7800"),
        "naomi": ("Sega Naomi", "naomi"),
        "naomi2": ("Sega Naomi 2", "naomi2"),
        "triforce": ("Sega Triforce", "triforce"),
        "3do": ("3DO Interactive Multiplayer", "3do"),
        "g-and-w": ("Game & Watch", "gameandwatch"),
    }

    if slug in mapping:
        return mapping[slug]

    return slug.replace("-", " ").replace("_", " ").title(), slug


def launch_for_shortname(shortname: str):
    launches = {
         # arcade / neo geo
         "arcade": f'{RETROARCH} -L {CORE_DIR}/fbneo_libretro.so "{{file.path}}"',
         "neogeo": f'{RETROARCH} -L {CORE_DIR}/fbneo_libretro.so "{{file.path}}"',
         "neogeoaes": f'{RETROARCH} -L {CORE_DIR}/fbneo_libretro.so "{{file.path}}"',
         "neogeomvs": f'{RETROARCH} -L {CORE_DIR}/fbneo_libretro.so "{{file.path}}"',
         "ngp": f'{RETROARCH} -L {CORE_DIR}/mednafen_ngp_libretro.so "{{file.path}}"',
         "ngpc": f'{RETROARCH} -L {CORE_DIR}/mednafen_ngp_libretro.so "{{file.path}}"',
 
         # sega
         "megadrive": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
         "segacd": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
         "segacd32": f'{RETROARCH} -L {CORE_DIR}/picodrive_libretro.so "{{file.path}}"',
         "sega32x": f'{RETROARCH} -L {CORE_DIR}/picodrive_libretro.so "{{file.path}}"',
         "mastersystem": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
         "gamegear": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
         "sg-1000": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
         "sc-3000": f'{RETROARCH} -L {CORE_DIR}/genesis_plus_gx_libretro.so "{{file.path}}"',
 
         # NEC
         "tg16": f'{RETROARCH} -L {CORE_DIR}/mednafen_pce_fast_libretro.so "{{file.path}}"',
         "tg-cd": f'{RETROARCH} -L {CORE_DIR}/mednafen_pce_fast_libretro.so "{{file.path}}"',
         "supergrafx": f'{RETROARCH} -L {CORE_DIR}/mednafen_pce_libretro.so "{{file.path}}"',
 
         # Nintendo
         "nes": f'{RETROARCH} -L {CORE_DIR}/nestopia_libretro.so "{{file.path}}"',
         "snes": f'{RETROARCH} -L {CORE_DIR}/snes9x_libretro.so "{{file.path}}"',
         "sfam": f'{RETROARCH} -L {CORE_DIR}/snes9x_libretro.so "{{file.path}}"',
         "gb": f'{RETROARCH} -L {CORE_DIR}/gambatte_libretro.so "{{file.path}}"',
         "gbc": f'{RETROARCH} -L {CORE_DIR}/gambatte_libretro.so "{{file.path}}"',
         "gba": f'{RETROARCH} -L {CORE_DIR}/mgba_libretro.so "{{file.path}}"',
         "n64": f'{RETROARCH} -L {CORE_DIR}/mupen64plus_next_libretro.so "{{file.path}}"',
         "nds": f'{RETROARCH} -L {CORE_DIR}/melondsds_libretro.so "{{file.path}}"',
         "gc": '/Applications/dolphin-emu.AppImage --appimage-extract-and-run -b -e "{file.path}"',
         "wii": '/Applications/dolphin-emu.AppImage --appimage-extract-and-run -b -e "{file.path}"',
         "virtualboy": f'{RETROARCH} -L {CORE_DIR}/mednafen_vb_libretro.so "{{file.path}}"',
         #"gameandwatch": f'{RETROARCH} -L {CORE_DIR}/gw_libretro.so "{{file.path}}"',
         "gameandwatch": f'{RETROARCH} -L {CORE_DIR}/mame_libretro.so "{{file.path}}"',
 
         # Sony
         "psx": f'{RETROARCH} -L {CORE_DIR}/pcsx_rearmed_libretro.so "{{file.path}}"',
         "psp": '/opt/emulator/ppsspp/PPSSPP-v1.20.3-anylinux-x86_64.AppImage "{file.path}"',
 
         # Sega Saturn / Dreamcast
         "saturn": f'{RETROARCH} -L {CORE_DIR}/mednafen_saturn_libretro.so "{{file.path}}"',
         "dreamcast": f'{RETROARCH} -L {CORE_DIR}/flycast_libretro.so "{{file.path}}"',
 
         # Atari
         "atarilynx": f'{RETROARCH} -L {CORE_DIR}/mednafen_lynx_libretro.so "{{file.path}}"',
         #"atarijaguar": f'{RETROARCH} -L {CORE_DIR}/virtualjaguar_libretro.so "{{file.path}}"',
         #"atarijaguarcd": f'{RETROARCH} -L {CORE_DIR}/virtualjaguar_libretro.so "{{file.path}}"',
         "atarijaguar": '/opt/emulator/bigpemu/bigpemu "{file.path}"',
         "atarijaguarcd": '/opt/emulator/bigpemu/bigpemu "{file.path}"',
         "atari2600": f'{RETROARCH} -L {CORE_DIR}/stella_libretro.so "{{file.path}}"',
         "atari5200": f'{RETROARCH} -L {CORE_DIR}/a5200_libretro.so "{{file.path}}"',
         "atari7800": f'{RETROARCH} -L {CORE_DIR}/prosystem_libretro.so "{{file.path}}"',
 
         # others
         "wonderswan": f'{RETROARCH} -L {CORE_DIR}/mednafen_wswan_libretro.so "{{file.path}}"',
         "wonderswancolor": f'{RETROARCH} -L {CORE_DIR}/mednafen_wswan_libretro.so "{{file.path}}"',
         "msx": f'{RETROARCH} -L {CORE_DIR}/fmsx_libretro.so "{{file.path}}"',
         "msx2": f'{RETROARCH} -L {CORE_DIR}/fmsx_libretro.so "{{file.path}}"',
         "amiga": f'{RETROARCH} -L {CORE_DIR}/puae_libretro.so "{{file.path}}"',
         "amiga-cd32": f'{RETROARCH} -L {CORE_DIR}/puae_libretro.so "{{file.path}}"',
         "scummvm": f'{RETROARCH} -L {CORE_DIR}/scummvm_libretro.so "{{file.path}}"',
         "vectrex": f'{RETROARCH} -L {CORE_DIR}/vecx_libretro.so "{{file.path}}"',
         "colecovision": f'{RETROARCH} -L {CORE_DIR}/gearcoleco_libretro.so "{{file.path}}"',
         "c64": f'{RETROARCH} -L {CORE_DIR}/vice_x64_libretro.so "{{file.path}}"',
         "c128": f'{RETROARCH} -L {CORE_DIR}/vice_x128_libretro.so "{{file.path}}"',
         "naomi": f'{RETROARCH} -L {CORE_DIR}/flycast_libretro.so "{{file.path}}"',
         "naomi2": f'{RETROARCH} -L {CORE_DIR}/flycast_libretro.so "{{file.path}}"',
         "triforce": f'{RETROARCH} -L {CORE_DIR}/flycast_libretro.so "{{file.path}}"',
         "vic-20": f'{RETROARCH} -L {CORE_DIR}/vice_xvic_libretro.so "{{file.path}}"',
         "3do": f'{RETROARCH} -L {CORE_DIR}/opera_libretro.so "{{file.path}}"',
    }
    return launches.get(shortname)


def detect_extensions(games):
    exts = set()
    for game in games:
        path = clean(game.findtext("path"))
        if path:
            suffix = Path(path).suffix.lower().lstrip(".")
            if suffix:
                exts.add(suffix)
    return sorted(exts)


def write_line(out, key, value):
    value = clean(value)
    if value:
        out.write(f"{key}: {value}\n")


def normalize_relpath(path_str: str) -> str:
    path_str = clean(path_str)
    if path_str.startswith("./"):
        path_str = path_str[2:]
    return path_str


def read_m3u_entries(m3u_path: Path):
    """
    Lit un .m3u et retourne un set de chemins relatifs normalisés,
    tels qu'ils apparaissent dans le dossier de la rom.
    """
    entries = set()

    try:
        with m3u_path.open("r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                line = line.strip()
                 if not line or line.startswith("#"):
                    continue

                rel = normalize_relpath(line)
                entries.add(rel)
    except Exception as e:
        print(f"[WARN] Failed to read m3u {m3u_path}: {e}")

    return entries


def build_m3u_referenced_files(games, romdir: Path):
    """
    Retourne l'ensemble des fichiers référencés par les .m3u présents
    dans ce gamelist.xml.
    """
    referenced = set()

    for game in games:
        path = clean(game.findtext("path"))
        if not path:
            continue

        rel = normalize_relpath(path)
        p = romdir / rel

        if p.suffix.lower() == ".m3u" and p.is_file():
            referenced.update(read_m3u_entries(p))

    return referenced


def convert_gamelist(gamelist_path: Path):
    romdir = gamelist_path.parent
    dirname = romdir.name
    out_path = romdir / "metadata.pegasus.txt"

    try:
        tree = ET.parse(gamelist_path)
        root = tree.getroot()
    except Exception as e:
        print(f"[ERR] {gamelist_path}: {e}")
        return

    games = root.findall("game")
    if not games:
        print(f"[WARN] {gamelist_path}: no <game> entries")
        return

    collection, shortname = collection_info_from_dir(dirname)
    exts = detect_extensions(games)

    # Tous les fichiers référencés par des .m3u présents dans ce dossier
    m3u_referenced_files = build_m3u_referenced_files(games, romdir)

    written_count = 0
    skipped_count = 0

    with out_path.open("w", encoding="utf-8") as out:
        out.write(f"collection: {collection}\n")
        out.write(f"shortname: {shortname}\n")
        if exts:
            out.write(f"extensions: {' '.join(exts)}\n")

        if ADD_LAUNCH:
            launch = launch_for_shortname(shortname)
            if launch:
                out.write(f"launch: {launch}\n")

        out.write("\n")

        for game in games:
            name = clean(game.findtext("name"))
            path = clean(game.findtext("path"))
            if not path:
                continue

            rel_path = normalize_relpath(path)
            rel_path_posix = Path(rel_path).as_posix()

            # On skip si ce fichier est référencé par un .m3u
            # MAIS on ne skip pas le .m3u lui-même
            if rel_path_posix in m3u_referenced_files and Path(rel_path_posix).suffix.lower() != ".m3u":
                skipped_count += 1
                continue

            out.write(f"game: {name or Path(rel_path_posix).stem}\n")
            out.write(f"file: {rel_path_posix}\n")

            write_line(out, "description", game.findtext("desc"))
            write_line(out, "developer", game.findtext("developer"))
            write_line(out, "publisher", game.findtext("publisher"))
            write_line(out, "genre", game.findtext("genre"))

            releasedate = clean(game.findtext("releasedate"))
            if releasedate:
                if len(releasedate) >= 8 and releasedate[:8].isdigit():
                    rd = releasedate[:8]
                    out.write(f"release: {rd[:4]}-{rd[4:6]}-{rd[6:8]}\n")
                else:
                    out.write(f"release: {releasedate}\n")

            rating = clean(game.findtext("rating"))
            if rating:
                out.write(f"rating: {rating}\n")

            thumbnail = clean(game.findtext("thumbnail"))
            image = clean(game.findtext("image"))
            screenshot = clean(game.findtext("screenshot"))
            fanart = clean(game.findtext("fanart"))
            video = clean(game.findtext("video"))

            if thumbnail:
                out.write(f"assets.box_front: {thumbnail}\n")
            elif image:
                out.write(f"assets.box_front: {image}\n")

            if screenshot:
                out.write(f"assets.screenshot: {screenshot}\n")
            if fanart:
                out.write(f"assets.background: {fanart}\n")
            if video:
                out.write(f"assets.video: {video}\n")

            out.write("\n")
            written_count += 1

    print(f"[OK] {gamelist_path} -> {out_path} ({written_count} games, {skipped_count} skipped via m3u)")


def main():
    gamelists = list(ROOT.rglob("gamelist.xml"))
    if not gamelists:
        print(f"[WARN] No gamelist.xml found under {ROOT}")
        return

    for gamelist in sorted(gamelists):
        convert_gamelist(gamelist)


if __name__ == "__main__":
    main()
# chmod +x /mnt/Media/Media/Emulation/gamelist_to_pegasus.py

Pour l'exécuter :

# python3 /mnt/Media/Media/Emulation/gamelist_to_pegasus.py

Il faudra penser à télécharger le launcher associé si nécessaire dans le Retroarch de Pegasus (Main Menu -> Online Updater -> -> Update Core Info Files -> Core Downloader)

Fichiers .m3u

Afin de gérer les jeu utilisant plusieurs CD/DVD il est nécessaire de creer un fichier liste .m3u :

Pour les fichiers .CHD ou .RVZ :

# cd /mes/roms/plateforme/

Coller la commande suivante :

#!/usr/bin/env bash

unset groups counts seen_files
declare -A groups
declare -A counts
declare -A seen_files

for file in *.chd *.rvz; do
    [ -e "$file" ] || continue

    name="${file%.*}"

    # Enlever uniquement le suffixe disque en fin de nom
    base="$(printf '%s\n' "$name" | sed -E '
           s/[[:space:]]*-[[:space:]]*(disc|disk|cd)[[:space:]]*[0-9]+$//I
           s/[[:space:]]*\((disc|disk|cd)[[:space:]]*[0-9]+\)$//I
           s/[[:space:]]+(disc|disk|cd)[[:space:]]*[0-9]+$//I
           s/(disc|disk|cd)[[:space:]]*[0-9]+$//I
    ')"

    [ "$base" != "$name" ] || continue

    group="$base"

    key="$group"$'\t'"$file"
    [ -n "${seen_files["$key"]}" ] && continue
    seen_files["$key"]=1

    groups["$group"]+="$file"$'\n'
    ((counts["$group"]++))
done

for group in "${!groups[@]}"; do
    [ "${counts["$group"]}" -ge 2 ] || continue
    printf '%s' "${groups["$group"]}" | sed '/^$/d' | sort -V > "$group.m3u"
done

Variante pour ignorer parenthèses après "(Disc 1)" etc :

#!/usr/bin/env bash

unset groups counts seen_files
declare -A groups
declare -A counts
declare -A seen_files

for file in *.chd *.rvz; do
    [ -e "$file" ] || continue

    name="${file%.*}"

    base="$(printf '%s\n' "$name" | sed -E '
         s/[[:space:]]*-[[:space:]]*(disc|disk|cd)[[:space:]]*[0-9]+([[:space:]]*\([^)]*\))*$//I
         s/[[:space:]]*\((disc|disk|cd)[[:space:]]*[0-9]+\)([[:space:]]*\([^)]*\))*$//I
         s/[[:space:]]+(disc|disk|cd)[[:space:]]*[0-9]+([[:space:]]*\([^)]*\))*$//I
         s/(disc|disk|cd)[[:space:]]*[0-9]+([[:space:]]*\([^)]*\))*$//I
    ')"

    [ "$base" != "$name" ] || continue

    group="$base"
    key="$group"$'\t'"$file"
    [ -n "${seen_files["$key"]:-}" ] && continue
    seen_files["$key"]=1

    groups["$group"]+="$file"$'\n'
    ((counts["$group"]++))
done

for group in "${!groups[@]}"; do
    [ "${counts["$group"]}" -ge 2 ] || continue
    printf '%s' "${groups["$group"]}" | sed '/^$/d' | sort -V > "$group.m3u"
done

Spécial Saturn

# vi make_m3u.py
#!/usr/bin/env python3
from __future__ import annotations

import re
from pathlib import Path
from collections import defaultdict

DELETE_OLD_M3U = True
EXTENSIONS = {".chd", ".rvz"}

# Si un fichier contient un de ces termes, on l'ignore complètement
EXCLUDE_FILE_TERMS = [
    "omake",
    "portrait",
    "interview",
    "making",
    "calendar",
    "yobikake-kun",
    "demo",
    "trial",
    "kawaraban",
    "digital edition",
    "special cd",
    "premium cd",
    "opening disc",
    "extra disc",
    "addition",
    "append",
    "bonus disc",
    "bonus",
    "premium disc",
]

# Groupes qu'on ne veut jamais transformer en .m3u
EXCLUDE_GROUP_TERMS = [
    "private idol",
    "thunder storm & road blaster",
    "time gal & ninja hayate",
    "dungeons & dragons collection",
    "street fighter collection",
    "wangan dead heat + real arrange",
    "virtuacall s",
    "twinkle star sprites",
    "senkutsu katsuryu taisen - chaos seed",
    "pia carrot e youkoso!! 2",
    "mahjong-kyou jidai cebu island",
    "mahjong doukyuusei special",
    "mahjong gakuensai",
    "falcom classics",
    "friends - seishun no kagayaki",
    "last bronx",
    "voice fantasia s - ushinawareta voice power",
    "idol janshi suchie-pai ii",
    "jantei battle cos-player",
    "elf o karu monotachi",
    "elf o karu monotachi ii",
    "daisuki",
    "sengoku blade - sengoku ace episode ii",
    "nanatsu kaze no shima monogatari",
]

# Détection stricte de Disc/CD/Disk + numéro/lettre
DISC_RE = re.compile(
    r'(?i)(?:^|[\s(.\-])(?:disc|disk|cd)\s*([0-9]+|[A-Za-z])(?:[^\w]|$)'
)

# Pour extraire la base commune avant le premier marqueur disque
BASE_PATTERNS = [
    re.compile(r'(?i)^(.*?)[ \t]*-[ \t]*(?:disc|disk|cd)\s*([0-9]+|[A-Za-z])(?:[^\w].*|$)'),
    re.compile(r'(?i)^(.*?)\s*\((?:disc|disk|cd)\s*([0-9]+|[A-Za-z])\)(?:.*|$)'),
    re.compile(r'(?i)^(.*?)\s+(?:disc|disk|cd)\s*([0-9]+|[A-Za-z])(?:[^\w].*|$)'),
]

def norm_spaces(s: str) -> str:
    s = re.sub(r"\s+", " ", s).strip()
    s = re.sub(r"\s+\)", ")", s)
    s = re.sub(r"\(\s+", "(", s)
    return s.strip()

def should_exclude_file(stem: str) -> bool:
    s = stem.lower()
    return any(term in s for term in EXCLUDE_FILE_TERMS)

def should_exclude_group(group: str) -> bool:
   s = group.lower()
     return any(term in s for term in EXCLUDE_GROUP_TERMS)

def has_real_disc_marker(stem: str) -> bool:
    return DISC_RE.search(stem) is not None

def extract_base(stem: str) -> str | None:
    for pat in BASE_PATTERNS:
        m = pat.match(stem)
        if m:
            return norm_spaces(m.group(1))
    return None

def disc_sort_key(stem: str) -> tuple[int, str]:
    m = DISC_RE.search(stem)
    if not m:
        return (9999, stem.lower())
    token = m.group(1)
    if token.isdigit():
        return (int(token), stem.lower())
    return (ord(token.lower()) - 96, stem.lower())

def main() -> None:
    cwd = Path(".")

    if DELETE_OLD_M3U:
        for m3u in cwd.glob("*.m3u"):
            m3u.unlink()

    groups: dict[str, list[Path]] = defaultdict(list)
    seen = set()

    for path in sorted(cwd.iterdir(), key=lambda p: p.name.lower()):
        if not path.is_file():
            continue
        if path.suffix.lower() not in EXTENSIONS:
            continue

        stem = path.stem

        if should_exclude_file(stem):
            continue
        if not has_real_disc_marker(stem):
            continue

        base = extract_base(stem)
        if not base or base == stem:
            continue
        if should_exclude_group(base):
            continue

        key = (base, path.name)
        if key in seen:
            continue
        seen.add(key)

        groups[base].append(path)

    generated = 0

    for base in sorted(groups, key=str.lower):
        files = sorted(groups[base], key=lambda p: disc_sort_key(p.stem))
        if len(files) < 2:
            continue

        outfile = cwd / f"{base}.m3u"
        outfile.write_text(
            "\n".join(p.name for p in files) + "\n",
            encoding="utf-8"
        )
        print(f"Créé : {outfile.name} ({len(files)} disques)")
        generated += 1

    print(f"\nTerminé. {generated} fichier(s) .m3u généré(s).")

if __name__ == "__main__":
    main()
# chmod +x make_m3u.py
# ./make_m3u.py
# rm make_m3u.py

Convertir bin et cue dans une archive 7z/zip en fichier CHD

Si nécessaire il faut installer les outils de MAME pour avoir chdman :

# apt install mame-tools

Si nécessaire installer les outils pour gérer les archives 7z :

# apt install p7zip-full

Puis on créé le script (à placer dans le dossier contenant les archives .7z/zip :

# cd /mon/dossier/
# vi convertchd.sh
#!/usr/bin/env bash

shopt -s nullglob

for f in *.7z *.zip; do
    [ -e "$f" ] || continue

    name="${f%.*}"
    tmpdir="$(mktemp -d)"

    echo "==> Extraction de : $f"

    case "${f##*.}" in
        7z)
            if ! 7z x -y "$f" -o"$tmpdir" >/dev/null; then
                echo "Erreur extraction : $f"
                rm -rf "$tmpdir"
                continue
            fi
            ;;
        zip)
            if ! unzip -q "$f" -d "$tmpdir"; then
                echo "Erreur extraction : $f"
                rm -rf "$tmpdir"
                continue
            fi
            ;;
        *)
            echo "Extension non supportée : $f"
            rm -rf "$tmpdir"
            continue
            ;;
    esac

    cue="$(find "$tmpdir" -type f -iname "*.cue" | head -n 1)"

    if [ -z "$cue" ]; then
        echo "Aucun .cue trouvé dans : $f"
        rm -rf "$tmpdir"
        continue
    fi

    echo "==> Conversion en CHD : $name.chd"
    if chdman createcd -i "$cue" -o "${name}.chd"; then
        echo "OK : ${name}.chd"
        rm -f -- "$f"
    else
        echo "Erreur conversion : $f"
    fi

    rm -rf "$tmpdir"
done
# chmod +x convertchd.sh

Puis il suffit de lancer le script :

# ./convertchd.sh

trier les roms par langues, régions.. (à revérifier/tester)

# apt update && apt upgrade
# apt install git python3 pip python3.13-venv python3-pip
# mkdir /opt/triederoms
# cd /opt/triederoms
# python3 -m venv retool-env
# source retool-env/bin/activate
# python -m pip install --upgrade pip
# pip install alive-progress darkdetect lxml psutil pyside6 strictyaml validators
# git clone https://github.com/unexpectedpanda/retool.git
# cd retool
# source ../retool-env/bin/activate

On organise le trie avec pour exemple :

# nano config/user-config.yaml
---
config version: 2.4.0

# If the -l option is used, only include titles with the following languages.
language order:
- French
- English

region order:
- France
- Europe
- World
- USA
- Japan

video order:
- PAL
- PAL 60Hz
- NTSC
- MPAL
- SECAM

list prefix:

list suffix:

exclude:

include:

filters:

On récupère le ficher .dat correspondant à la plateforme à trier à cette adresse puis on lance le trie :

# python retool.py maplateforme.dat --exclude a B d D m M o P r u v --output .