commit fb78292b63eacc691e667a11b47725ca86d5bbcb Author: Brennen Raimer <5969754+norweeg@users.noreply.github.com> Date: Wed Aug 26 19:27:08 2026 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea1384b --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# ES Multi-Disc Game Organizer + +Organizes multi-disc game files in an ES ROMs system directory so that ES will treat them as a single game with many discs rather than many single-disc games + +## Usage + +### Pre-requisites + +* [uv](https://docs.astral.sh/uv/) + +### Instructions + +1. Clone this repository or download and unzip it +2. Open a terminal change to the directory you just cloned or unzipped +3. Run `uv run es-multi-disc-game-organizer /path/to/ROMs/system` replacing the path with the full path to a system folder in your ES-DE ROMs folder containing multi-disc games + +### Important Caveats + +* This tool supports .iso, .zip, .chd, and .cso ROM files (sorry, bin/cue is just not easy to work with, and you can easily convert those to .chd using [chdman](https://wiki.recalbox.com/en/tutorials/utilities/rom-conversion/chdman)) +* This tool assumes that your ROM files are named in the format "Game Name (disc 1).ext" or "Game Name (disk 1 of 2).ext". Many ROMs will come with a lot of superfluous information (region, software version, disk pressing, the name of the person who ripped the disc, etc.). There's just far too many naming conventions out there for me to even begin to try to support them all. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..96eac53 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "es-multi-disc-game-organizer" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ + { name = "Brennen Raimer", email = "5969754+norweeg@users.noreply.github.com" } +] +requires-python = ">=3.14" +dependencies = [ + "toolz>=1.1.0", +] + +[project.scripts] +es-multi-disc-game-organizer = "es_multi_disc_game_organizer:main" + +[build-system] +requires = ["uv_build>=0.12.5,<0.13.0"] +build-backend = "uv_build" diff --git a/src/es_multi_disc_game_organizer/__init__.py b/src/es_multi_disc_game_organizer/__init__.py new file mode 100644 index 0000000..ba6a861 --- /dev/null +++ b/src/es_multi_disc_game_organizer/__init__.py @@ -0,0 +1,61 @@ +from argparse import ArgumentParser +from operator import methodcaller, attrgetter +from pathlib import Path +from shutil import move +from re import compile, IGNORECASE + +from toolz import compose_left, first, groupby +from toolz.curried.operator import contains + +# Extensions typically used for rom files of disc-based games. This is not an exhaustive list, but it should cover most cases. +ROM_FILE_EXTS = (".cso", ".chd", ".zip", ".iso") + +# This regex expects ROM files named in the format "Game Name (disc 1).ext" or "Game Name (disk 1 of 2).ext". The regex is case-insensitive. +# The game name is captured in group 1, which is used to create the playlist and subdirectory and m3u playlist names. +DISC_REGEX = compile(r"(.+) \(dis[ck] \d+( of \d+)?\)$", IGNORECASE) + +# filter predicates for identifying rom files +is_romfile = compose_left( + attrgetter("suffix"), methodcaller("lower"), contains(ROM_FILE_EXTS) +) + +# filter predicate for identifying multi-disc games +is_multidisc = compose_left(attrgetter("stem"), DISC_REGEX.match) + + +def organize_multidisc_games(folder: Path) -> None: + # Get all the rom files in the folder, filter for multi-disc games, and group them by game name + rom_files = filter(is_romfile, folder.glob("*")) + multi_disc_games = filter(is_multidisc, rom_files) + + games = groupby( + compose_left(attrgetter("stem"), DISC_REGEX.match, methodcaller("group", 1)), + multi_disc_games, + ) + + # Create a playlist for each multi-disc game and move the disc files into a subdirectory + for game, discs in games.items(): + first_file = first(discs) + playlist_path = first_file.parent / f"{game}.m3u" + discs_path = first_file.parent / game + discs_path.mkdir(exist_ok=True) + + # noload.txt tells ES to ignore this directory for scanning, so we don't get duplicate entries in the gamelist.xml + (discs_path / "noload.txt").touch(exist_ok=True) + + with open(playlist_path, "w") as playlist: + for file in sorted(discs, key=attrgetter("name")): + playlist.write(f"{game}/{file.name}\n") + move(file, discs_path / file.name) + + +def main() -> None: + parser = ArgumentParser(description="create playlists for multdisc games") + parser.add_argument("folder", type=Path) + + args = parser.parse_args() + organize_multidisc_games(args.folder) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a7e169c --- /dev/null +++ b/uv.lock @@ -0,0 +1,23 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "es-multi-disc-game-organizer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "toolz" }, +] + +[package.metadata] +requires-dist = [{ name = "toolz", specifier = ">=1.1.0" }] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +]