Created
February 19, 2025 02:06
-
-
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
This file contains 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
#!/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