Skip to content

Instantly share code, notes, and snippets.

@jondkelley
Created February 19, 2025 02:06
Show Gist options
  • Save jondkelley/74175a59f02b397b7dc650f193c05c89 to your computer and use it in GitHub Desktop.
Save jondkelley/74175a59f02b397b7dc650f193c05c89 to your computer and use it in GitHub Desktop.
Organizes huge directory of ROM files into structured directories based on region and alphabetical ranges
#!/usr/bin/env python3
"""
(Copyleft) 2025 Jonathan D. Kelley
ROM Organizer Tool
==================
This script organizes ROM files into structured directories based on region
and alphabetical ranges. It supports recursive scanning, dry-run mode,
and automatic directory cleanup.
Usage:
------
Run the script with the `organize` command:
python rom_organizer.py organize --source-dir /path/to/roms --destination-dir /path/to/organized_roms
Options:
--------
--source-dir Path to the directory containing ROM files.
--destination-dir Path where organized ROMs will be stored.
--recursive Recursively scan subdirectories for ROM files.
--dry-run Simulate the organization without making changes.
Supported File Types:
---------------------
- .zip
- .nes
- .rom
ROM Filename Examples:
----------------------
Input files:
- "Super Mario Bros. (USA).zip"
- "The Legend of Zelda (Europe).nes"
- "Final Fantasy III (Japan).rom"
- "Contra (World).zip"
- "Dragon Quest (JPN).zip"
- "Metal Gear (USA).zip"
- "Donkey Kong Country (Europe).zip"
- "Chrono Trigger (Japan).rom"
- "Street Fighter II Turbo (USA).zip"
- "Pokémon Red (USA).zip"
- "Tekken 3 (Europe).zip"
- "Castlevania - Symphony of the Night (Japan).zip"
- "Sonic the Hedgehog (World).zip"
- "Mega Man X (USA).zip"
- "The King of Fighters '98 (Asia).zip"
- "Fire Emblem - Mystery of the Emblem (JPN).zip"
- "Harvest Moon (USA).zip"
- "Tetris (World).zip"
- "Dragon Ball Z - Super Butoden (Japan).zip"
Resulting Directory Structure:
/destination-dir/
├── USA-NTSC(A-C)/
│ ├── Contra (World).zip
│ ├── Castlevania - Symphony of the Night (Japan).zip
│ ├── Chrono Trigger (Japan).rom
│ ├── Contra (World).zip
│ ├── Chrono Trigger (Japan).rom
│ ├── Castlevania - Symphony of the Night (Japan).zip
├── USA-NTSC(D-F)/
│ ├── Donkey Kong Country (Europe).zip
│ ├── Dragon Quest (JPN).zip
│ ├── Dragon Ball Z - Super Butoden (Japan).zip
│ ├── Fire Emblem - Mystery of the Emblem (JPN).zip
├── USA-NTSC(G-I)/
│ ├── Harvest Moon (USA).zip
├── EUR-PAL(J-L)/
│ ├── The Legend of Zelda (Europe).nes
├── JAPAN(M-O)/
│ ├── Mega Man X (USA).zip
├── WORLD(P-R)/
│ ├── Pokémon Red (USA).zip
├── EUR-PAL(S-U)/
│ ├── Sonic the Hedgehog (World).zip
│ ├── Street Fighter II Turbo (USA).zip
├── ASIA(V-Z)/
│ ├── The King of Fighters '98 (Asia).zip
├── WORLD(A-C)/
│ ├── Tetris (World).zip
├── EUR-PAL(T-Z)/
│ ├── Tekken 3 (Europe).zip
├── JAPAN(A-C)/
│ ├── Final Fantasy III (Japan).rom
│ ├── Fire Emblem - Mystery of the Emblem (JPN).zip
├── USA-NTSC(A-C)/
│ ├── Super Mario Bros. (USA).zip
Features:
---------
✅ Automatically detects ROM regions based on filename patterns.
✅ Organizes ROMs into alphabetical subdirectories within their region.
✅ Supports dry-run mode to preview changes before execution.
✅ Cleans up empty directories after organization.
✅ Recursive mode for scanning nested folders.
"""
import os
import re
import shutil
import argparse
from pathlib import Path
from typing import List, Dict, Tuple
class ROMOrganizer:
REGION_PATTERNS = {
'USA-NTSC': [r'\(USA\)', r'\(US\)'],
'EUR-PAL': [r'\(Europe\)', r'\(EUR\)'],
'JAPAN': [r'\(Japan\)', r'\(JPN\)'],
'ASIA': [r'\(Asia\)', r'\(AS\)'],
'CHINA': [r'\(China\)', r'\(CN\)'],
'WORLD': [r'\(World\)'],
'PIRATE': [r'\(Pirate\)', r'\(Unl\)'],
'ROMHACK': [r'\(Romhack\)', r'\(Hack\)']
}
ALPHA_RANGES = {
'USA-NTSC': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'EUR-PAL': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'JAPAN': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'ASIA': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'CHINA': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'WORLD': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'PIRATE': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
],
'ROMHACK': [
('A', 'C'), ('D', 'F'), ('G', 'I'),
('J', 'L'), ('M', 'O'), ('P', 'R'),
('S', 'U'), ('V', 'Z')
]
}
def __init__(self, source_dir: str, dest_dir: str, recursive: bool, dry_run: bool):
self.source_dir = Path(source_dir)
self.dest_dir = Path(dest_dir)
self.recursive = recursive
self.dry_run = dry_run
def get_rom_files(self) -> List[Path]:
"""Get all ROM files from the source directory."""
pattern = "**/*" if self.recursive else "*"
return [f for f in self.source_dir.glob(pattern)
if f.is_file() and
f.suffix.lower() in ['.zip', '.nes', '.rom'] and
not f.name.startswith('.')] # Skip dot files
def determine_region(self, filename: str) -> str:
"""Determine the region of a ROM based on its filename."""
for region, patterns in self.REGION_PATTERNS.items():
if any(re.search(pattern, filename) for pattern in patterns):
return region
return 'WORLD' # Default to World if no region found
def get_alpha_range(self, region: str, filename: str) -> Tuple[str, str]:
"""Determine the alphabetical range for a filename within a region."""
# Get the first letter of the filename (ignoring special characters)
first_letter = next((c for c in filename.upper() if c.isalpha()), 'A')
for start, end in self.ALPHA_RANGES[region]:
if start <= first_letter <= end:
return (start, end)
return self.ALPHA_RANGES[region][0] # Default to first range if no match
def create_directory_structure(self):
"""Create the directory structure for ROM organization."""
for region, ranges in self.ALPHA_RANGES.items():
for start, end in ranges:
dir_name = f"{region}({start}-{end})"
dir_path = self.dest_dir / dir_name
if not self.dry_run:
dir_path.mkdir(parents=True, exist_ok=True)
def organize_rom(self, rom_file: Path):
"""Organize a single ROM file."""
region = self.determine_region(rom_file.name)
alpha_range = self.get_alpha_range(region, rom_file.name)
dir_name = f"{region}({alpha_range[0]}-{alpha_range[1]})"
dest_path = self.dest_dir / dir_name / rom_file.name
# Create directory structure regardless of dry run mode
dest_path.parent.mkdir(parents=True, exist_ok=True)
if self.dry_run:
# Create empty text file in destination
with open(f"{dest_path}.txt", 'w') as f:
f.write(f"Would move {rom_file} here")
print(f"Would move {rom_file} to {dest_path}")
else:
shutil.copy2(rom_file, dest_path)
print(f"Moved {rom_file} to {dest_path}")
def cleanup_empty_directories(self):
"""Remove empty directories in the destination folder."""
if self.dry_run:
# Check which directories would be removed
for dirpath, dirnames, filenames in os.walk(self.dest_dir, topdown=False):
if not dirnames and not filenames:
print(f"Would remove empty directory: {dirpath}")
else:
# Actually remove empty directories
for dirpath, dirnames, filenames in os.walk(self.dest_dir, topdown=False):
if not dirnames and not filenames:
try:
os.rmdir(dirpath)
print(f"Removed empty directory: {dirpath}")
except OSError as e:
print(f"Error removing directory {dirpath}: {e}")
def organize(self):
"""Main organization method."""
self.create_directory_structure()
rom_files = self.get_rom_files()
for rom_file in rom_files:
self.organize_rom(rom_file)
# After organizing, clean up empty directories
print("\nCleaning up empty directories...")
self.cleanup_empty_directories()
def main():
parser = argparse.ArgumentParser(description="ROM Organization Tool")
subparsers = parser.add_subparsers(dest='command', help='Commands')
# Organize command
organize_parser = subparsers.add_parser('organize', help='Organize ROMs into categorized directories')
organize_parser.add_argument('--source-dir', required=True, help='Source directory containing ROMs')
organize_parser.add_argument('--destination-dir', required=True, help='Destination directory for organized ROMs')
organize_parser.add_argument('--recursive', action='store_true', help='Recursively process subdirectories')
organize_parser.add_argument('--dry-run', action='store_true', help='Show what would be done without making changes')
args = parser.parse_args()
if args.command == 'organize':
organizer = ROMOrganizer(
args.source_dir,
args.destination_dir,
args.recursive,
args.dry_run
)
organizer.organize()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment