302 lines
11 KiB
Python
302 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Simple RomM staging import worker.
|
|
|
|
Watches a single landing folder, /staging/incoming, for ROM files, infers the
|
|
RomM platform slug when it can do so safely, moves files through
|
|
processing/verified, copies them into /romm/library/roms/<platform_slug>/, and
|
|
then triggers RomM's library scan API.
|
|
|
|
The worker intentionally quarantines files when the platform cannot be inferred
|
|
with reasonable confidence. Add mappings with IMPORT_PLATFORM_MAP_JSON if needed.
|
|
Example:
|
|
IMPORT_PLATFORM_MAP_JSON={".iso":"ngc",".rvz":"ngc"}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from urllib import error, parse, request
|
|
|
|
STAGING = Path(os.environ.get("STAGING_PATH", "/staging"))
|
|
INCOMING = STAGING / "incoming"
|
|
PROCESSING = STAGING / "processing"
|
|
VERIFIED = STAGING / "verified"
|
|
QUARANTINE = STAGING / "quarantine"
|
|
REPORTS = Path(os.environ.get("REPORTS_PATH", "/reports"))
|
|
ROMM_LIBRARY = Path(os.environ.get("ROMM_LIBRARY_CONTAINER_PATH", "/romm/library"))
|
|
ROMM_ROMS = ROMM_LIBRARY / "roms"
|
|
POLL_SECONDS = int(os.environ.get("IMPORT_POLL_SECONDS", "60"))
|
|
SCAN_AFTER_IMPORT = os.environ.get("ROMM_SCAN_AFTER_IMPORT", "true").lower() in {"1", "true", "yes", "on"}
|
|
QUIET_IDLE = os.environ.get("IMPORT_QUIET_IDLE", "true").lower() in {"1", "true", "yes", "on"}
|
|
|
|
ROMM_SERVER_URL = os.environ.get("ROMM_SERVER_URL", "").rstrip("/")
|
|
ROMM_USERNAME = os.environ.get("ROMM_USERNAME", "")
|
|
ROMM_PASSWORD = os.environ.get("ROMM_PASSWORD", "")
|
|
|
|
IGNORE_SUFFIXES = {".part", ".partial", ".tmp", ".crdownload"}
|
|
|
|
# Safe extension mappings that are normally unambiguous for RomM platform slugs.
|
|
EXTENSION_PLATFORM_MAP: dict[str, str] = {
|
|
".nes": "nes",
|
|
".fds": "fds",
|
|
".sfc": "snes",
|
|
".smc": "snes",
|
|
".gb": "gb",
|
|
".gbc": "gbc",
|
|
".gba": "gba",
|
|
".n64": "n64",
|
|
".z64": "n64",
|
|
".v64": "n64",
|
|
".ndS".lower(): "nds",
|
|
".3ds": "3ds",
|
|
".cia": "3ds",
|
|
".gcm": "ngc",
|
|
".rvz": "ngc",
|
|
".wbfs": "wii",
|
|
".wad": "wii",
|
|
}
|
|
|
|
# Extensions that require header detection or an explicit override because they
|
|
# can belong to many systems.
|
|
AMBIGUOUS_EXTENSIONS = {".iso", ".bin", ".cue", ".chd", ".zip", ".7z", ".rar"}
|
|
|
|
try:
|
|
user_map = json.loads(os.environ.get("IMPORT_PLATFORM_MAP_JSON", "{}") or "{}")
|
|
if isinstance(user_map, dict):
|
|
for ext, platform in user_map.items():
|
|
if isinstance(ext, str) and isinstance(platform, str) and ext and platform:
|
|
normalized_ext = ext.lower() if ext.startswith(".") else f".{ext.lower()}"
|
|
EXTENSION_PLATFORM_MAP[normalized_ext] = platform.strip()
|
|
except json.JSONDecodeError as exc:
|
|
print(f"WARNING: failed to parse IMPORT_PLATFORM_MAP_JSON: {exc}", flush=True)
|
|
|
|
|
|
def log(message: str) -> None:
|
|
ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
print(f"[{ts}] {message}", flush=True)
|
|
|
|
|
|
def ensure_dirs() -> None:
|
|
for path in (INCOMING, PROCESSING, VERIFIED, QUARANTINE, REPORTS, ROMM_ROMS):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def is_ready_file(path: Path) -> bool:
|
|
if not path.is_file():
|
|
return False
|
|
if path.name.startswith("."):
|
|
return False
|
|
if path.suffix.lower() in IGNORE_SUFFIXES:
|
|
return False
|
|
try:
|
|
size1 = path.stat().st_size
|
|
time.sleep(1)
|
|
size2 = path.stat().st_size
|
|
return size1 == size2 and size2 > 0
|
|
except FileNotFoundError:
|
|
return False
|
|
|
|
|
|
def iter_incoming_files() -> list[Path]:
|
|
if not INCOMING.exists():
|
|
return []
|
|
# Single landing folder mode: process files dropped directly in incoming.
|
|
# Ignore files already inside subdirectories so processing/verified layouts do
|
|
# not accidentally get re-imported if someone creates folders in incoming.
|
|
return sorted([p for p in INCOMING.iterdir() if is_ready_file(p)])
|
|
|
|
|
|
def detect_gamecube_or_wii(path: Path) -> str | None:
|
|
"""Return ngc/wii for disc images with Nintendo magic, otherwise None."""
|
|
try:
|
|
with path.open("rb") as fh:
|
|
fh.seek(0x18)
|
|
raw = fh.read(8)
|
|
if len(raw) < 8:
|
|
return None
|
|
# GameCube magic at 0x1C: 0xC2339F3D. Wii magic at 0x18: 0x5D1C9EA3.
|
|
wii_magic = struct.unpack(">I", raw[0:4])[0]
|
|
gc_magic = struct.unpack(">I", raw[4:8])[0]
|
|
if gc_magic == 0xC2339F3D:
|
|
return "ngc"
|
|
if wii_magic == 0x5D1C9EA3:
|
|
return "wii"
|
|
except OSError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def platform_for(path: Path) -> tuple[str | None, str]:
|
|
suffix = path.suffix.lower()
|
|
|
|
detected = None
|
|
if suffix in {".iso", ".gcm", ".rvz", ".wbfs"}:
|
|
detected = detect_gamecube_or_wii(path)
|
|
if detected:
|
|
return detected, f"Nintendo disc image header detected as {detected}"
|
|
|
|
if suffix in EXTENSION_PLATFORM_MAP:
|
|
platform = EXTENSION_PLATFORM_MAP[suffix]
|
|
if suffix in AMBIGUOUS_EXTENSIONS:
|
|
return platform, f"explicit/import env extension override {suffix}->{platform}"
|
|
return platform, f"extension mapping {suffix}->{platform}"
|
|
|
|
if suffix in AMBIGUOUS_EXTENSIONS:
|
|
return None, f"ambiguous extension {suffix}; add IMPORT_PLATFORM_MAP_JSON override or use a less ambiguous file format"
|
|
|
|
return None, f"unknown extension {suffix or '<none>'}; add IMPORT_PLATFORM_MAP_JSON override if this is expected"
|
|
|
|
|
|
def unique_dest(dest: Path) -> Path:
|
|
if not dest.exists():
|
|
return dest
|
|
stem = dest.stem
|
|
suffix = dest.suffix
|
|
parent = dest.parent
|
|
for i in range(1, 1000):
|
|
candidate = parent / f"{stem} ({i}){suffix}"
|
|
if not candidate.exists():
|
|
return candidate
|
|
raise RuntimeError(f"could not find unique destination for {dest}")
|
|
|
|
|
|
def write_report(name: str, data: dict) -> None:
|
|
REPORTS.mkdir(parents=True, exist_ok=True)
|
|
safe_name = "".join(c if c.isalnum() or c in "-. _" else "_" for c in name)
|
|
path = REPORTS / safe_name
|
|
path.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
|
|
|
|
|
def quarantine(path: Path, reason: str) -> None:
|
|
dest = unique_dest(QUARANTINE / path.name)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(path), str(dest))
|
|
report = {
|
|
"time_utc": datetime.now(timezone.utc).isoformat(),
|
|
"source": str(path),
|
|
"destination": str(dest),
|
|
"reason": reason,
|
|
}
|
|
write_report(f"quarantine-{int(time.time())}-{dest.name}.json", report)
|
|
log(f"quarantined {path} -> {dest}: {reason}")
|
|
|
|
|
|
def romm_token() -> str:
|
|
if not (ROMM_SERVER_URL and ROMM_USERNAME and ROMM_PASSWORD):
|
|
raise RuntimeError("ROMM_SERVER_URL, ROMM_USERNAME, and ROMM_PASSWORD are required for scan trigger")
|
|
data = parse.urlencode({"username": ROMM_USERNAME, "password": ROMM_PASSWORD}).encode()
|
|
req = request.Request(
|
|
f"{ROMM_SERVER_URL}/api/token",
|
|
data=data,
|
|
method="POST",
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
with request.urlopen(req, timeout=20) as resp:
|
|
body = json.loads(resp.read().decode("utf-8"))
|
|
token = body.get("access_token") or body.get("token")
|
|
if not token:
|
|
raise RuntimeError(f"RomM token response did not include access_token/token: {body}")
|
|
return token
|
|
|
|
|
|
def trigger_romm_scan() -> None:
|
|
if not SCAN_AFTER_IMPORT:
|
|
return
|
|
token = romm_token()
|
|
req = request.Request(
|
|
f"{ROMM_SERVER_URL}/api/tasks/run/scan_library",
|
|
data=b"{}",
|
|
method="POST",
|
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with request.urlopen(req, timeout=30) as resp:
|
|
body = resp.read().decode("utf-8", errors="replace")
|
|
log(f"triggered RomM scan_library: HTTP {resp.status} {body[:300]}")
|
|
except error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"RomM scan trigger failed: HTTP {exc.code} {body}") from exc
|
|
|
|
|
|
def import_file(path: Path) -> bool:
|
|
platform, platform_reason = platform_for(path)
|
|
if not platform:
|
|
quarantine(path, f"Could not infer RomM platform from single-folder incoming mode: {platform_reason}")
|
|
return False
|
|
|
|
processing_dir = PROCESSING / platform
|
|
verified_dir = VERIFIED / platform
|
|
romm_dir = ROMM_ROMS / platform
|
|
for d in (processing_dir, verified_dir, romm_dir):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
|
|
processing_path = unique_dest(processing_dir / path.name)
|
|
shutil.move(str(path), str(processing_path))
|
|
log(f"processing {processing_path} as platform={platform} ({platform_reason})")
|
|
|
|
verified_path = unique_dest(verified_dir / processing_path.name)
|
|
shutil.copy2(str(processing_path), str(verified_path))
|
|
|
|
romm_path = romm_dir / processing_path.name
|
|
if romm_path.exists():
|
|
log(f"RomM destination already exists, skipping library copy: {romm_path}")
|
|
imported = False
|
|
else:
|
|
shutil.copy2(str(processing_path), str(romm_path))
|
|
imported = True
|
|
log(f"copied into RomM library: {romm_path}")
|
|
|
|
processing_path.unlink(missing_ok=True)
|
|
|
|
report = {
|
|
"time_utc": datetime.now(timezone.utc).isoformat(),
|
|
"platform": platform,
|
|
"platform_reason": platform_reason,
|
|
"incoming": str(path),
|
|
"verified": str(verified_path),
|
|
"romm_path": str(romm_path),
|
|
"imported": imported,
|
|
"validation": "staging-flow-only; add DAT-backed Igir validation before trusting unknown files",
|
|
}
|
|
write_report(f"import-{int(time.time())}-{romm_path.name}.json", report)
|
|
|
|
if imported:
|
|
trigger_romm_scan()
|
|
return imported
|
|
|
|
|
|
def main() -> int:
|
|
ensure_dirs()
|
|
log(f"RomM import worker started; single-folder incoming={INCOMING}; roms={ROMM_ROMS}; poll={POLL_SECONDS}s; scan_after_import={SCAN_AFTER_IMPORT}")
|
|
while True:
|
|
try:
|
|
files = iter_incoming_files()
|
|
if not files and not QUIET_IDLE:
|
|
log("no incoming files")
|
|
for path in files:
|
|
try:
|
|
import_file(path)
|
|
except Exception as exc: # keep worker alive, quarantine when possible
|
|
log(f"ERROR processing {path}: {exc}")
|
|
traceback.print_exc()
|
|
if path.exists() and path.is_file():
|
|
try:
|
|
quarantine(path, f"worker error: {exc}")
|
|
except Exception:
|
|
traceback.print_exc()
|
|
except Exception:
|
|
traceback.print_exc()
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|