Last active
December 23, 2025 13:15
-
-
Save turnercore/5eeb0f4ab437f7c77ae1c8bc61fc4122 to your computer and use it in GitHub Desktop.
A big ol' script to help import launchbox managed roms to batocera. Mainly used for myeslf and being updated for personal use.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import argparse | |
| import copy | |
| import csv | |
| import logging | |
| import re | |
| import shutil | |
| import zipfile | |
| from datetime import datetime | |
| from pathlib import Path | |
| import xml.etree.ElementTree as ET | |
| from xml.dom import minidom | |
| # ------------------------- | |
| # CONFIG DEFAULTS (override at runtime via CLI) | |
| # Most commonly changed options are at the top. | |
| # ------------------------- | |
| # Paths | |
| LAUNCHBOX_ROOT = Path(r"T:\LaunchBox") # folder containing Images/, Videos/, Data/ | |
| LAUNCHBOX_GAMES_ROOT = Path(r"T:\LaunchBox\Games") | |
| LAUNCHBOX_BIOS_DIRS = [ | |
| LAUNCHBOX_ROOT / "Emulators" / "RetroArch" / "system", | |
| ] | |
| BATOCERA_ROOT = Path(r"E:\batocera") # folder containing roms/ | |
| # If your destination is directly the SHARE root, set BATOCERA_ROOT to that and ensure roms/ exists under it. | |
| # Common toggles | |
| EXPORT_VIDEOS = True | |
| EXPORT_SYSTEM_MUSIC = True | |
| EXPORT_GAME_MUSIC = True | |
| EXPORT_BEZELS = True # off by default; enable with --copy-bezels | |
| COPY_ROMS = True | |
| COPY_BIOS = True | |
| PRUNE_MISSING = False # when True (or --prune-missing), delete ROM/media not present in LaunchBox anymore | |
| RESET_IMAGES = False # when True (or --reset-images), clear system images before importing | |
| NORMALIZE_ASSETS = True # standardize asset filenames referenced by gamelist | |
| VERIFY_GAMELIST = True # verify gamelist and media integrity after build | |
| WRITE_LOG_FILE = True | |
| BACKUP_GAMELIST = True | |
| # Overwrite controls | |
| OVERWRITE_MEDIA = False # keep False for "safe to re-run" | |
| OVERWRITE_FIELDS = False # if True, overwrite existing gamelist fields | |
| OVERWRITE_ROMS = False | |
| OVERWRITE_BIOS = False | |
| FLATTEN_BIOS_TO_ROOT = True # also drop a copy into batocera/bios root (helps PSX/others that expect root-level BIOS) | |
| SKIP_EXISTING_ROMS = True # if True, do not touch ROMs already present in Batocera | |
| # Misc behavior | |
| TRASH_DIRNAME = "_trash" # under BATOCERA_ROOT; we move pruned files here instead of deleting | |
| DRY_RUN = False | |
| DEFAULT_LOG_DIRNAME = "_logs" | |
| BACKUP_DIRNAME = "_backups" | |
| LOGGER = logging.getLogger("launchbox_scraper") | |
| # Platform filtering | |
| EXCLUDE_PLATFORMS = { | |
| # Sony heavy | |
| "Sony Playstation 2","Sony Playstation 3","Sony Playstation 4","Sony Playstation 5","Sony Playstation Vita", | |
| # Nintendo heavy | |
| "Nintendo GameCube","Nintendo Wii","Nintendo Wii U","Nintendo Switch","Nintendo Switch 2","Switch Updates and DLC", | |
| "Nintendo 3DS","Nintendo DS", | |
| # Microsoft heavy | |
| "Microsoft Xbox","Microsoft Xbox 360","Microsoft Xbox One","Microsoft Xbox Series X_S", | |
| # PC / misc | |
| "Windows","Windows 3.X","Web Browser","Linux","Android","Apple iOS","Apple Mac OS", | |
| # Engines/apps | |
| "MUGEN","OpenBOR","Ouya","GameWave","Game Wave Family Entertainment System", | |
| } | |
| ROM_SKIP_EXT = { | |
| ".xml",".txt",".nfo",".pdf",".csv", | |
| ".jpg",".jpeg",".png",".gif",".bmp",".webp", | |
| ".mp4",".mkv",".avi",".mov",".wmv", | |
| ".mp3",".wav",".flac",".ogg",".m4a", | |
| ".lnk",".url",".db",".ini", | |
| } | |
| # Systems that should have ROMs extracted from .zip archives | |
| SYSTEMS_EXTRACT_ZIPS = {"n64", "n64dd"} | |
| # Safety / defaults are above | |
| # LaunchBox platform name -> Batocera system shortname (copied from your PowerShell map) | |
| PLATFORMS = { | |
| # Nintendo | |
| "Nintendo Entertainment System": "nes", | |
| "Super Nintendo Entertainment System": "snes", | |
| "Nintendo 64": "n64", | |
| "Nintendo 64DD": "n64dd", | |
| "Nintendo Famicom Disk System": "fds", | |
| "Nintendo Game Boy": "gb", | |
| "Nintendo Game Boy Color": "gbc", | |
| "Nintendo Game Boy Advance": "gba", | |
| "Nintendo Virtual Boy": "virtualboy", | |
| "Nintendo Game & Watch": "gameandwatch", | |
| "Nintendo Satellaview": "satellaview", | |
| "Nintendo Pokemon Mini": "pokemini", | |
| # Sega | |
| "Sega Master System": "mastersystem", | |
| "Sega Genesis": "megadrive", | |
| "Sega Game Gear": "gamegear", | |
| "Sega SG-1000": "sg1000", | |
| "Sega SC-3000": "sc3000", | |
| "Sega 32X": "sega32x", | |
| "Sega CD": "segacd", | |
| "Sega Saturn": "saturn", | |
| "Sega Dreamcast": "dreamcast", | |
| "Sega Dreamcast VMU": "vmu", | |
| "Sega Pico": "pico", | |
| # Sony | |
| "Sony Playstation": "psx", | |
| "Sony PSP": "psp", | |
| "Sony PSP Minis": "pspminis", | |
| "Sony PocketStation": "pocketstation", | |
| # Arcade | |
| "Arcade": "mame", | |
| "ZiNc": "zinc", | |
| # Atari / early consoles | |
| "Atari 2600": "atari2600", | |
| "Atari 5200": "atari5200", | |
| "Atari 7800": "atari7800", | |
| "Atari Lynx": "lynx", | |
| "Atari 800": "atari800", | |
| "Atari ST": "atarist", | |
| "Atari Jaguar": "jaguar", | |
| "Atari Jaguar CD": "jaguarcd", | |
| "Atari XEGS": "xegs", | |
| "Magnavox Odyssey": "odyssey", | |
| "Magnavox Odyssey 2": "odyssey2", | |
| "Fairchild Channel F": "channelf", | |
| "Bally Astrocade": "astrocade", | |
| "Mattel Intellivision": "intellivision", | |
| "ColecoVision": "colecovision", | |
| # NEC / TG / PC lines | |
| "NEC TurboGrafx-16": "pcengine", | |
| "PC Engine SuperGrafx": "supergrafx", | |
| "NEC TurboGrafx-CD": "pcenginecd", | |
| "NEC PC-FX": "pcfx", | |
| "NEC PC-8801": "pc88", | |
| "NEC PC-9801": "pc98", | |
| # SNK handheld/CD | |
| "SNK Neo Geo Pocket": "ngp", | |
| "SNK Neo Geo Pocket Color": "ngpc", | |
| "SNK Neo Geo CD": "neogeocd", | |
| # Fantasy / misc | |
| "PICO-8": "pico8", | |
| "WASM-4": "wasm4", | |
| "Uzebox": "uzebox", | |
| "Arduboy": "arduboy", | |
| # Home computers | |
| "Commodore 64": "c64", | |
| "Commodore 128": "c128", | |
| "Commodore PET": "pet", | |
| "Commodore VIC-20": "c20", | |
| "Commodore Plus 4": "cplus4", | |
| "Commodore Amiga": "amiga", | |
| "Commodore Amiga CD32": "amigacd32", | |
| "Commodore CDTV": "amigacdtv", | |
| "Microsoft MSX": "msx1", | |
| "Microsoft MSX2": "msx2", | |
| "Microsoft MSX2+": "msx2+", | |
| "Sinclair ZX Spectrum": "zxspectrum", | |
| "Sinclair ZX-81": "zx81", | |
| "Amstrad CPC": "amstradcpc", | |
| "Amstrad GX4000": "gx4000", | |
| "Apple II": "apple2", | |
| "Apple IIGS": "apple2gs", | |
| "BBC Microcomputer System": "bbc", | |
| "Texas Instruments TI 99_4A": "ti99", | |
| "Acorn Electron": "electron", | |
| "Acorn Atom": "atom", | |
| "Acorn Archimedes": "archimedes", | |
| "Oric Atmos": "oricatmos", | |
| "Sharp X1": "x1", | |
| "Sharp X68000": "x68000", | |
| "Fujitsu FM-7": "fm7", | |
| "Fujitsu FM Towns Marty": "fmtowns", | |
| "SAM CoupAc": "samcoupe", | |
| # DOS | |
| "MS-DOS": "dos", | |
| # Handheld oddballs | |
| "WonderSwan": "wswan", | |
| "WonderSwan Color": "wswanc", | |
| "Watara Supervision": "supervision", | |
| "GCE Vectrex": "vectrex", | |
| "Mega Duck": "megaduck", | |
| "Nokia N-Gage": "ngage", | |
| "Tiger Game.com": "gamecom", | |
| # CD / misc | |
| "3DO Interactive Multiplayer": "3do", | |
| "Philips CD-i": "cdi", | |
| "RCA Studio II": "studioii", | |
| "VTech V.Smile": "vsmile", | |
| "VTech Socrates": "socrates", | |
| } | |
| # Media preferences (LaunchBox subfolders) | |
| IMAGE_PREF_ORDER = [ | |
| # Region-first | |
| "Box - Front/North America", | |
| "Box - Front/USA", | |
| "Box - Front/United States", | |
| "Box - Front/International", | |
| "Box - Front/Europe", | |
| "Box - Front/North America, Europe", | |
| "Box - Front/Japan, North America", | |
| "Box - Front/Japan", | |
| "Box - Front/World", | |
| "Box - Front/France", | |
| "Box - Front/Germany", | |
| # Root-level box art (common) | |
| "Box - Front", | |
| # Only front box art for now; Clear Logo last-resort. | |
| "Clear Logo", | |
| ] | |
| MARQUEE_PREF_ORDER = [ | |
| "Arcade - Marquee", | |
| "Marquee", | |
| "Clear Logo", | |
| ] | |
| BOXBACK_PREF_ORDER = [ | |
| "Box - Back/North America", | |
| "Box - Back/USA", | |
| "Box - Back/United States", | |
| "Box - Back/International", | |
| "Box - Back/Europe", | |
| "Box - Back/World", | |
| "Box - Back", | |
| "Box - Rear", | |
| ] | |
| VIDEO_PREF_DIRS = [ | |
| "", | |
| "Video - Snap", | |
| "Video - Gameplay", | |
| "Video - Theme", | |
| "Theme Videos", | |
| "Video", | |
| ] | |
| BEZEL_PREF_ORDER = [ | |
| "Bezel - Gameplay", | |
| "Bezel", | |
| "Bezel - Arcade", | |
| "Bezels", | |
| "Arcade - Bezel", | |
| ] | |
| MUSIC_PREF_DIRS = [ | |
| "", | |
| "Music", | |
| "Theme Music", | |
| "Background Music", | |
| "Soundtrack", | |
| ] | |
| # ------------------------- | |
| # Helpers | |
| # ------------------------- | |
| INVALID_FILENAME_CHARS = r'[:\'/*?"<>|]' | |
| def sanitize_filename(s: str) -> str: | |
| return re.sub(INVALID_FILENAME_CHARS, "_", s) | |
| def norm(s: str) -> str: | |
| s = s.strip().lower() | |
| s = re.sub(r"\s+", " ", s) | |
| return s | |
| def norm_name_for_match(name: str) -> str: | |
| return norm(sanitize_filename(name)) | |
| def norm_gamelist_path(path_text: str) -> str: | |
| return path_text.replace("\\", "/").strip().lower() | |
| def log_info(msg: str): | |
| LOGGER.info(msg) | |
| def log_warn(msg: str): | |
| LOGGER.warning(msg) | |
| def setup_logging(log_dir: Path | None, *, dry_run: bool) -> Path | None: | |
| if LOGGER.handlers: | |
| return None | |
| LOGGER.setLevel(logging.INFO) | |
| formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") | |
| console_handler = logging.StreamHandler() | |
| console_handler.setFormatter(formatter) | |
| LOGGER.addHandler(console_handler) | |
| if not log_dir or not WRITE_LOG_FILE: | |
| return None | |
| if not dry_run: | |
| ensure_dir(log_dir, dry_run=False) | |
| log_path = log_dir / f"launchbox_scraper_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" | |
| file_handler = logging.FileHandler(log_path, encoding="utf-8") | |
| file_handler.setFormatter(formatter) | |
| LOGGER.addHandler(file_handler) | |
| return log_path | |
| def ensure_dir(p: Path, dry_run: bool): | |
| if dry_run: | |
| return | |
| p.mkdir(parents=True, exist_ok=True) | |
| def safe_copy(src: Path, dst: Path, *, overwrite: bool, dry_run: bool): | |
| if dst.exists() and not overwrite: | |
| return False | |
| if dry_run: | |
| log_info(f"[DRY] copy {src} -> {dst}") | |
| return True | |
| ensure_dir(dst.parent, dry_run) | |
| shutil.copy2(src, dst) | |
| return True | |
| def safe_delete(p: Path, *, dry_run: bool) -> bool: | |
| if dry_run: | |
| log_info(f"[DRY] delete {p}") | |
| return True | |
| if p.exists(): | |
| p.unlink() | |
| return True | |
| return False | |
| def move_to_trash(p: Path, trash_root: Path, base_root: Path, *, dry_run: bool) -> Path: | |
| """ | |
| Move a file into trash, preserving relative path from base_root. | |
| Returns the destination path. | |
| """ | |
| rel = p.relative_to(base_root) | |
| dest = trash_root / rel | |
| # Avoid collision if already in trash; delete source to complete prune | |
| if dest.exists(): | |
| if not dry_run and p.exists(): | |
| p.unlink() | |
| return dest | |
| if dry_run: | |
| log_info(f"[DRY] move {p} -> {dest}") | |
| return dest | |
| ensure_dir(dest.parent, dry_run) | |
| shutil.move(str(p), dest) | |
| return dest | |
| def safe_rename(src: Path, dst: Path, *, overwrite: bool, dry_run: bool) -> bool: | |
| if dst.exists() and not overwrite: | |
| return False | |
| if dry_run: | |
| log_info(f"[DRY] rename {src} -> {dst}") | |
| return True | |
| ensure_dir(dst.parent, dry_run) | |
| shutil.move(str(src), str(dst)) | |
| return True | |
| def mark_related_rom_variants(system_root: Path, base_stem: str, kept: set[Path]): | |
| """ | |
| Keep multi-disc siblings (CHD/bin/etc.) and m3u helpers when the base game is matched. | |
| Prevents pruning disc2/3/etc. | |
| """ | |
| # Keep any file with the exact same stem (different extensions like .m3u/.chd/.bin) | |
| for p in system_root.glob(f"{base_stem}.*"): | |
| if p.is_file(): | |
| kept.add(p) | |
| patterns = [ | |
| f"{base_stem} (Disc *).*", | |
| f"{base_stem} (Disk *).*", | |
| f"{base_stem} (Disc*).*", | |
| f"{base_stem} (Disk*).*", | |
| f"{base_stem} (Track *).*", | |
| ] | |
| for pat in patterns: | |
| for p in system_root.glob(pat): | |
| if p.is_file(): | |
| kept.add(p) | |
| # also keep any m3u with same base stem | |
| for p in system_root.glob(f"{base_stem}*.m3u"): | |
| if p.is_file(): | |
| kept.add(p) | |
| def copy_roms_for_platform(src_root: Path, dst_root: Path, *, skip_ext: set[str], overwrite: bool, dry_run: bool, extract_archives: bool = False) -> dict: | |
| """ | |
| Copy missing ROM files from a LaunchBox platform folder to Batocera system folder, | |
| preserving subfolder structure. Does not overwrite unless overwrite=True. | |
| If extract_archives=True, extracts .zip files instead of copying them (for N64, etc). | |
| """ | |
| stats = {"copied": 0, "skipped_exists": 0, "skipped_ext": 0, "missing_source": False, "extracted": 0} | |
| if not src_root.exists(): | |
| stats["missing_source"] = True | |
| return stats | |
| for p in src_root.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| ext = p.suffix.lower() | |
| # Handle zip extraction if enabled | |
| if extract_archives and ext == ".zip": | |
| try: | |
| if dry_run: | |
| log_info(f"[DRY] extract {p} -> {dst_root}") | |
| stats["extracted"] += 1 | |
| continue | |
| # Extract zip contents to destination | |
| with zipfile.ZipFile(p, 'r') as zip_ref: | |
| # Get ROM files from zip (skip non-ROM files) | |
| for member in zip_ref.namelist(): | |
| member_path = Path(member) | |
| # Skip directories and non-ROM files | |
| if member.endswith('/'): | |
| continue | |
| if member_path.suffix.lower() in skip_ext: | |
| continue | |
| # Extract to destination, preserving filename only | |
| dst = dst_root / member_path.name | |
| if dst.exists() and not overwrite: | |
| stats["skipped_exists"] += 1 | |
| continue | |
| ensure_dir(dst.parent, dry_run) | |
| with zip_ref.open(member) as src_file, open(dst, 'wb') as dst_file: | |
| dst_file.write(src_file.read()) | |
| stats["extracted"] += 1 | |
| except (zipfile.BadZipFile, OSError) as e: | |
| log_warn(f"Failed to extract {p}: {e}") | |
| continue | |
| if ext in skip_ext: | |
| stats["skipped_ext"] += 1 | |
| continue | |
| rel = p.relative_to(src_root) | |
| dst = dst_root / rel | |
| if dst.exists() and not overwrite: | |
| stats["skipped_exists"] += 1 | |
| continue | |
| if safe_copy(p, dst, overwrite=overwrite, dry_run=dry_run): | |
| stats["copied"] += 1 | |
| return stats | |
| def cleanup_extracted_archives(system_root: Path, trash_root: Path, *, dry_run: bool) -> int: | |
| """ | |
| Move any .zip files under the system root to trash after extraction. | |
| """ | |
| moved = 0 | |
| skip_folders = {"images", "videos", "music", "manuals"} | |
| for p in system_root.rglob("*.zip"): | |
| if not p.is_file(): | |
| continue | |
| if any(part.lower() in skip_folders for part in p.relative_to(system_root).parts[:-1]): | |
| continue | |
| move_to_trash(p, trash_root, system_root, dry_run=dry_run) | |
| moved += 1 | |
| return moved | |
| def copy_bios_dirs(src_dirs: list[Path], dest_root: Path, *, overwrite: bool, flatten_to_root: bool, dry_run: bool) -> dict: | |
| stats = {"copied": 0, "skipped_exists": 0, "missing_sources": [], "per_dir": []} | |
| ensure_dir(dest_root, dry_run) | |
| for src in src_dirs: | |
| per_dir = {"src": src, "copied": 0, "skipped_exists": 0, "missing": False} | |
| if not src.exists(): | |
| stats["missing_sources"].append(src) | |
| per_dir["missing"] = True | |
| stats["per_dir"].append(per_dir) | |
| continue | |
| for p in src.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| rel = p.relative_to(src) | |
| dst = dest_root / rel | |
| if dst.exists() and not overwrite: | |
| stats["skipped_exists"] += 1 | |
| per_dir["skipped_exists"] += 1 | |
| continue | |
| if safe_copy(p, dst, overwrite=overwrite, dry_run=dry_run): | |
| stats["copied"] += 1 | |
| per_dir["copied"] += 1 | |
| if flatten_to_root: | |
| root_dst = dest_root / p.name | |
| if root_dst.exists() and not overwrite: | |
| stats["skipped_exists"] += 1 | |
| per_dir["skipped_exists"] += 1 | |
| else: | |
| if safe_copy(p, root_dst, overwrite=overwrite, dry_run=dry_run): | |
| stats["copied"] += 1 | |
| per_dir["copied"] += 1 | |
| stats["per_dir"].append(per_dir) | |
| return stats | |
| def prettify_xml(elem: ET.Element) -> str: | |
| raw = ET.tostring(elem, encoding="utf-8") | |
| pretty = minidom.parseString(raw).toprettyxml(indent=" ") | |
| # remove xml declaration | |
| lines = pretty.splitlines() | |
| if lines and lines[0].startswith("<?xml"): | |
| pretty = "\n".join(lines[1:]) | |
| return pretty | |
| def write_gamelist_atomic(root: ET.Element, gamelist_path: Path, backup_dir: Path | None, *, backup_enabled: bool, dry_run: bool): | |
| xml_text = prettify_xml(root) | |
| if dry_run: | |
| log_info(f"[DRY] Would write gamelist: {gamelist_path}") | |
| return | |
| if backup_dir and backup_enabled and gamelist_path.exists(): | |
| ensure_dir(backup_dir, dry_run=False) | |
| stamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| backup_path = backup_dir / f"gamelist_{stamp}.xml" | |
| shutil.copy2(gamelist_path, backup_path) | |
| log_info(f"Backup saved: {backup_path}") | |
| tmp_path = gamelist_path.with_suffix(".xml.tmp") | |
| tmp_path.write_text(xml_text, encoding="utf-8") | |
| try: | |
| ET.parse(tmp_path) | |
| except ET.ParseError as e: | |
| log_warn(f"Refusing to replace gamelist; temp parse failed: {e}") | |
| return | |
| tmp_path.replace(gamelist_path) | |
| def load_existing_gamelist(gamelist_path: Path) -> dict[str, ET.Element]: | |
| if not gamelist_path.exists(): | |
| return {} | |
| try: | |
| tree = ET.parse(gamelist_path) | |
| except ET.ParseError: | |
| return {} | |
| root = tree.getroot() | |
| existing = {} | |
| for game in root.findall("game"): | |
| path_text = (game.findtext("path") or "").strip() | |
| if not path_text: | |
| continue | |
| existing[norm_gamelist_path(path_text)] = game | |
| return existing | |
| def set_child_text(game_node: ET.Element, tag: str, text: str | None, *, overwrite: bool): | |
| if not text: | |
| return | |
| existing = game_node.find(tag) | |
| if existing is None: | |
| ET.SubElement(game_node, tag).text = text | |
| return | |
| if overwrite or not (existing.text or "").strip(): | |
| existing.text = text | |
| def pick_existing_media_file(folder: Path, base_stem: str, suffixes: list[str], exts: set[str], pref_exts: list[str]) -> Path | None: | |
| if not folder.exists(): | |
| return None | |
| for suffix in suffixes: | |
| candidates = [] | |
| for p in folder.glob(f"{base_stem}{suffix}.*"): | |
| if not p.is_file(): | |
| continue | |
| if p.suffix.lower() in exts: | |
| candidates.append(p) | |
| if candidates: | |
| candidates.sort(key=lambda p: (pref_exts.index(p.suffix.lower()) if p.suffix.lower() in pref_exts else len(pref_exts), p.name.lower())) | |
| return candidates[0] | |
| return None | |
| def gamelist_media_missing(game_node: ET.Element, tag: str, system_root: Path) -> bool: | |
| """ | |
| Returns True if the tag exists but its referenced file does not. | |
| """ | |
| node = game_node.find(tag) | |
| if node is None: | |
| return False | |
| text = (node.text or "").strip() | |
| if not text: | |
| return False | |
| rel_path = text.lstrip("./") | |
| return not (system_root / rel_path).exists() | |
| def cleanup_missing_media_and_zero_rating(game_node: ET.Element, system_root: Path): | |
| """ | |
| Remove media tags when the referenced file is missing, and drop rating if zero. | |
| """ | |
| media_tags = ["image", "thumbnail", "marquee", "bezel", "boxback", "video", "manual", "music"] | |
| for tag in media_tags: | |
| if gamelist_media_missing(game_node, tag, system_root): | |
| node = game_node.find(tag) | |
| if node is not None: | |
| game_node.remove(node) | |
| rating_node = game_node.find("rating") | |
| if rating_node is not None: | |
| txt = (rating_node.text or "").strip() | |
| try: | |
| if not txt or float(txt) <= 0: | |
| game_node.remove(rating_node) | |
| except ValueError: | |
| pass | |
| def normalize_game_assets( | |
| rom_stem: str, | |
| out_images: Path, | |
| out_videos: Path, | |
| out_manuals: Path, | |
| out_music: Path, | |
| *, | |
| overwrite: bool, | |
| dry_run: bool, | |
| ): | |
| image_exts = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} | |
| video_exts = {".mp4", ".mkv", ".avi", ".mov", ".wmv"} | |
| audio_exts = {".mp3", ".ogg", ".wav", ".flac", ".m4a", ".opus", ".mod", ".xm", ".s3m", ".it"} | |
| manual_exts = {".pdf", ".cbz", ".cbr"} | |
| if out_images.exists(): | |
| base_img = pick_existing_media_file(out_images, rom_stem, [""], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if base_img: | |
| normalized = out_images / f"{rom_stem}-image{base_img.suffix.lower()}" | |
| if normalized != base_img: | |
| safe_rename(base_img, normalized, overwrite=overwrite, dry_run=dry_run) | |
| base_thumb = pick_existing_media_file(out_images, rom_stem, ["-thumb"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if not base_thumb: | |
| normalized = pick_existing_media_file(out_images, rom_stem, ["-image"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if normalized: | |
| thumb = out_images / f"{rom_stem}-thumb{normalized.suffix.lower()}" | |
| safe_copy(normalized, thumb, overwrite=overwrite, dry_run=dry_run) | |
| if out_videos.exists(): | |
| base_vid = pick_existing_media_file(out_videos, rom_stem, [""], video_exts, [".mp4", ".mkv", ".avi", ".mov", ".wmv"]) | |
| if base_vid: | |
| normalized = out_videos / f"{rom_stem}-video{base_vid.suffix.lower()}" | |
| if normalized != base_vid: | |
| safe_rename(base_vid, normalized, overwrite=overwrite, dry_run=dry_run) | |
| if out_manuals.exists(): | |
| base_manual = pick_existing_media_file(out_manuals, rom_stem, [""], manual_exts, [".pdf", ".cbz", ".cbr"]) | |
| if base_manual: | |
| normalized = out_manuals / f"{rom_stem}-manual{base_manual.suffix.lower()}" | |
| if normalized != base_manual: | |
| safe_rename(base_manual, normalized, overwrite=overwrite, dry_run=dry_run) | |
| if out_music.exists(): | |
| base_music = pick_existing_media_file(out_music, rom_stem, [""], audio_exts, [".mp3", ".ogg", ".wav", ".flac", ".m4a", ".opus", ".mod", ".xm", ".s3m", ".it"]) | |
| if base_music: | |
| normalized = out_music / f"{rom_stem}-music{base_music.suffix.lower()}" | |
| if normalized != base_music: | |
| safe_rename(base_music, normalized, overwrite=overwrite, dry_run=dry_run) | |
| def verify_gamelist_entries(root: ET.Element, system_root: Path, system: str) -> list[dict]: | |
| rows: list[dict] = [] | |
| seen_paths: set[str] = set() | |
| media_tags = ["image", "thumbnail", "marquee", "bezel", "boxback", "video", "manual", "music"] | |
| for game in root.findall("game"): | |
| name = (game.findtext("name") or "").strip() | |
| path_text = (game.findtext("path") or "").strip() | |
| norm_path = norm_gamelist_path(path_text) if path_text else "" | |
| if not path_text: | |
| rows.append({"system": system, "name": name, "issue": "missing_path", "tag": "", "path": ""}) | |
| else: | |
| if norm_path in seen_paths: | |
| rows.append({"system": system, "name": name, "issue": "duplicate_path", "tag": "path", "path": path_text}) | |
| seen_paths.add(norm_path) | |
| rel = path_text.lstrip("./") | |
| if not (system_root / rel).exists(): | |
| rows.append({"system": system, "name": name, "issue": "missing_rom", "tag": "path", "path": path_text}) | |
| for tag in media_tags: | |
| node = game.find(tag) | |
| text = (node.text or "").strip() if node is not None else "" | |
| if not text: | |
| continue | |
| rel_path = text.lstrip("./") | |
| if not (system_root / rel_path).exists(): | |
| rows.append({"system": system, "name": name, "issue": "missing_media", "tag": tag, "path": text}) | |
| rating = (game.findtext("rating") or "").strip() | |
| if rating: | |
| try: | |
| if float(rating) <= 0: | |
| rows.append({"system": system, "name": name, "issue": "zero_rating", "tag": "rating", "path": rating}) | |
| except ValueError: | |
| rows.append({"system": system, "name": name, "issue": "invalid_rating", "tag": "rating", "path": rating}) | |
| return rows | |
| def load_launchbox_platform_games(platform_xml: Path): | |
| tree = ET.parse(platform_xml) | |
| root = tree.getroot() | |
| games = [] | |
| for g in root.findall(".//Game"): | |
| title = (g.findtext("Title") or "").strip() | |
| app_path = (g.findtext("ApplicationPath") or "").strip() | |
| if not title or not app_path: | |
| continue | |
| games.append((g, title, app_path)) | |
| return games | |
| def build_batocera_rom_index(system_root: Path): | |
| """ | |
| Index all rom files under system_root (recursive) by normalized stem. | |
| Returns dict: norm(stem) -> [Path] | |
| Excludes media subfolders (images/, videos/, music/) from indexing. | |
| """ | |
| idx = {} | |
| skip_ext = {".xml", ".txt", ".nfo", ".pdf", ".csv", | |
| ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", | |
| ".mp4", ".mkv", ".avi", ".mov", ".wmv", | |
| ".mp3", ".wav", ".flac", ".ogg", ".m4a", | |
| ".lnk", ".url", ".db", ".ini", ".cfg"} | |
| # Folders to skip when indexing ROMs | |
| skip_folders = {"images", "videos", "music"} | |
| for p in system_root.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| # Skip if in a media folder | |
| if any(part.lower() in skip_folders for part in p.relative_to(system_root).parts[:-1]): | |
| continue | |
| if p.name.lower() == "gamelist.xml": | |
| continue | |
| # PICO-8 stores roms as .p8.png carts; allow them under pico8 system | |
| if system_root.name.lower() == "pico8" and p.name.lower().endswith(".p8.png"): | |
| pass # Allow .p8.png files for PICO-8 | |
| elif p.suffix.lower() in skip_ext: | |
| continue | |
| idx.setdefault(norm(p.stem), []).append(p) | |
| return idx | |
| def pick_launchbox_asset(lb_images_platform_dir: Path, names: list[str], preference_order: list[str], recurse: bool = False) -> Path | None: | |
| """ | |
| Finds the "best" asset for a game title by checking a preference list of subfolders | |
| and matching filename patterns LaunchBox typically uses: | |
| Title-01.png / Title-02.jpg / Title.png | |
| """ | |
| targets = [norm_name_for_match(n) for n in names if n] | |
| if not targets: | |
| return None | |
| def seq(p: Path): | |
| m = re.search(r"-(\d+)$", p.stem) | |
| return int(m.group(1)) if m else 9999 | |
| for sub in preference_order: | |
| parts = [p for p in sub.split("/") if p] if sub else [] | |
| folder = lb_images_platform_dir.joinpath(*parts) if parts else lb_images_platform_dir | |
| if not folder.exists() or not folder.is_dir(): | |
| continue | |
| candidates = [] | |
| iterator = folder.rglob("*") if recurse else folder.iterdir() | |
| for p in iterator: | |
| if not p.is_file(): | |
| continue | |
| stem = re.sub(r"-\d+$", "", p.stem) # strip -01 | |
| nstem = norm(stem) | |
| if any(nstem == t for t in targets): | |
| candidates.append(p) | |
| if not candidates: | |
| # looser contains match | |
| iterator = folder.rglob("*") if recurse else folder.iterdir() | |
| for p in iterator: | |
| if not p.is_file(): | |
| continue | |
| stem = re.sub(r"-\d+$", "", p.stem) | |
| nstem = norm(stem) | |
| if any(t and t in nstem for t in targets): | |
| candidates.append(p) | |
| if candidates: | |
| # prefer lowest -01, then png, then name | |
| candidates.sort(key=lambda p: (seq(p), p.suffix.lower() != ".png", p.name.lower())) | |
| return candidates[0] | |
| return None | |
| def pick_launchbox_video(lb_videos_platform_dir: Path, names: list[str]) -> Path | None: | |
| if not lb_videos_platform_dir.exists(): | |
| return None | |
| targets = [norm_name_for_match(n) for n in names if n] | |
| if not targets: | |
| return None | |
| def ext_rank(path: Path) -> int: | |
| order = [".mp4", ".mkv", ".avi", ".mov", ".wmv"] | |
| ext = path.suffix.lower() | |
| return order.index(ext) if ext in order else len(order) | |
| candidates: list[tuple[int, Path]] = [] | |
| for idx, sub in enumerate(VIDEO_PREF_DIRS): | |
| folder = lb_videos_platform_dir if not sub else lb_videos_platform_dir / sub | |
| if not folder.exists() or not folder.is_dir(): | |
| continue | |
| for p in folder.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| nstem = norm(re.sub(r"-\d+$", "", p.stem)) | |
| if any(nstem == t for t in targets) or any(t and t in nstem for t in targets): | |
| candidates.append((idx, p)) | |
| if not candidates: | |
| return None | |
| candidates.sort(key=lambda item: (item[0], ext_rank(item[1]), item[1].name.lower())) | |
| return candidates[0][1] | |
| def pick_launchbox_music(lb_music_platform_dir: Path, names: list[str]) -> Path | None: | |
| if not lb_music_platform_dir.exists(): | |
| return None | |
| targets = [norm_name_for_match(n) for n in names if n] | |
| if not targets: | |
| return None | |
| def ext_rank(path: Path) -> int: | |
| order = [".mp3", ".ogg", ".wav", ".flac", ".m4a", ".opus", ".mod", ".xm", ".s3m", ".it"] | |
| ext = path.suffix.lower() | |
| return order.index(ext) if ext in order else len(order) | |
| candidates: list[tuple[int, Path]] = [] | |
| for idx, sub in enumerate(MUSIC_PREF_DIRS): | |
| folder = lb_music_platform_dir if not sub else lb_music_platform_dir / sub | |
| if not folder.exists() or not folder.is_dir(): | |
| continue | |
| for p in folder.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| nstem = norm(re.sub(r"-\d+$", "", p.stem)) | |
| if any(nstem == t for t in targets) or any(t and t in nstem for t in targets): | |
| candidates.append((idx, p)) | |
| if not candidates: | |
| return None | |
| candidates.sort(key=lambda item: (item[0], ext_rank(item[1]), item[1].name.lower())) | |
| return candidates[0][1] | |
| def pick_launchbox_manual(lb_manuals_platform_dir: Path, names: list[str]) -> Path | None: | |
| if not lb_manuals_platform_dir.exists(): | |
| return None | |
| targets = [norm_name_for_match(n) for n in names if n] | |
| if not targets: | |
| return None | |
| exts = {".pdf", ".cbz", ".cbr"} | |
| candidates = [] | |
| for p in lb_manuals_platform_dir.rglob("*"): | |
| if not p.is_file() or p.suffix.lower() not in exts: | |
| continue | |
| stem = re.sub(r"-\d+$", "", p.stem) | |
| nstem = norm(stem) | |
| if any(nstem == t for t in targets) or any(t and t in nstem for t in targets): | |
| candidates.append(p) | |
| if not candidates: | |
| return None | |
| candidates.sort(key=lambda p: (p.suffix.lower() != ".pdf", p.name.lower())) | |
| return candidates[0] | |
| def extract_metadata(game_elem: ET.Element) -> dict: | |
| md = {} | |
| # description | |
| notes = (game_elem.findtext("Notes") or "").strip() | |
| if notes: | |
| md["desc"] = notes | |
| # developer/publisher/genre | |
| dev = (game_elem.findtext("Developer") or "").strip() | |
| pub = (game_elem.findtext("Publisher") or "").strip() | |
| gen = (game_elem.findtext("Genre") or "").strip() | |
| if dev: md["developer"] = dev | |
| if pub: md["publisher"] = pub | |
| if gen: md["genre"] = gen | |
| # releasedate -> YYYYMMDDT000000 | |
| rel = (game_elem.findtext("ReleaseDate") or "").strip() | |
| if rel: | |
| date = rel.split("T")[0].replace("-", "") | |
| if len(date) == 8: | |
| md["releasedate"] = f"{date}T000000" | |
| # rating: LaunchBox StarRating is 0..5. Batocera rating is typically 0..1. | |
| sr = (game_elem.findtext("StarRating") or "").strip() | |
| try: | |
| if sr: | |
| rating = float(sr) / 5 | |
| if rating > 0: | |
| md["rating"] = str(rating) | |
| except ValueError: | |
| pass | |
| # players | |
| mp = (game_elem.findtext("MaxPlayers") or "").strip() | |
| if mp: | |
| md["players"] = mp.lstrip("0") or "1" | |
| # coop / kid-friendly flags | |
| coop = (game_elem.findtext("Cooperative") or "").strip().lower() | |
| if coop in {"yes", "true", "1"}: | |
| md["coop"] = "true" | |
| kid = (game_elem.findtext("EsrbRating") or "").lower() | |
| if "early childhood" in kid or "everyone" in kid: | |
| md["kidgame"] = "true" | |
| # favorites / region / language | |
| fav = (game_elem.findtext("Favorite") or "").strip().lower() | |
| if fav in {"true", "1", "yes"}: | |
| md["favorite"] = "true" | |
| region = (game_elem.findtext("Region") or "").strip() | |
| if region: | |
| md["region"] = region | |
| lang = (game_elem.findtext("Language") or "").strip() | |
| if lang: | |
| md["lang"] = lang | |
| pc = (game_elem.findtext("PlayCount") or "").strip() | |
| if pc.isdigit(): | |
| md["playcount"] = pc | |
| return md | |
| # ------------------------- | |
| # Overlay helpers | |
| # ------------------------- | |
| def copy_system_music(lb_music_platform_dir: Path, dest_music_dir: Path, *, overwrite: bool, dry_run: bool) -> int: | |
| """ | |
| Batocera supports system-level music under /userdata/music/<system>/. | |
| Copy all known audio files from LaunchBox Music/<Platform> (and common subfolders). | |
| """ | |
| if not lb_music_platform_dir.exists(): | |
| return 0 | |
| copied = 0 | |
| audio_exts = {".mp3", ".ogg", ".wav", ".flac", ".m4a", ".mod", ".xm", ".s3m", ".stm", ".far", ".mtm", ".669", ".it", ".opus"} | |
| # Walk preferred subfolders first to loosely preserve ordering | |
| seen = set() | |
| folders = [] | |
| for sub in MUSIC_PREF_DIRS: | |
| parts = [p for p in sub.split("/") if p] if sub else [] | |
| folder = lb_music_platform_dir.joinpath(*parts) if parts else lb_music_platform_dir | |
| if folder.exists() and folder.is_dir(): | |
| folders.append(folder) | |
| seen.add(folder) | |
| # add root recursively for anything else | |
| if lb_music_platform_dir not in seen: | |
| folders.append(lb_music_platform_dir) | |
| for folder in folders: | |
| for p in folder.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| if p.suffix.lower() not in audio_exts: | |
| continue | |
| rel = p.relative_to(lb_music_platform_dir) | |
| dst = dest_music_dir / rel | |
| if safe_copy(p, dst, overwrite=overwrite, dry_run=dry_run): | |
| copied += 1 | |
| return copied | |
| # ------------------------- | |
| # CLI | |
| # ------------------------- | |
| def parse_args(): | |
| parser = argparse.ArgumentParser( | |
| description="Export LaunchBox metadata/media to Batocera gamelist + assets." | |
| ) | |
| parser.add_argument("--launchbox-root", type=Path, default=LAUNCHBOX_ROOT, | |
| help="Path to LaunchBox root (contains Images/, Videos/, Data/)") | |
| parser.add_argument("--batocera-root", type=Path, default=BATOCERA_ROOT, | |
| help="Path to Batocera root (contains roms/)") | |
| parser.add_argument("--platform", action="append", | |
| help="Limit to specific LaunchBox platform name(s); repeatable") | |
| parser.add_argument("--system", action="append", | |
| help="Limit to specific Batocera system shortnames; repeatable") | |
| parser.add_argument("--no-videos", action="store_true", help="Skip exporting videos") | |
| parser.add_argument("--no-system-music", action="store_true", help="Skip exporting system music") | |
| parser.add_argument("--no-game-music", action="store_true", help="Skip exporting per-game music") | |
| parser.add_argument("--copy-bezels", action="store_true", help="Copy bezels into Batocera decorations (thebezelproject)") | |
| parser.add_argument("--overwrite-media", action="store_true", help="Allow overwriting media if it exists") | |
| parser.add_argument("--overwrite-fields", action="store_true", help="Overwrite existing gamelist fields") | |
| parser.add_argument("--no-normalize-assets", action="store_true", help="Disable asset filename normalization") | |
| parser.add_argument("--no-verify", action="store_true", help="Disable verification and reporting") | |
| parser.add_argument("--verify-report", type=Path, help="Write verification report CSV to this path") | |
| parser.add_argument("--log-dir", type=Path, help="Directory for log and verification reports") | |
| parser.add_argument("--no-log-file", action="store_true", help="Disable log file output") | |
| parser.add_argument("--no-backup-gamelist", action="store_true", help="Disable gamelist backups before write") | |
| parser.add_argument("--no-copy-roms", action="store_true", help="Skip copying ROMs from LaunchBox to Batocera") | |
| parser.add_argument("--overwrite-roms", action="store_true", help="Allow overwriting ROMs if they already exist") | |
| parser.add_argument("--skip-existing-roms", action="store_true", | |
| help="Skip gamelist/media updates for ROMs already present in Batocera") | |
| parser.add_argument("--include-excluded-platforms", action="store_true", help="Process platforms listed in EXCLUDE_PLATFORMS") | |
| parser.add_argument("--no-copy-bios", action="store_true", help="Skip copying BIOS files from LaunchBox to Batocera") | |
| parser.add_argument("--overwrite-bios", action="store_true", help="Allow overwriting BIOS files if they already exist") | |
| parser.add_argument("--prune-missing", action="store_true", help="Delete ROMs/media/overlays not present in LaunchBox anymore (dangerous; use with backups)") | |
| parser.add_argument("--reset-images", action="store_true", help="Clear system images folder before importing (moves to trash)") | |
| parser.add_argument("--log-missing-images", action="store_true", help="Print titles with no matched box art (per platform)") | |
| parser.add_argument("--dry-run", action="store_true", help="Do not write/copy, just log actions") | |
| return parser.parse_args() | |
| # ------------------------- | |
| # Main | |
| # ------------------------- | |
| def main(): | |
| args = parse_args() | |
| launchbox_root = args.launchbox_root | |
| batocera_root = args.batocera_root | |
| roms_root = batocera_root / "roms" | |
| trash_root = batocera_root / TRASH_DIRNAME | |
| export_videos = EXPORT_VIDEOS and not args.no_videos | |
| export_system_music = EXPORT_SYSTEM_MUSIC and not args.no_system_music | |
| export_game_music = EXPORT_GAME_MUSIC and not args.no_game_music | |
| export_bezels = EXPORT_BEZELS or args.copy_bezels | |
| overwrite_media = OVERWRITE_MEDIA or args.overwrite_media | |
| overwrite_fields = OVERWRITE_FIELDS or args.overwrite_fields | |
| normalize_assets = NORMALIZE_ASSETS and not args.no_normalize_assets | |
| verify_gamelist = VERIFY_GAMELIST and not args.no_verify | |
| copy_roms = COPY_ROMS and not args.no_copy_roms | |
| overwrite_roms = OVERWRITE_ROMS or args.overwrite_roms | |
| skip_existing_roms = SKIP_EXISTING_ROMS or args.skip_existing_roms | |
| copy_bios = COPY_BIOS and not args.no_copy_bios | |
| overwrite_bios = OVERWRITE_BIOS or args.overwrite_bios | |
| dry_run = DRY_RUN or args.dry_run | |
| prune_missing = PRUNE_MISSING or args.prune_missing | |
| reset_images = RESET_IMAGES or args.reset_images | |
| include_excluded = args.include_excluded_platforms | |
| log_missing_images = args.log_missing_images | |
| backup_gamelist = BACKUP_GAMELIST and not args.no_backup_gamelist | |
| log_to_file = WRITE_LOG_FILE and not args.no_log_file | |
| log_dir = args.log_dir | |
| if log_dir is None: | |
| log_dir = batocera_root / DEFAULT_LOG_DIRNAME | |
| if not log_to_file: | |
| log_dir = None | |
| log_path = setup_logging(log_dir, dry_run=dry_run) | |
| if log_path: | |
| log_info(f"Log file: {log_path}") | |
| run_stamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| verify_report_path = None | |
| verify_rows: list[dict] = [] | |
| if verify_gamelist: | |
| if args.verify_report: | |
| verify_report_path = args.verify_report | |
| else: | |
| report_dir = log_dir if log_dir is not None else (batocera_root / DEFAULT_LOG_DIRNAME) | |
| verify_report_path = report_dir / f"verify_{run_stamp}.csv" | |
| if not roms_root.exists(): | |
| raise SystemExit(f"Batocera roms folder not found: {roms_root}") | |
| bios_copy_stats = None | |
| if copy_bios: | |
| log_info("Copying BIOS from LaunchBox...") | |
| bios_copy_stats = copy_bios_dirs( | |
| LAUNCHBOX_BIOS_DIRS, | |
| batocera_root / "bios", | |
| overwrite=overwrite_bios, | |
| flatten_to_root=FLATTEN_BIOS_TO_ROOT, | |
| dry_run=dry_run, | |
| ) | |
| platform_map = dict(PLATFORMS) | |
| if args.platform: | |
| missing = [p for p in args.platform if p not in platform_map] | |
| if missing: | |
| log_warn(f"platform(s) not mapped, skipping: {', '.join(missing)}") | |
| platform_map = {k: v for k, v in platform_map.items() if k in set(args.platform)} | |
| if args.system: | |
| system_filter = set(args.system) | |
| platform_map = {k: v for k, v in platform_map.items() if v in system_filter} | |
| for lb_platform, system in platform_map.items(): | |
| if lb_platform in EXCLUDE_PLATFORMS and not include_excluded: | |
| log_info(f"\n[{lb_platform}] Skipped (excluded platform)") | |
| continue | |
| platform_xml = launchbox_root / "Data" / "Platforms" / f"{lb_platform}.xml" | |
| if not platform_xml.exists(): | |
| continue | |
| system_root = roms_root / system | |
| if not system_root.exists(): | |
| if copy_roms: | |
| ensure_dir(system_root, dry_run) | |
| else: | |
| log_warn(f"\n[{lb_platform} -> {system}] Missing Batocera system folder: {system_root}") | |
| continue | |
| log_info(f"\n=== {lb_platform} -> {system} ===") | |
| lb_images_platform_dir = launchbox_root / "Images" / lb_platform | |
| lb_videos_platform_dir = launchbox_root / "Videos" / lb_platform | |
| lb_music_platform_dir = launchbox_root / "Music" / lb_platform | |
| lb_manuals_platform_dir = launchbox_root / "Manuals" / lb_platform | |
| out_images = system_root / "images" | |
| out_videos = system_root / "videos" | |
| out_manuals = system_root / "manuals" | |
| out_music = system_root / "music" | |
| if reset_images: | |
| if out_images.exists(): | |
| for f in out_images.rglob("*"): | |
| if f.is_file(): | |
| move_to_trash(f, trash_root / system, system_root, dry_run=dry_run) | |
| ensure_dir(out_images, dry_run) | |
| if export_videos: | |
| ensure_dir(out_videos, dry_run) | |
| if export_game_music: | |
| ensure_dir(out_music, dry_run) | |
| copied_sys_music = 0 | |
| rom_copy_stats = None | |
| bezels_copied = 0 | |
| cleaned_archives = 0 | |
| skipped_existing = 0 | |
| preexisting_rom_paths: set[Path] = set() | |
| if skip_existing_roms: | |
| preexisting_index = build_batocera_rom_index(system_root) | |
| for paths in preexisting_index.values(): | |
| preexisting_rom_paths.update(paths) | |
| if copy_roms: | |
| src_platform_dir = LAUNCHBOX_GAMES_ROOT / lb_platform | |
| # Extract ROMs from zip files if this system is in the list | |
| extract_zips = system in SYSTEMS_EXTRACT_ZIPS | |
| rom_copy_stats = copy_roms_for_platform( | |
| src_platform_dir, | |
| system_root, | |
| skip_ext=ROM_SKIP_EXT, | |
| overwrite=overwrite_roms, | |
| dry_run=dry_run, | |
| extract_archives=extract_zips, | |
| ) | |
| if extract_zips: | |
| system_trash = trash_root / system | |
| ensure_dir(system_trash, dry_run) | |
| cleaned_archives = cleanup_extracted_archives(system_root, system_trash, dry_run=dry_run) | |
| if export_system_music: | |
| dest_music_dir = batocera_root / "music" / system | |
| ensure_dir(dest_music_dir, dry_run) | |
| # Batocera only supports system-level music; copy everything for this platform. | |
| copied_sys_music = copy_system_music(lb_music_platform_dir, dest_music_dir, overwrite=overwrite_media, dry_run=dry_run) | |
| rom_index = build_batocera_rom_index(system_root) | |
| games = load_launchbox_platform_games(platform_xml) | |
| kept_rom_paths: set[Path] = set() | |
| gamelist_path = system_root / "gamelist.xml" | |
| existing_games = load_existing_gamelist(gamelist_path) | |
| root = ET.Element("gameList") | |
| matched = 0 | |
| missing_rom = 0 | |
| copied_imgs = 0 | |
| copied_vids = 0 | |
| missing_images: list[str] = [] | |
| copied_manuals = 0 | |
| copied_music = 0 | |
| for game_elem, title, app_path in games: | |
| app_stem = Path(app_path).stem | |
| rom_candidates = rom_index.get(norm(app_stem), []) | |
| # fallback: try by title stem | |
| if not rom_candidates: | |
| rom_candidates = rom_index.get(norm(title), []) | |
| if not rom_candidates: | |
| missing_rom += 1 | |
| continue | |
| # choose most direct path (shortest) | |
| rom_candidates.sort(key=lambda p: (len(str(p)), p.name.lower())) | |
| rom = rom_candidates[0] | |
| match_names = [title, rom.stem] | |
| kept_rom_paths.add(rom) | |
| mark_related_rom_variants(system_root, rom.stem, kept_rom_paths) | |
| rel_rom = "./" + rom.relative_to(system_root).as_posix() | |
| existing = existing_games.get(norm_gamelist_path(rel_rom)) | |
| if skip_existing_roms and rom in preexisting_rom_paths: | |
| if existing is not None: | |
| root.append(copy.deepcopy(existing)) | |
| skipped_existing += 1 | |
| continue | |
| if existing is not None: | |
| game_node = copy.deepcopy(existing) | |
| else: | |
| game_node = ET.Element("game") | |
| set_child_text(game_node, "path", rel_rom, overwrite=overwrite_fields) | |
| set_child_text(game_node, "name", title, overwrite=overwrite_fields) | |
| # metadata | |
| md = extract_metadata(game_elem) | |
| for k, v in md.items(): | |
| set_child_text(game_node, k, v, overwrite=overwrite_fields) | |
| # image | |
| img = pick_launchbox_asset(lb_images_platform_dir, match_names, IMAGE_PREF_ORDER) | |
| if img: | |
| # copy to images/<romstem>-image.<ext> and -thumb.<ext> | |
| dst_img = out_images / f"{rom.stem}-image{img.suffix.lower()}" | |
| if safe_copy(img, dst_img, overwrite=overwrite_media, dry_run=dry_run): | |
| copied_imgs += 1 | |
| set_child_text(game_node, "image", f"./images/{dst_img.name}", overwrite=overwrite_fields) | |
| dst_thumb = out_images / f"{rom.stem}-thumb{img.suffix.lower()}" | |
| safe_copy(img, dst_thumb, overwrite=overwrite_media, dry_run=dry_run) | |
| set_child_text(game_node, "thumbnail", f"./images/{dst_thumb.name}", overwrite=overwrite_fields) | |
| else: | |
| missing_images.append(title) | |
| # marquee (optional) | |
| marquee = pick_launchbox_asset(lb_images_platform_dir, match_names, MARQUEE_PREF_ORDER) | |
| if marquee: | |
| dst_marquee = out_images / f"{rom.stem}-marquee{marquee.suffix.lower()}" | |
| safe_copy(marquee, dst_marquee, overwrite=overwrite_media, dry_run=dry_run) | |
| set_child_text(game_node, "marquee", f"./images/{dst_marquee.name}", overwrite=overwrite_fields) | |
| # boxback (optional) | |
| boxback = pick_launchbox_asset(lb_images_platform_dir, match_names, BOXBACK_PREF_ORDER, recurse=True) | |
| if boxback: | |
| dst_boxback = out_images / f"{rom.stem}-boxback{boxback.suffix.lower()}" | |
| safe_copy(boxback, dst_boxback, overwrite=overwrite_media, dry_run=dry_run) | |
| set_child_text(game_node, "boxback", f"./images/{dst_boxback.name}", overwrite=overwrite_fields) | |
| # video | |
| if export_videos: | |
| vid = pick_launchbox_video(lb_videos_platform_dir, match_names) | |
| if vid: | |
| dst_vid = out_videos / f"{rom.stem}-video{vid.suffix.lower()}" | |
| if safe_copy(vid, dst_vid, overwrite=overwrite_media, dry_run=dry_run): | |
| copied_vids += 1 | |
| set_child_text(game_node, "video", f"./videos/{dst_vid.name}", overwrite=overwrite_fields) | |
| # manual | |
| manual = pick_launchbox_manual(lb_manuals_platform_dir, match_names) | |
| if manual: | |
| ensure_dir(out_manuals, dry_run) | |
| dst_manual = out_manuals / f"{rom.stem}-manual{manual.suffix.lower()}" | |
| if safe_copy(manual, dst_manual, overwrite=overwrite_media, dry_run=dry_run): | |
| copied_manuals += 1 | |
| set_child_text(game_node, "manual", f"./manuals/{dst_manual.name}", overwrite=overwrite_fields) | |
| # per-game music (optional) | |
| if export_game_music: | |
| music = pick_launchbox_music(lb_music_platform_dir, match_names) | |
| if music: | |
| dst_music = out_music / f"{rom.stem}-music{music.suffix.lower()}" | |
| if safe_copy(music, dst_music, overwrite=overwrite_media, dry_run=dry_run): | |
| copied_music += 1 | |
| set_child_text(game_node, "music", f"./music/{dst_music.name}", overwrite=overwrite_fields) | |
| # bezel (copy to Batocera decorations/thebezelproject/games/<system>/<rom>.png) | |
| if export_bezels: | |
| bezel = pick_launchbox_asset(lb_images_platform_dir, match_names, BEZEL_PREF_ORDER, recurse=True) | |
| if bezel: | |
| dest_bezel_dir = batocera_root / "decorations" / "thebezelproject" / "games" / system | |
| ensure_dir(dest_bezel_dir, dry_run) | |
| dst_bezel = dest_bezel_dir / f"{rom.stem}{bezel.suffix.lower()}" | |
| if safe_copy(bezel, dst_bezel, overwrite=overwrite_media, dry_run=dry_run): | |
| bezels_copied += 1 | |
| dst_bezel_img = out_images / f"{rom.stem}-bezel{bezel.suffix.lower()}" | |
| safe_copy(bezel, dst_bezel_img, overwrite=overwrite_media, dry_run=dry_run) | |
| set_child_text(game_node, "bezel", f"./images/{dst_bezel_img.name}", overwrite=overwrite_fields) | |
| if normalize_assets: | |
| normalize_game_assets( | |
| rom.stem, | |
| out_images, | |
| out_videos, | |
| out_manuals, | |
| out_music, | |
| overwrite=overwrite_media, | |
| dry_run=dry_run, | |
| ) | |
| # Fill missing media fields from existing files on disk | |
| image_exts = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} | |
| video_exts = {".mp4", ".mkv", ".avi", ".mov", ".wmv"} | |
| audio_exts = {".mp3", ".ogg", ".wav", ".flac", ".m4a", ".opus", ".mod", ".xm", ".s3m", ".it"} | |
| manual_exts = {".pdf", ".cbz", ".cbr"} | |
| existing_image = pick_existing_media_file(out_images, rom.stem, ["-image", ""], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if existing_image: | |
| overwrite_image = overwrite_fields or gamelist_media_missing(game_node, "image", system_root) | |
| set_child_text(game_node, "image", f"./images/{existing_image.name}", overwrite=overwrite_image) | |
| existing_thumb = pick_existing_media_file(out_images, rom.stem, ["-thumb"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if existing_thumb: | |
| overwrite_thumb = overwrite_fields or gamelist_media_missing(game_node, "thumbnail", system_root) | |
| set_child_text(game_node, "thumbnail", f"./images/{existing_thumb.name}", overwrite=overwrite_thumb) | |
| elif existing_image: | |
| overwrite_thumb = overwrite_fields or gamelist_media_missing(game_node, "thumbnail", system_root) | |
| set_child_text(game_node, "thumbnail", f"./images/{existing_image.name}", overwrite=overwrite_thumb) | |
| existing_marquee = pick_existing_media_file(out_images, rom.stem, ["-marquee"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if existing_marquee: | |
| overwrite_marquee = overwrite_fields or gamelist_media_missing(game_node, "marquee", system_root) | |
| set_child_text(game_node, "marquee", f"./images/{existing_marquee.name}", overwrite=overwrite_marquee) | |
| existing_bezel = pick_existing_media_file(out_images, rom.stem, ["-bezel"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if existing_bezel: | |
| overwrite_bezel = overwrite_fields or gamelist_media_missing(game_node, "bezel", system_root) | |
| set_child_text(game_node, "bezel", f"./images/{existing_bezel.name}", overwrite=overwrite_bezel) | |
| existing_boxback = pick_existing_media_file(out_images, rom.stem, ["-boxback"], image_exts, [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"]) | |
| if existing_boxback: | |
| overwrite_boxback = overwrite_fields or gamelist_media_missing(game_node, "boxback", system_root) | |
| set_child_text(game_node, "boxback", f"./images/{existing_boxback.name}", overwrite=overwrite_boxback) | |
| existing_video = pick_existing_media_file(out_videos, rom.stem, ["-video", ""], video_exts, [".mp4", ".mkv", ".avi", ".mov", ".wmv"]) | |
| if existing_video: | |
| overwrite_video = overwrite_fields or gamelist_media_missing(game_node, "video", system_root) | |
| set_child_text(game_node, "video", f"./videos/{existing_video.name}", overwrite=overwrite_video) | |
| existing_manual = pick_existing_media_file(out_manuals, rom.stem, ["-manual"], manual_exts, [".pdf", ".cbz", ".cbr"]) | |
| if existing_manual: | |
| overwrite_manual = overwrite_fields or gamelist_media_missing(game_node, "manual", system_root) | |
| set_child_text(game_node, "manual", f"./manuals/{existing_manual.name}", overwrite=overwrite_manual) | |
| existing_music = pick_existing_media_file(out_music, rom.stem, ["-music"], audio_exts, [".mp3", ".ogg", ".wav", ".flac", ".m4a", ".opus", ".mod", ".xm", ".s3m", ".it"]) | |
| if existing_music: | |
| overwrite_music = overwrite_fields or gamelist_media_missing(game_node, "music", system_root) | |
| set_child_text(game_node, "music", f"./music/{existing_music.name}", overwrite=overwrite_music) | |
| # Remove entries pointing to missing files and strip zero ratings | |
| cleanup_missing_media_and_zero_rating(game_node, system_root) | |
| root.append(game_node) | |
| matched += 1 | |
| if verify_gamelist: | |
| rows = verify_gamelist_entries(root, system_root, system) | |
| verify_rows.extend(rows) | |
| if rows: | |
| log_warn(f"Verification issues: {len(rows)} for {system}") | |
| else: | |
| log_info(f"Verification OK: {system}") | |
| backup_dir = batocera_root / BACKUP_DIRNAME / "gamelist" / system | |
| write_gamelist_atomic( | |
| root, | |
| gamelist_path, | |
| backup_dir, | |
| backup_enabled=backup_gamelist, | |
| dry_run=dry_run, | |
| ) | |
| log_info(f"Matched games: {matched}") | |
| log_info(f"Missing ROM matches (skipped): {missing_rom}") | |
| log_info(f"Images copied: {copied_imgs}") | |
| log_info(f"Videos copied: {copied_vids}") | |
| log_info(f"Manuals copied: {copied_manuals}") | |
| log_info(f"Per-game music copied: {copied_music}") | |
| if log_missing_images and missing_images: | |
| log_info(f"Missing images (first 20 of {len(missing_images)}): {', '.join(missing_images[:20])}") | |
| if export_system_music: | |
| log_info(f"System music copied: {copied_sys_music}") | |
| if export_bezels: | |
| log_info(f"Bezels copied: {bezels_copied}") | |
| if skip_existing_roms: | |
| log_info(f"Existing ROMs skipped: {skipped_existing}") | |
| if cleaned_archives: | |
| log_info(f"Archives moved to trash: {cleaned_archives}") | |
| if rom_copy_stats is not None: | |
| if rom_copy_stats.get("missing_source"): | |
| log_warn(f"ROM copy: source not found: {LAUNCHBOX_GAMES_ROOT / lb_platform}") | |
| else: | |
| extracted = rom_copy_stats.get('extracted', 0) | |
| if extracted > 0: | |
| log_info(f"ROM copy: copied {rom_copy_stats['copied']}, extracted {extracted}, skipped exists {rom_copy_stats['skipped_exists']}, skipped by ext {rom_copy_stats['skipped_ext']}") | |
| else: | |
| log_info(f"ROM copy: copied {rom_copy_stats['copied']}, skipped exists {rom_copy_stats['skipped_exists']}, skipped by ext {rom_copy_stats['skipped_ext']}") | |
| log_info(f"Wrote: {gamelist_path}") | |
| # Prune: anything in Batocera that did not match LaunchBox gets moved to trash | |
| if prune_missing: | |
| pruned_roms = 0 | |
| pruned_media = 0 | |
| # Build a flat list of all roms indexed | |
| all_roms: list[Path] = [] | |
| for paths in rom_index.values(): | |
| all_roms.extend(paths) | |
| extra_roms = [p for p in all_roms if p not in kept_rom_paths] | |
| if extra_roms: | |
| ensure_dir(trash_root, dry_run) | |
| system_trash = trash_root / system | |
| for rom_path in extra_roms: | |
| move_to_trash(rom_path, system_trash, system_root, dry_run=dry_run) | |
| pruned_roms += 1 | |
| stem = rom_path.stem | |
| # per-rom override next to rom (rom.ext.cfg) | |
| override = rom_path.with_name(rom_path.name + ".cfg") | |
| if override.exists(): | |
| move_to_trash(override, system_trash, system_root, dry_run=dry_run) | |
| pruned_overlays += 1 | |
| # images (box art, thumb, marquee, bezel, boxback) | |
| image_patterns = [ | |
| f"{stem}.*", | |
| f"{stem}-image.*", | |
| f"{stem}-thumb.*", | |
| f"{stem}-marquee.*", | |
| f"{stem}-bezel.*", | |
| f"{stem}-boxback.*", | |
| ] | |
| for pattern in image_patterns: | |
| for img_file in (system_root / "images").glob(pattern): | |
| move_to_trash(img_file, system_trash, system_root, dry_run=dry_run) | |
| pruned_media += 1 | |
| # videos | |
| for vid_file in (system_root / "videos").glob(f"{stem}-video.*"): | |
| move_to_trash(vid_file, system_trash, system_root, dry_run=dry_run) | |
| pruned_media += 1 | |
| # manuals | |
| for manual_file in (system_root / "manuals").glob(f"{stem}-manual.*"): | |
| move_to_trash(manual_file, system_trash, system_root, dry_run=dry_run) | |
| pruned_media += 1 | |
| # per-game music | |
| for music_file in (system_root / "music").glob(f"{stem}-music.*"): | |
| move_to_trash(music_file, system_trash, system_root, dry_run=dry_run) | |
| pruned_media += 1 | |
| log_info(f"Pruned ROMs: {pruned_roms}") | |
| log_info(f"Pruned media: {pruned_media}") | |
| if bios_copy_stats is not None: | |
| missing = bios_copy_stats.get("missing_sources", []) | |
| if missing: | |
| log_warn(f"Bios copy: missing source dirs: {', '.join(str(m) for m in missing)}") | |
| log_info(f"Bios copy: copied {bios_copy_stats['copied']}, skipped exists {bios_copy_stats['skipped_exists']}") | |
| for per in bios_copy_stats.get("per_dir", []): | |
| src = per["src"] | |
| if per.get("missing"): | |
| log_warn(f" BIOS dir missing: {src}") | |
| else: | |
| log_info(f" BIOS dir {src}: copied {per['copied']}, skipped exists {per['skipped_exists']}") | |
| if verify_gamelist and verify_report_path: | |
| try: | |
| if not dry_run: | |
| ensure_dir(verify_report_path.parent, dry_run=False) | |
| if not dry_run: | |
| with open(verify_report_path, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=["system", "name", "issue", "tag", "path"]) | |
| writer.writeheader() | |
| for row in verify_rows: | |
| writer.writerow(row) | |
| log_info(f"Verification report: {verify_report_path} ({len(verify_rows)} issues)") | |
| except OSError as e: | |
| log_warn(f"Failed to write verification report: {e}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment