Skip to content

Instantly share code, notes, and snippets.

@turnercore
Last active December 23, 2025 13:15
Show Gist options
  • Select an option

  • Save turnercore/5eeb0f4ab437f7c77ae1c8bc61fc4122 to your computer and use it in GitHub Desktop.

Select an option

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.
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