feat: add single-folder RomM import and arr helpers
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple RomM staging import worker.
|
||||
|
||||
Watches /staging/incoming/<platform_slug>/ for files, moves them through
|
||||
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.
|
||||
|
||||
This intentionally requires a platform subfolder under incoming. Example:
|
||||
/staging/incoming/ngc/Paper Mario.iso
|
||||
/staging/incoming/nes/Homebrew.nes
|
||||
|
||||
Files dropped directly into /staging/incoming are quarantined so the worker does
|
||||
not guess the wrong RomM platform.
|
||||
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
|
||||
@@ -18,6 +17,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
@@ -43,6 +43,41 @@ 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")
|
||||
@@ -73,17 +108,51 @@ def is_ready_file(path: Path) -> bool:
|
||||
def iter_incoming_files() -> list[Path]:
|
||||
if not INCOMING.exists():
|
||||
return []
|
||||
return sorted([p for p in INCOMING.rglob("*") if is_ready_file(p)])
|
||||
# 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 platform_for(path: Path) -> str | None:
|
||||
rel = path.relative_to(INCOMING)
|
||||
if len(rel.parts) < 2:
|
||||
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
|
||||
platform = rel.parts[0].strip()
|
||||
if platform in {"", ".", ".."} or "/" in platform:
|
||||
return None
|
||||
return platform
|
||||
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:
|
||||
@@ -101,7 +170,8 @@ def unique_dest(dest: Path) -> Path:
|
||||
|
||||
def write_report(name: str, data: dict) -> None:
|
||||
REPORTS.mkdir(parents=True, exist_ok=True)
|
||||
path = REPORTS / name
|
||||
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")
|
||||
|
||||
|
||||
@@ -157,9 +227,9 @@ def trigger_romm_scan() -> None:
|
||||
|
||||
|
||||
def import_file(path: Path) -> bool:
|
||||
platform = platform_for(path)
|
||||
platform, platform_reason = platform_for(path)
|
||||
if not platform:
|
||||
quarantine(path, "File was dropped directly into incoming. Put ROMs under incoming/<romm_platform_slug>/ so the worker does not guess the platform.")
|
||||
quarantine(path, f"Could not infer RomM platform from single-folder incoming mode: {platform_reason}")
|
||||
return False
|
||||
|
||||
processing_dir = PROCESSING / platform
|
||||
@@ -170,7 +240,7 @@ def import_file(path: Path) -> bool:
|
||||
|
||||
processing_path = unique_dest(processing_dir / path.name)
|
||||
shutil.move(str(path), str(processing_path))
|
||||
log(f"processing {processing_path} as platform={platform}")
|
||||
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))
|
||||
@@ -189,6 +259,7 @@ def import_file(path: Path) -> bool:
|
||||
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),
|
||||
@@ -204,7 +275,7 @@ def import_file(path: Path) -> bool:
|
||||
|
||||
def main() -> int:
|
||||
ensure_dirs()
|
||||
log(f"RomM import worker started; incoming={INCOMING}; roms={ROMM_ROMS}; poll={POLL_SECONDS}s; scan_after_import={SCAN_AFTER_IMPORT}")
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user