feat: add RomM save staging and import utility scripts
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user