# Game List Rebuilder Script

```python
#!/usr/bin/env python3
"""
Rebuild/update gamelist.xml for each console folder under a ROMs root,
adding entries only for ROM files not already listed. Pure stdlib, no
network calls, no external services.

Usage:
    python3 rebuild_gamelists.py /path/to/roms/root [--dry-run] [--only sys1,sys2]

Skips known non-console directories automatically. Existing <game>
entries are left untouched; new ones are appended with <path>, <name>,
and a locally-computed <md5>. Everything else (desc/rating/genre/etc.)
is written as an empty tag so EmulationStation renders fine and later
manual/scraper edits slot into the same fields.
"""
import argparse
import hashlib
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

SKIP_DIRS = {
    "bios", "themes", "tools", "videos", "movies", "images", "backup",
    "launchimages", "bgmusic", "ports", "System Volume Information",
    "downloaded_images", "media", "manuals",
}

SKIP_FILES = {"gamelist.xml", "gamelist.xml.old", "gamelist.db", "miximage.xml"}

# Extensions that are never ROMs (companion/art/text files sitting in a system dir)
SKIP_EXTS = {".xml", ".db", ".txt", ".nfo", ".jpg", ".jpeg", ".png", ".srm",
             ".state", ".sav", ".cfg", ".ini", ".log"}

REGION_TAG_RE = re.compile(r"\s*[\(\[][^\)\]]*[\)\]]")


def clean_name(stem: str) -> str:
    """Best-effort display name: strip region/version tags, collapse whitespace."""
    name = REGION_TAG_RE.sub("", stem)
    name = re.sub(r"\s+", " ", name).strip()
    return name or stem


def md5_of(path: Path) -> str:
    h = hashlib.md5()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def load_existing_paths(gamelist_path: Path):
    if not gamelist_path.exists():
        return None, set()
    try:
        tree = ET.parse(gamelist_path)
    except ET.ParseError as e:
        print(f"  ! could not parse existing {gamelist_path}: {e} (skipping this system)")
        return None, None
    root = tree.getroot()
    existing = set()
    for game in root.findall("game"):
        p = game.findtext("path")
        if p:
            existing.add(p.strip())
    return tree, existing


def build_game_element(rel_path: str, abs_path: Path) -> ET.Element:
    game = ET.Element("game")
    ET.SubElement(game, "path").text = rel_path
    ET.SubElement(game, "name").text = clean_name(abs_path.stem)
    ET.SubElement(game, "desc")
    ET.SubElement(game, "rating").text = "0"
    ET.SubElement(game, "releasedate")
    ET.SubElement(game, "developer")
    ET.SubElement(game, "publisher")
    ET.SubElement(game, "genre")
    ET.SubElement(game, "players")
    ET.SubElement(game, "image")
    ET.SubElement(game, "playcount").text = "0"
    ET.SubElement(game, "lastplayed")
    ET.SubElement(game, "md5").text = md5_of(abs_path)
    return game


def indent(elem, level=0):
    i = "\n" + level * "  "
    if len(elem):
        if not elem.text or not elem.text.strip():
            elem.text = i + "  "
        for child in elem:
            indent(child, level + 1)
            if not child.tail or not child.tail.strip():
                child.tail = i + "  "
        if not elem[-1].tail or not elem[-1].tail.strip():
            elem[-1].tail = i
    else:
        if level and (not elem.tail or not elem.tail.strip()):
            elem.tail = i


def process_system(sys_dir: Path, dry_run: bool):
    gamelist_path = sys_dir / "gamelist.xml"
    tree, existing = load_existing_paths(gamelist_path)
    if existing is None and gamelist_path.exists():
        return  # parse error, already reported
    if tree is None:
        root = ET.Element("gameList")
        tree = ET.ElementTree(root)
        existing = set()
    else:
        root = tree.getroot()

    added = 0
    for entry in sorted(sys_dir.iterdir()):
        if not entry.is_file():
            continue
        if entry.name in SKIP_FILES or entry.name.endswith(".old"):
            continue
        if entry.suffix.lower() in SKIP_EXTS:
            continue
        rel_path = f"./{entry.name}"
        if rel_path in existing:
            continue
        root.append(build_game_element(rel_path, entry))
        added += 1

    if added == 0:
        return

    print(f"  + {added} new entr{'y' if added == 1 else 'ies'}")
    if dry_run:
        return
    indent(root)
    tree.write(gamelist_path, encoding="utf-8", xml_declaration=True)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("roms_root")
    ap.add_argument("--dry-run", action="store_true", help="report what would change, write nothing")
    ap.add_argument("--only", help="comma-separated list of system folder names to process")
    args = ap.parse_args()

    root = Path(args.roms_root).expanduser()
    if not root.is_dir():
        sys.exit(f"not a directory: {root}")

    only = set(s.strip() for s in args.only.split(",")) if args.only else None

    for entry in sorted(root.iterdir()):
        if not entry.is_dir():
            continue
        if entry.name in SKIP_DIRS:
            continue
        if only and entry.name not in only:
            continue
        print(f"== {entry.name} ==")
        process_system(entry, args.dry_run)


if __name__ == "__main__":
    main()
```
