mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
feat: add exporter plugin architecture
This commit is contained in:
35
scripts/exporter/__init__.py
Normal file
35
scripts/exporter/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Exporter plugin discovery module.
|
||||
|
||||
Auto-detects *_exporter.py files and exposes their exporters.
|
||||
Each exporter module must define an Exporter class inheriting BaseExporter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
_exporters: dict[str, type] = {}
|
||||
|
||||
|
||||
def discover_exporters() -> dict[str, type]:
|
||||
"""Auto-discover *_exporter.py modules, return {platform: ExporterClass}."""
|
||||
if _exporters:
|
||||
return _exporters
|
||||
|
||||
package_dir = Path(__file__).parent
|
||||
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(package_dir)]):
|
||||
if not name.endswith("_exporter") or name == "base_exporter":
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f".{name}", package=__package__)
|
||||
exporter_class = getattr(module, "Exporter", None)
|
||||
|
||||
if exporter_class and issubclass(exporter_class, BaseExporter):
|
||||
_exporters[exporter_class.platform_name()] = exporter_class
|
||||
|
||||
return _exporters
|
||||
27
scripts/exporter/base_exporter.py
Normal file
27
scripts/exporter/base_exporter.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Abstract base class for platform exporters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseExporter(ABC):
|
||||
"""Base class for exporting truth data to native platform formats."""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def platform_name() -> str:
|
||||
"""Return the platform identifier this exporter targets."""
|
||||
|
||||
@abstractmethod
|
||||
def export(
|
||||
self,
|
||||
truth_data: dict,
|
||||
output_path: str,
|
||||
scraped_data: dict | None = None,
|
||||
) -> None:
|
||||
"""Export truth data to the native platform format."""
|
||||
|
||||
@abstractmethod
|
||||
def validate(self, truth_data: dict, output_path: str) -> list[str]:
|
||||
"""Validate exported file against truth data, return list of issues."""
|
||||
104
scripts/exporter/systemdat_exporter.py
Normal file
104
scripts/exporter/systemdat_exporter.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Exporter for libretro System.dat (clrmamepro DAT format)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from scraper.dat_parser import parse_dat
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
|
||||
|
||||
def _slug_to_native(slug: str) -> str:
|
||||
"""Convert a system slug to a native 'Manufacturer - Console' name."""
|
||||
parts = slug.split("-", 1)
|
||||
if len(parts) == 1:
|
||||
return parts[0].title()
|
||||
manufacturer = parts[0].replace("-", " ").title()
|
||||
console = parts[1].replace("-", " ").title()
|
||||
return f"{manufacturer} - {console}"
|
||||
|
||||
|
||||
class Exporter(BaseExporter):
|
||||
"""Export truth data to libretro System.dat format."""
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
return "retroarch"
|
||||
|
||||
def export(
|
||||
self,
|
||||
truth_data: dict,
|
||||
output_path: str,
|
||||
scraped_data: dict | None = None,
|
||||
) -> None:
|
||||
native_map: dict[str, str] = {}
|
||||
if scraped_data:
|
||||
for sys_id, sys_data in scraped_data.get("systems", {}).items():
|
||||
nid = sys_data.get("native_id")
|
||||
if nid:
|
||||
native_map[sys_id] = nid
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append('clrmamepro (')
|
||||
lines.append('\tname "System.dat"')
|
||||
lines.append(')')
|
||||
|
||||
systems = truth_data.get("systems", {})
|
||||
for sys_id in sorted(systems):
|
||||
sys_data = systems[sys_id]
|
||||
native_name = native_map.get(sys_id, _slug_to_native(sys_id))
|
||||
|
||||
for fe in sys_data.get("files", []):
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
|
||||
dest = fe.get("path", name)
|
||||
size = fe.get("size", 0)
|
||||
crc = fe.get("crc32", "")
|
||||
md5 = fe.get("md5", "")
|
||||
sha1 = fe.get("sha1", "")
|
||||
|
||||
rom_parts = [f'name "{name}"']
|
||||
rom_parts.append(f"size {size}")
|
||||
if crc:
|
||||
rom_parts.append(f"crc {crc}")
|
||||
if md5:
|
||||
rom_parts.append(f"md5 {md5}")
|
||||
if sha1:
|
||||
rom_parts.append(f"sha1 {sha1}")
|
||||
rom_str = " ".join(rom_parts)
|
||||
|
||||
game_name = f"{native_name}/{dest}"
|
||||
lines.append("")
|
||||
lines.append("game (")
|
||||
lines.append(f'\tname "{game_name}"')
|
||||
lines.append(f'\tdescription "{name}"')
|
||||
lines.append(f"\trom ( {rom_str} )")
|
||||
lines.append(")")
|
||||
|
||||
lines.append("")
|
||||
Path(output_path).write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
def validate(self, truth_data: dict, output_path: str) -> list[str]:
|
||||
content = Path(output_path).read_text(encoding="utf-8")
|
||||
parsed = parse_dat(content)
|
||||
|
||||
exported_names: set[str] = set()
|
||||
for rom in parsed:
|
||||
exported_names.add(rom.name)
|
||||
|
||||
issues: list[str] = []
|
||||
for sys_id, sys_data in truth_data.get("systems", {}).items():
|
||||
for fe in sys_data.get("files", []):
|
||||
name = fe.get("name", "")
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
if name not in exported_names:
|
||||
issues.append(f"missing: {name} (system {sys_id})")
|
||||
|
||||
return issues
|
||||
@@ -3048,5 +3048,65 @@ class TestE2E(unittest.TestCase):
|
||||
self.assertEqual(snes["native_id"], "snes")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Exporter: System.dat round-trip
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def test_systemdat_exporter_round_trip(self):
|
||||
"""Export truth data to System.dat and validate round-trip."""
|
||||
from exporter import discover_exporters
|
||||
from exporter.systemdat_exporter import Exporter as SystemDatExporter
|
||||
|
||||
truth = {
|
||||
"platform": "retroarch",
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"files": [
|
||||
{
|
||||
"name": "scph5501.bin",
|
||||
"path": "scph5501.bin",
|
||||
"size": 524288,
|
||||
"md5": "490f666e1afb15ed6c63b88fc7571f2f",
|
||||
"sha1": "b056ee5a4d65937e1a3a17e1e78f3258ea49c38e",
|
||||
"crc32": "71af80b4",
|
||||
"required": True,
|
||||
"_cores": ["beetle_psx"],
|
||||
"_source_refs": ["libretro.c:50"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
scraped = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"native_id": "Sony - PlayStation",
|
||||
"files": [
|
||||
{"name": "scph5501.bin", "destination": "scph5501.bin"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out_path = os.path.join(self.root, "System.dat")
|
||||
exporter = SystemDatExporter()
|
||||
exporter.export(truth, out_path, scraped_data=scraped)
|
||||
|
||||
with open(out_path) as fh:
|
||||
content = fh.read()
|
||||
self.assertIn("Sony - PlayStation", content)
|
||||
self.assertIn("scph5501.bin", content)
|
||||
self.assertIn("b056ee5a4d65937e1a3a17e1e78f3258ea49c38e", content)
|
||||
self.assertIn('name "System.dat"', content)
|
||||
|
||||
issues = exporter.validate(truth, out_path)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
# Discovery finds the systemdat exporter
|
||||
exporters = discover_exporters()
|
||||
self.assertIn("retroarch", exporters)
|
||||
self.assertIs(exporters["retroarch"], SystemDatExporter)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user