diff --git a/scripts/import-saves-to-romm.py b/scripts/import-saves-to-romm.py new file mode 100644 index 0000000..431d324 --- /dev/null +++ b/scripts/import-saves-to-romm.py @@ -0,0 +1,247 @@ +import os +import re +import sys +import shutil +import datetime +from sqlalchemy import create_engine, text + +# We run inside the romm container, so we can import ConfigManager +from config.config_manager import ConfigManager + +def normalize(name): + # Strip extension + base, _ = os.path.splitext(name) + # Strip everything inside parentheses/brackets + base = re.sub(r"\([^)]*\)", "", base) + base = re.sub(r"\[[^\]]*\]", "", base) + # Remove non-alphanumeric characters and lowercase + return re.sub(r"[^a-zA-Z0-9]", "", base).lower() + +def get_emulator_slug(platform): + mapping = { + "gba": "mgba", + "nds": "melonds", + "n64": "retroarch-mupen64plus_next", + "psx": "retroarch-pcsx_rearmed", + "switch": "yuzu" + } + return mapping.get(platform, "retroarch") + +def main(): + dry_run = "--apply" not in sys.argv + if dry_run: + print("=== DRY RUN MODE: No changes will be applied ===") + print("To apply changes, run with --apply flag.\n") + else: + print("=== EXECUTION MODE: Appending files and updating database ===\n") + + # Paths inside container + staging_dir = "/romm/library/tmp/saves_staging" + assets_dir = "/romm/assets" + user_id = 1 + user_hex = "557365723a31" # User:1 in hex + + if not os.path.exists(staging_dir): + print(f"Staging directory {staging_dir} does not exist!") + sys.exit(1) + + # Manual overrides dictionary: (platform, normalized_save_name) -> rom_id + manual_overrides = { + ("switch", "armsglobaltestpunch"): 9599, + ("nds", "pokemondefinitiveheartgoldversion"): 3582, + ("nds", "heartgoldqol"): 3586, + } + + # 2. Connect to database + engine = create_engine(ConfigManager.get_db_engine()) + + # Fetch all ROMs in database to build a matching index + # Map (platform_id, normalized_name) -> list of (rom_id, rom_name, file_name, file_path) + rom_index = {} + + # Map rom_id -> (rom_id, rom_name, file_name, file_path) + rom_by_id = {} + + # Map title_id (16 hex chars starting with 0100) -> list of (rom_id, rom_name, file_name, file_path) + title_id_index = {} + + platform_slug_to_id = { + "gba": 72, + "nds": 116, + "n64": 111, + "psx": 148, + "switch": 179 + } + + with engine.begin() as conn: + print("Fetching ROMs from database...") + result = conn.execute(text(""" + SELECT r.id, r.name, r.platform_id, rf.file_name, rf.file_path + FROM roms r + JOIN rom_files rf ON r.id = rf.rom_id + """)) + + for row in result: + rom_id, rom_name, platform_id, file_name, file_path = row + rom_tuple = (rom_id, rom_name, file_name, file_path) + + rom_by_id[rom_id] = rom_tuple + + # Index by normalized name + norm_name = normalize(file_name) + key = (platform_id, norm_name) + if key not in rom_index: + rom_index[key] = [] + rom_index[key].append(rom_tuple) + + # Index Switch by title ID if present in filename + if platform_id == 179: + match = re.search(r"0100[0-9a-fA-F]{12}", file_name) + if match: + title_id = match.group(0).lower() + if title_id not in title_id_index: + title_id_index[title_id] = [] + title_id_index[title_id].append(rom_tuple) + + print(f"Indexed {len(rom_index)} ROM name patterns and {len(title_id_index)} Switch title IDs.") + + # 3. Process staged files + platforms = [d for d in os.listdir(staging_dir) if os.path.isdir(os.path.join(staging_dir, d))] + + copied_count = 0 + db_count = 0 + + for platform_slug in platforms: + platform_id = platform_slug_to_id.get(platform_slug) + if not platform_id: + print(f"Skipping unknown platform: {platform_slug}") + continue + + platform_path = os.path.join(staging_dir, platform_slug) + save_files = [f for f in os.listdir(platform_path) if os.path.isfile(os.path.join(platform_path, f))] + + print(f"\nProcessing {len(save_files)} saves for platform '{platform_slug}'...") + + for save_file in save_files: + save_path = os.path.join(platform_path, save_file) + save_base, save_ext = os.path.splitext(save_file) + save_ext_clean = save_ext.replace(".", "").lower() + + # Match logic + matched_rom = None + norm_save_name = normalize(save_file) + + # Check manual overrides first + override_rom_id = manual_overrides.get((platform_slug, norm_save_name)) + if override_rom_id: + matched_rom = rom_by_id.get(override_rom_id) + if matched_rom: + print(f" [*] MANUAL OVERRIDE MATCH: '{save_file}' -> ROM ID: {override_rom_id}") + + # Switch Title ID match + if not matched_rom and platform_slug == "switch" and re.match(r"^0100[0-9a-fA-F]{12}$", save_base): + title_id = save_base.lower() + matches = title_id_index.get(title_id, []) + if len(matches) == 1: + matched_rom = matches[0] + elif len(matches) > 1: + print(f" [!] Ambiguous title ID {title_id} ({len(matches)} matches). Skipping.") + continue + + # Fallback to normalized name match + if not matched_rom: + key = (platform_id, norm_save_name) + matches = rom_index.get(key, []) + if len(matches) == 1: + matched_rom = matches[0] + elif len(matches) > 1: + # Try exact match on file name without extension + exact_matches = [m for m in matches if os.path.splitext(m[2])[0].lower() == save_base.lower()] + if len(exact_matches) == 1: + matched_rom = exact_matches[0] + else: + print(f" [!] Ambiguous normalized name {norm_save_name} ({len(matches)} matches). Skipping.") + continue + + if not matched_rom: + print(f" [-] UNMATCHED: '{save_file}' (normalized: {norm_save_name})") + continue + + rom_id, rom_name, rom_file_name, rom_file_path = matched_rom + rom_base, _ = os.path.splitext(rom_file_name) + + # Format file name on disk + mtime = os.path.getmtime(save_path) + dt = datetime.datetime.fromtimestamp(mtime) + timestamp_str = dt.strftime("%Y-%m-%d_%H-%M-%S") + + new_save_file_name = f"{rom_base} [{timestamp_str}]{save_ext}" + new_save_file_no_ext = f"{rom_base} [{timestamp_str}]" + + # Emulator slug + emulator = get_emulator_slug(platform_slug) + + # Path relative to assets folder + rel_save_path = f"users/{user_hex}/saves/{platform_slug}/{rom_id}/{emulator}" + dest_dir = os.path.join(assets_dir, rel_save_path) + dest_file_path = os.path.join(dest_dir, new_save_file_name) + + file_size = os.path.getsize(save_path) + + print(f" [+] MATCHED: '{save_file}' -> ROM '{rom_name}' (ID: {rom_id})") + print(f" Target Name: '{new_save_file_name}'") + print(f" Target Path: '{rel_save_path}'") + + if not dry_run: + # Create destination directory + os.makedirs(dest_dir, exist_ok=True) + # Copy file + shutil.copy2(save_path, dest_file_path) + copied_count += 1 + + # Database insert + with engine.begin() as conn: + # Check if save is already registered + exist_check = conn.execute(text(""" + SELECT id FROM saves + WHERE rom_id = :rom_id AND user_id = :user_id AND file_name = :file_name + """), {"rom_id": rom_id, "user_id": user_id, "file_name": new_save_file_name}).fetchone() + + if not exist_check: + conn.execute(text(""" + INSERT INTO saves ( + emulator, created_at, updated_at, file_name, file_name_no_tags, + file_name_no_ext, file_extension, file_path, file_size_bytes, + rom_id, user_id, missing_from_fs, slot + ) VALUES ( + :emulator, :created_at, :updated_at, :file_name, :file_name_no_tags, + :file_name_no_ext, :file_extension, :file_path, :file_size_bytes, + :rom_id, :user_id, 0, 'default' + ) + """), { + "emulator": emulator, + "created_at": dt, + "updated_at": dt, + "file_name": new_save_file_name, + "file_name_no_tags": rom_name, + "file_name_no_ext": new_save_file_no_ext, + "file_extension": save_ext_clean, + "file_path": rel_save_path, + "file_size_bytes": file_size, + "rom_id": rom_id, + "user_id": user_id + }) + db_count += 1 + print(" [DB] Inserted database record.") + else: + print(" [DB] Save record already exists. Skipped DB insert.") + + print("\n=== SUMMARY ===") + if dry_run: + print("Dry run completed. Run with --apply flag to execute.") + else: + print(f"Successfully copied {copied_count} files to assets.") + print(f"Successfully registered {db_count} saves in database.") + +if __name__ == "__main__": + main() diff --git a/scripts/stage-saves.ps1 b/scripts/stage-saves.ps1 new file mode 100644 index 0000000..fc00143 --- /dev/null +++ b/scripts/stage-saves.ps1 @@ -0,0 +1,130 @@ +# stage-saves.ps1 +# PowerShell script to collect and structure emulation saves on Z:\tmp\saves_staging\ + +$stagingDir = "Z:\Emulation\tmp\saves_staging" +if (-not (Test-Path $stagingDir)) { + New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null +} + +Write-Host "Staging saves to: $stagingDir" -ForegroundColor Cyan + +# Helper to copy single save files +function Stage-SingleSave($platform, $srcPath) { + if (Test-Path $srcPath) { + $destPlatformDir = Join-Path $stagingDir $platform + if (-not (Test-Path $destPlatformDir)) { + New-Item -ItemType Directory -Path $destPlatformDir -Force | Out-Null + } + $fileName = Split-Path $srcPath -Leaf + $destPath = Join-Path $destPlatformDir $fileName + Copy-Item -Path $srcPath -Destination $destPath -Force + Write-Host " [+] Staged $platform save: $fileName" -ForegroundColor Green + } else { + Write-Warning " [-] Source save not found: $srcPath" + } +} + +# Helper to zip a directory of Switch files +function Stage-SwitchSave($name, $folderPath) { + if (Test-Path $folderPath) { + $destPlatformDir = Join-Path $stagingDir "switch" + if (-not (Test-Path $destPlatformDir)) { + New-Item -ItemType Directory -Path $destPlatformDir -Force | Out-Null + } + + # Normalize name to lowercase alphanumeric + $cleanName = ($name -replace '[^a-zA-Z0-9]', '').ToLower() + $zipPath = Join-Path $destPlatformDir "$cleanName.zip" + + # Remove old zip if exists + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force + } + + # Compress folder contents (so files are at root of zip) + $filesPath = Join-Path $folderPath "*" + Compress-Archive -Path $filesPath -DestinationPath $zipPath -Force + Write-Host " [+] Zipped & Staged Switch save: $name ($cleanName.zip)" -ForegroundColor Green + } else { + Write-Warning " [-] Source Switch save folder not found: $folderPath" + } +} + +# --- 1. Stage GBA (mGBA) --- +Write-Host "`nStaging GBA saves..." -ForegroundColor Yellow +Stage-SingleSave "gba" "Z:\Emulation\saves\mgba\Pokemon FireRed Rocket Edition.sav" + +# --- 2. Stage NDS (melonDS) --- +Write-Host "`nStaging NDS saves..." -ForegroundColor Yellow +$ndsSaves = @( + "Ace Attorney Investigations - Miles Edgeworth.sav", + "Heartgold QoL.sav", + "Nine Hours, Nine Persons, Nine Doors.sav", + "Pokemon Definitive HeartGold Version.sav", + "Pokemon Definitive HeartGold.sav", + "Pokemon HeartGold Generations 2.0.sav" +) +foreach ($save in $ndsSaves) { + Stage-SingleSave "nds" "Z:\Emulation\saves\melonds\saves\$save" +} + +# --- 3. Stage N64 (RetroArch) --- +Write-Host "`nStaging N64 saves..." -ForegroundColor Yellow +Stage-SingleSave "n64" "Z:\Emulation\saves\retroarch\saves\007 - The World Is Not Enough (USA).srm" +Stage-SingleSave "n64" "Z:\Emulation\saves\retroarch\saves\Chameleon Twist 2 (USA).srm" + +# --- 4. Stage PSX (RetroArch) --- +Write-Host "`nStaging PS1 saves..." -ForegroundColor Yellow +Stage-SingleSave "psx" "Z:\Emulation\saves\retroarch\saves\Tomba! 2 - The Evil Swine Return (USA).srm" + +# --- 5. Stage Switch JKSV Saves --- +Write-Host "`nStaging Switch JKSV saves..." -ForegroundColor Yellow +$jksvDir = "Z:\Emulation\JKSV" +if (Test-Path $jksvDir) { + $gameDirs = Get-ChildItem -Path $jksvDir -Directory + foreach ($gameDir in $gameDirs) { + $gameName = $gameDir.Name + + # Get all subdirectories (backups) inside this game folder + $backups = Get-ChildItem -Path $gameDir.FullName -Directory | Sort-Object Name -Descending + if ($backups.Count -gt 0) { + # Pick the most recent backup + $recentBackup = $backups[0] + Stage-SwitchSave $gameName $recentBackup.FullName + } + } +} else { + Write-Warning "JKSV directory not found!" +} + +# --- 6. Stage Switch Yuzu Local Roaming Saves --- +Write-Host "`nStaging Switch Yuzu local saves..." -ForegroundColor Yellow +$yuzuUserSaveBase = "C:\Users\MattC\AppData\Roaming\yuzu\nand\user\save\0000000000000000" +if (Test-Path $yuzuUserSaveBase) { + # 01008DB008C2C000: Pokemon Let's Go Pikachu + # Yuzu uses profile folders inside yuzu\nand\user\save\0000000000000000\\ + # We will search for title IDs recursively under the base folder + + # 01008DB008C2C000 -> Let's Go Pikachu + $pikachuFolders = Get-ChildItem -Path $yuzuUserSaveBase -Directory -Filter "01008DB008C2C000" -Recurse + # Pick the one with the latest write time + if ($pikachuFolders.Count -gt 0) { + $latestPikachu = $pikachuFolders | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Stage-SwitchSave "01008db008c2c000" $latestPikachu.FullName + } else { + Write-Warning "Pokemon Let's Go Pikachu save folder not found in Yuzu AppData!" + } + + # 0100453019AA8000 -> Xenoblade Chronicles X: Definitive Edition + $xenoFolders = Get-ChildItem -Path $yuzuUserSaveBase -Directory -Filter "0100453019AA8000" -Recurse + if ($xenoFolders.Count -gt 0) { + $latestXeno = $xenoFolders | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Stage-SwitchSave "0100453019aa8000" $latestXeno.FullName + } else { + Write-Warning "Xenoblade X save folder not found in Yuzu AppData!" + } +} else { + Write-Warning "Yuzu user save base path not found!" +} + +Write-Host "`nStaging complete!" -ForegroundColor Cyan