mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 14:56:01 +00:00
Add server-side ROM patching endpoint using RomPatcher.js
Introduces POST /api/roms/{id}/patch that applies patch files to ROM files
server-side, enabling third-party apps to request ROM patching using games
and patches already in the library without needing client-side JS support.
- backend/utils/patcher.js: Node.js helper that loads RomPatcher.js and
applies a patch file to a ROM, writing the result to an output path
- backend/endpoints/roms/patch.py: FastAPI endpoint that looks up ROM and
patch files by ID, invokes the Node.js patcher via subprocess, and
streams the patched ROM back as a download
- Supports all 9 patch formats: IPS, UPS, BPS, PPF, RUP, APS, BDF, PMSR, VCDIFF
- Requires roms.read scope for authentication
- Temp files are cleaned up via Starlette BackgroundTask after response
https://claude.ai/code/session_01HS6ZvAiBjmLPVB3gGw8eEt
This commit is contained in:
@@ -72,6 +72,7 @@ from utils.validation import ValidationError
|
||||
from .files import router as files_router
|
||||
from .manual import router as manual_router
|
||||
from .notes import router as notes_router
|
||||
from .patch import router as patch_router
|
||||
from .upload import router as upload_router
|
||||
|
||||
router = APIRouter(
|
||||
@@ -82,6 +83,7 @@ router.include_router(upload_router)
|
||||
router.include_router(files_router)
|
||||
router.include_router(manual_router)
|
||||
router.include_router(notes_router)
|
||||
router.include_router(patch_router)
|
||||
|
||||
|
||||
def safe_int_or_none(value: Any) -> int | None:
|
||||
|
||||
197
backend/endpoints/roms/patch.py
Normal file
197
backend/endpoints/roms/patch.py
Normal file
@@ -0,0 +1,197 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import Body, HTTPException
|
||||
from fastapi import Path as PathVar
|
||||
from fastapi import Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.background import BackgroundTask
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from handler.auth.constants import Scope
|
||||
from handler.database import db_rom_handler
|
||||
from handler.filesystem import fs_rom_handler
|
||||
from logger.formatter import BLUE
|
||||
from logger.formatter import highlight as hl
|
||||
from logger.logger import log
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PATCHER_SCRIPT = Path(__file__).resolve().parent.parent / "utils" / "patcher.js"
|
||||
|
||||
SUPPORTED_PATCH_EXTENSIONS = frozenset(
|
||||
(".ips", ".ups", ".bps", ".ppf", ".rup", ".aps", ".bdf", ".pmsr", ".vcdiff")
|
||||
)
|
||||
|
||||
|
||||
class PatchRequest(BaseModel):
|
||||
patch_file_id: int = Field(
|
||||
description="ID of the patch file (RomFile) to apply."
|
||||
)
|
||||
output_file_name: str | None = Field(
|
||||
default=None,
|
||||
description="Custom output file name. If omitted, derived from ROM + patch names.",
|
||||
)
|
||||
|
||||
|
||||
class PatchResponse(BaseModel):
|
||||
message: str
|
||||
output_file_name: str
|
||||
output_file_size: int
|
||||
|
||||
|
||||
@protected_route(
|
||||
router.post,
|
||||
"/{id}/patch",
|
||||
[Scope.ROMS_READ],
|
||||
responses={
|
||||
status.HTTP_400_BAD_REQUEST: {},
|
||||
status.HTTP_404_NOT_FOUND: {},
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {},
|
||||
},
|
||||
)
|
||||
async def patch_rom(
|
||||
request: Request,
|
||||
id: Annotated[int, PathVar(description="ROM file ID (the base game file).", ge=1)],
|
||||
patch_request: PatchRequest = Body(...),
|
||||
):
|
||||
"""Apply a patch to a ROM file server-side and return the patched file.
|
||||
|
||||
Both the ROM file and the patch file must already exist in the library.
|
||||
The patched ROM is streamed back as a download.
|
||||
"""
|
||||
|
||||
current_username = (
|
||||
request.user.username if request.user.is_authenticated else "unknown"
|
||||
)
|
||||
|
||||
# Look up ROM file
|
||||
rom_file = db_rom_handler.get_rom_file_by_id(id)
|
||||
if not rom_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"ROM file with id {id} not found",
|
||||
)
|
||||
|
||||
if rom_file.missing_from_fs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"ROM file '{rom_file.file_name}' is missing from filesystem",
|
||||
)
|
||||
|
||||
# Look up patch file
|
||||
patch_file = db_rom_handler.get_rom_file_by_id(patch_request.patch_file_id)
|
||||
if not patch_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Patch file with id {patch_request.patch_file_id} not found",
|
||||
)
|
||||
|
||||
if patch_file.missing_from_fs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Patch file '{patch_file.file_name}' is missing from filesystem",
|
||||
)
|
||||
|
||||
# Validate the patch file extension
|
||||
patch_ext = Path(patch_file.file_name).suffix.lower()
|
||||
if patch_ext not in SUPPORTED_PATCH_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported patch format '{patch_ext}'. Supported: {', '.join(sorted(SUPPORTED_PATCH_EXTENSIONS))}",
|
||||
)
|
||||
|
||||
# Resolve filesystem paths
|
||||
rom_path = fs_rom_handler.validate_path(rom_file.full_path)
|
||||
patch_path = fs_rom_handler.validate_path(patch_file.full_path)
|
||||
|
||||
# Build output filename
|
||||
if patch_request.output_file_name:
|
||||
# Strip any extension from custom name and use ROM's extension
|
||||
custom_base = Path(patch_request.output_file_name).stem
|
||||
rom_ext = Path(rom_file.file_name).suffix
|
||||
output_file_name = f"{custom_base}{rom_ext}"
|
||||
else:
|
||||
rom_base = Path(rom_file.file_name).stem
|
||||
patch_base = Path(patch_file.file_name).stem
|
||||
rom_ext = Path(rom_file.file_name).suffix
|
||||
output_file_name = f"{rom_base} (patched-{patch_base}){rom_ext}"
|
||||
|
||||
log.info(
|
||||
f"User {hl(current_username, color=BLUE)} is patching "
|
||||
f"ROM file {hl(rom_file.file_name)} with patch {hl(patch_file.file_name)}"
|
||||
)
|
||||
|
||||
# Create temp directory for the patched output
|
||||
tmp_dir = tempfile.mkdtemp(prefix="romm_patch_")
|
||||
output_path = Path(tmp_dir) / output_file_name
|
||||
|
||||
try:
|
||||
# Run the Node.js patcher script
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"node",
|
||||
str(PATCHER_SCRIPT),
|
||||
str(rom_path),
|
||||
str(patch_path),
|
||||
str(output_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
error_msg = "Patching failed"
|
||||
try:
|
||||
err_data = json.loads(stderr.decode())
|
||||
error_msg = err_data.get("error", error_msg)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
if stderr:
|
||||
error_msg = stderr.decode(errors="replace").strip()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=error_msg,
|
||||
)
|
||||
|
||||
if not output_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Patcher did not produce an output file",
|
||||
)
|
||||
|
||||
output_size = output_path.stat().st_size
|
||||
|
||||
log.info(
|
||||
f"Successfully patched ROM for user {hl(current_username, color=BLUE)}: "
|
||||
f"{hl(output_file_name)} ({output_size} bytes)"
|
||||
)
|
||||
|
||||
# Return the patched file as a download, cleaning up the temp
|
||||
# directory after the response body has been sent.
|
||||
return FileResponse(
|
||||
path=str(output_path),
|
||||
filename=output_file_name,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(output_file_name)}; filename=\"{quote(output_file_name)}\"",
|
||||
"Content-Length": str(output_size),
|
||||
},
|
||||
background=BackgroundTask(shutil.rmtree, tmp_dir, True),
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
log.error(f"Patching error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Unexpected patching error: {e}",
|
||||
) from e
|
||||
94
backend/utils/patcher.js
Normal file
94
backend/utils/patcher.js
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Server-side ROM patcher helper script.
|
||||
*
|
||||
* Uses RomPatcher.js (https://github.com/marcrobledo/RomPatcher.js/) to apply
|
||||
* a patch file to a ROM file, writing the result to an output path.
|
||||
*
|
||||
* Usage:
|
||||
* node patcher.js <rom_path> <patch_path> <output_path>
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - success
|
||||
* 1 - usage / argument error
|
||||
* 2 - patching error
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Resolve RomPatcher.js from the frontend node_modules
|
||||
const ROM_PATCHER_BASE = path.resolve(
|
||||
__dirname,
|
||||
"../../frontend/node_modules/rom-patcher/rom-patcher-js"
|
||||
);
|
||||
|
||||
// Load the library (sets globals that RomPatcher.js expects)
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "BinFile.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "HashCalculator.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_gba.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.aps_n64.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bdf.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.bps.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ips.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.pmsr.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ppf.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.rup.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.ups.js"));
|
||||
require(path.join(ROM_PATCHER_BASE, "modules", "RomPatcher.format.vcdiff.js"));
|
||||
const RomPatcher = require(path.join(ROM_PATCHER_BASE, "RomPatcher.js"));
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length !== 3) {
|
||||
console.error("Usage: node patcher.js <rom_path> <patch_path> <output_path>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [romPath, patchPath, outputPath] = args;
|
||||
|
||||
try {
|
||||
// Validate input files exist
|
||||
if (!fs.existsSync(romPath)) {
|
||||
throw new Error(`ROM file not found: ${romPath}`);
|
||||
}
|
||||
if (!fs.existsSync(patchPath)) {
|
||||
throw new Error(`Patch file not found: ${patchPath}`);
|
||||
}
|
||||
|
||||
// Load files using BinFile (Node.js mode accepts file paths)
|
||||
const romFile = new BinFile(romPath);
|
||||
const patchFile = new BinFile(patchPath);
|
||||
|
||||
// Parse the patch format
|
||||
const patch = RomPatcher.parsePatchFile(patchFile);
|
||||
if (!patch) {
|
||||
throw new Error("Unsupported or invalid patch format");
|
||||
}
|
||||
|
||||
// Apply patch
|
||||
const patchedRom = RomPatcher.applyPatch(romFile, patch, {
|
||||
requireValidation: false,
|
||||
fixChecksum: false,
|
||||
outputSuffix: false,
|
||||
});
|
||||
|
||||
// Extract binary data and write to output
|
||||
const data = patchedRom._u8array || patchedRom.u8array || patchedRom.data;
|
||||
if (!data) {
|
||||
throw new Error("Failed to extract patched ROM data");
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, Buffer.from(data.buffer, data.byteOffset, data.byteLength));
|
||||
console.log(JSON.stringify({ success: true, output: outputPath, size: data.byteLength }));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ success: false, error: err.message || String(err) }));
|
||||
process.exit(2);
|
||||
}
|
||||
Reference in New Issue
Block a user