mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-18 14:52:32 -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")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user