rom-manager 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rom_manager/__init__.py +29 -0
- rom_manager/__main__.py +4 -0
- rom_manager/adapters/__init__.py +22 -0
- rom_manager/adapters/api_client.py +147 -0
- rom_manager/agent_data/IDENTITY.md +21 -0
- rom_manager/agent_server.py +96 -0
- rom_manager/api_client.py +22 -0
- rom_manager/archives.py +24 -0
- rom_manager/auth.py +47 -0
- rom_manager/conversion.py +15 -0
- rom_manager/core/__init__.py +12 -0
- rom_manager/core/archives.py +112 -0
- rom_manager/core/conversion.py +143 -0
- rom_manager/core/naming.py +59 -0
- rom_manager/game_codes.py +3390 -0
- rom_manager/main_agent.json +13 -0
- rom_manager/mcp/__init__.py +13 -0
- rom_manager/mcp/mcp_conversion.py +57 -0
- rom_manager/mcp/mcp_game_codes.py +53 -0
- rom_manager/mcp_config.json +3 -0
- rom_manager/mcp_server.py +124 -0
- rom_manager/models/__init__.py +24 -0
- rom_manager/models/schemas.py +78 -0
- rom_manager/naming.py +14 -0
- rom_manager/prompts/__init__.py +19 -0
- rom_manager/prompts/convert_rom.md +22 -0
- rom_manager/rom_manager.py +468 -0
- rom_manager/version.py +5 -0
- rom_manager-1.0.0.dist-info/METADATA +372 -0
- rom_manager-1.0.0.dist-info/RECORD +34 -0
- rom_manager-1.0.0.dist-info/WHEEL +5 -0
- rom_manager-1.0.0.dist-info/entry_points.txt +4 -0
- rom_manager-1.0.0.dist-info/licenses/LICENSE +21 -0
- rom_manager-1.0.0.dist-info/top_level.txt +1 -0
rom_manager/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""rom-manager — ROM converter/organizer + MCP Server + A2A Agent.
|
|
3
|
+
|
|
4
|
+
Convert game ROMs to Compressed Hunks of Data (CHD) or RVZ, extract archives,
|
|
5
|
+
auto-rename via known game codes, and generate cue sheets. The core
|
|
6
|
+
:class:`RomManager` pipeline is wrapped by an MCP server and a Pydantic-AI agent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from rom_manager.game_codes import psx_codes
|
|
10
|
+
from rom_manager.rom_manager import (
|
|
11
|
+
RomManager,
|
|
12
|
+
get_directory_size,
|
|
13
|
+
get_operating_system,
|
|
14
|
+
main,
|
|
15
|
+
rom_manager,
|
|
16
|
+
)
|
|
17
|
+
from rom_manager.version import __author__, __credits__, __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"psx_codes",
|
|
21
|
+
"rom_manager",
|
|
22
|
+
"main",
|
|
23
|
+
"RomManager",
|
|
24
|
+
"get_operating_system",
|
|
25
|
+
"get_directory_size",
|
|
26
|
+
"__version__",
|
|
27
|
+
"__author__",
|
|
28
|
+
"__credits__",
|
|
29
|
+
]
|
rom_manager/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Adapter layer for ROM Manager.
|
|
2
|
+
|
|
3
|
+
CONCEPT:ROM-001, CONCEPT:ROM-002 — adapts the core pipeline for tool/agent
|
|
4
|
+
consumption. Houses the :class:`~rom_manager.adapters.api_client.Api` facade that
|
|
5
|
+
the MCP tools and the ``auth.get_client`` factory depend on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rom_manager.adapters.api_client import (
|
|
9
|
+
Api,
|
|
10
|
+
RomManager,
|
|
11
|
+
get_directory_size,
|
|
12
|
+
get_operating_system,
|
|
13
|
+
psx_codes,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Api",
|
|
18
|
+
"RomManager",
|
|
19
|
+
"psx_codes",
|
|
20
|
+
"get_operating_system",
|
|
21
|
+
"get_directory_size",
|
|
22
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""Local facade over the real ROM conversion pipeline.
|
|
3
|
+
|
|
4
|
+
``rom-manager`` is not a REST API; this module is an **honest local facade**. It
|
|
5
|
+
re-exports the real callables from :mod:`rom_manager.rom_manager` and exposes a
|
|
6
|
+
thin :class:`Api` class that the MCP tools and the local ``auth.get_client``
|
|
7
|
+
factory depend on. Every method delegates to the genuine :class:`RomManager`
|
|
8
|
+
pipeline so behaviour is preserved exactly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from agent_utilities.base_utilities import get_logger
|
|
16
|
+
|
|
17
|
+
from rom_manager.game_codes import psx_codes
|
|
18
|
+
from rom_manager.rom_manager import (
|
|
19
|
+
RomManager,
|
|
20
|
+
get_directory_size,
|
|
21
|
+
get_operating_system,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Api",
|
|
28
|
+
"RomManager",
|
|
29
|
+
"psx_codes",
|
|
30
|
+
"get_operating_system",
|
|
31
|
+
"get_directory_size",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Api:
|
|
36
|
+
"""Thin facade wrapping :class:`RomManager` for tool/agent consumption."""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("directory", "iso_type", "verbose", "force")
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
directory: str = os.path.curdir,
|
|
43
|
+
iso_type: str = "chd",
|
|
44
|
+
verbose: bool = False,
|
|
45
|
+
force: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Hold conversion defaults for the facade (CONCEPT:ROM-001)."""
|
|
48
|
+
self.directory = directory
|
|
49
|
+
self.iso_type = iso_type
|
|
50
|
+
self.verbose = verbose
|
|
51
|
+
self.force = force
|
|
52
|
+
|
|
53
|
+
def _build_manager(
|
|
54
|
+
self,
|
|
55
|
+
directory: str | None = None,
|
|
56
|
+
iso_type: str | None = None,
|
|
57
|
+
verbose: bool | None = None,
|
|
58
|
+
force: bool | None = None,
|
|
59
|
+
clean_origin_files: bool = False,
|
|
60
|
+
) -> RomManager:
|
|
61
|
+
manager = RomManager()
|
|
62
|
+
manager.directory = directory if directory is not None else self.directory
|
|
63
|
+
manager.iso_type = iso_type if iso_type is not None else self.iso_type
|
|
64
|
+
manager.verbose = verbose if verbose is not None else self.verbose
|
|
65
|
+
manager.force = force if force is not None else self.force
|
|
66
|
+
manager.clean_origin_files = clean_origin_files
|
|
67
|
+
return manager
|
|
68
|
+
|
|
69
|
+
# --- Conversion domain -------------------------------------------------
|
|
70
|
+
def convert(
|
|
71
|
+
self,
|
|
72
|
+
directory: str | None = None,
|
|
73
|
+
cpu_count: int | None = None,
|
|
74
|
+
iso_type: str | None = None,
|
|
75
|
+
verbose: bool | None = None,
|
|
76
|
+
force: bool | None = None,
|
|
77
|
+
clean_origin_files: bool = False,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Run the full parallel conversion pipeline over a directory (CONCEPT:ROM-001)."""
|
|
80
|
+
manager = self._build_manager(
|
|
81
|
+
directory=directory,
|
|
82
|
+
iso_type=iso_type,
|
|
83
|
+
verbose=verbose,
|
|
84
|
+
force=force,
|
|
85
|
+
clean_origin_files=clean_origin_files,
|
|
86
|
+
)
|
|
87
|
+
before = get_directory_size(directory=manager.directory)
|
|
88
|
+
results = manager.process_parallel(cpu_count=cpu_count)
|
|
89
|
+
after = get_directory_size(directory=manager.directory)
|
|
90
|
+
return {
|
|
91
|
+
"directory": manager.directory,
|
|
92
|
+
"iso_type": manager.iso_type,
|
|
93
|
+
"files_processed": len(results) if results else 0,
|
|
94
|
+
"size_before_gb": round(before[3], 4),
|
|
95
|
+
"size_after_gb": round(after[3], 4),
|
|
96
|
+
"storage_delta_gb": round(before[3] - after[3], 4),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# alias — convert an entire directory tree
|
|
100
|
+
process_directory = convert
|
|
101
|
+
|
|
102
|
+
def process_file(
|
|
103
|
+
self,
|
|
104
|
+
file: str,
|
|
105
|
+
iso_type: str | None = None,
|
|
106
|
+
verbose: bool | None = None,
|
|
107
|
+
force: bool | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Process a single ROM file through the conversion pipeline (CONCEPT:ROM-001)."""
|
|
110
|
+
manager = self._build_manager(iso_type=iso_type, verbose=verbose, force=force)
|
|
111
|
+
manager.process_file(
|
|
112
|
+
file=file,
|
|
113
|
+
logger_name=manager.logger_name,
|
|
114
|
+
logger_level=logging.WARNING,
|
|
115
|
+
logger_format=manager.logger_format,
|
|
116
|
+
)
|
|
117
|
+
return {"file": file, "status": "processed"}
|
|
118
|
+
|
|
119
|
+
def generate_cue(self, directory: str) -> dict[str, Any]:
|
|
120
|
+
"""Generate a missing ``.cue`` sheet for ``.bin`` tracks in a directory."""
|
|
121
|
+
manager = self._build_manager(directory=directory)
|
|
122
|
+
cue_path = manager.cue_file_generator(directory=directory)
|
|
123
|
+
return {"directory": directory, "cue_file": cue_path}
|
|
124
|
+
|
|
125
|
+
def list_files(self, directory: str, extensions: list[str] | None = None):
|
|
126
|
+
"""List ROM/archive files under a directory matching extensions."""
|
|
127
|
+
manager = self._build_manager(directory=directory)
|
|
128
|
+
exts = tuple(extensions) if extensions else manager.supported_extensions
|
|
129
|
+
return manager.get_files(directory=directory, extensions=exts)
|
|
130
|
+
|
|
131
|
+
# --- Game codes domain -------------------------------------------------
|
|
132
|
+
@staticmethod
|
|
133
|
+
def lookup_game_code(code: str) -> dict[str, Any]:
|
|
134
|
+
"""Look up a single game code -> name in the PSX code registry."""
|
|
135
|
+
return {"code": code, "name": psx_codes.get(code)}
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def list_game_codes(prefix: str | None = None) -> dict[str, str]:
|
|
139
|
+
"""List known game codes, optionally filtered by a code prefix."""
|
|
140
|
+
if prefix:
|
|
141
|
+
return {k: v for k, v in psx_codes.items() if k.startswith(prefix)}
|
|
142
|
+
return dict(psx_codes)
|
|
143
|
+
|
|
144
|
+
def rename_by_game_code(self, file: str) -> dict[str, Any]:
|
|
145
|
+
"""Rename a file in-place using the embedded game code, if recognised."""
|
|
146
|
+
renamed = RomManager.map_game_code_name(file=file, logger=logger)
|
|
147
|
+
return {"original": file, "renamed": renamed, "changed": renamed != file}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# IDENTITY.md - ROM Manager Agent Identity
|
|
2
|
+
|
|
3
|
+
## [default]
|
|
4
|
+
* **Name:** ROM Manager
|
|
5
|
+
* **Role:** Agent for converting and organizing game ROMs (CHD/RVZ)
|
|
6
|
+
* **Emoji:** 👾
|
|
7
|
+
|
|
8
|
+
### System Prompt
|
|
9
|
+
You are the ROM Manager Agent.
|
|
10
|
+
You must always first run `list_skills` to show all skills.
|
|
11
|
+
Then, use the `mcp-client` universal skill and check the reference documentation for `rom-manager.md` to discover the exact tags and tools available for your capabilities.
|
|
12
|
+
|
|
13
|
+
### Capabilities
|
|
14
|
+
- **ROM Conversion**: Convert game ROMs to CHD (`chdman`) or RVZ (`dolphin-tool`), extract archives, and generate `.cue` sheets via the `conversion` tool domain (CONCEPT:ROM-001).
|
|
15
|
+
- **Game Codes / Naming**: Resolve game/serial codes to titles and rename ROM files using the embedded code registry via the `game-codes` tool domain (CONCEPT:ROM-002).
|
|
16
|
+
- **MCP Operations**: Leverage the `mcp-client` skill to interact with the target MCP server. Refer to `rom-manager.md` for specific tool capabilities.
|
|
17
|
+
- **Custom Agent**: Handle custom tasks or general tasks.
|
|
18
|
+
|
|
19
|
+
### Safety
|
|
20
|
+
- Always recommend backing up ROMs before running destructive (`clean_origin_files`/delete) operations.
|
|
21
|
+
- Verify the external `chdman` / `dolphin-tool` binaries are installed before attempting conversions.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/python
|
|
2
|
+
"""ROM Manager A2A agent server.
|
|
3
|
+
|
|
4
|
+
CONCEPT:ROM-001, CONCEPT:ROM-002 — exposes the ROM conversion and game-codes
|
|
5
|
+
tool domains over an A2A/AG-UI surface. The agent is driven by the
|
|
6
|
+
agent-utilities router, the integration point for the cross-project bridge
|
|
7
|
+
capabilities CONCEPT:ORCH-1.2 (Confidence-Gated Router), CONCEPT:OS-5.2
|
|
8
|
+
(Cognitive Scheduler) and CONCEPT:KG-2.0 (Knowledge Graph Core ingestion of the
|
|
9
|
+
tool surface).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import warnings
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
logging.basicConfig(
|
|
20
|
+
level=logging.INFO,
|
|
21
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
22
|
+
handlers=[logging.StreamHandler()],
|
|
23
|
+
)
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_AGENT_NAME = None
|
|
28
|
+
DEFAULT_AGENT_DESCRIPTION = None
|
|
29
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def agent_server():
|
|
33
|
+
"""Launch the ROM Manager A2A agent (CONCEPT:ROM-001, CONCEPT:ORCH-1.2)."""
|
|
34
|
+
from agent_utilities import (
|
|
35
|
+
build_system_prompt_from_workspace,
|
|
36
|
+
create_agent_parser,
|
|
37
|
+
create_agent_server,
|
|
38
|
+
initialize_workspace,
|
|
39
|
+
load_identity,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
global DEFAULT_AGENT_NAME, DEFAULT_AGENT_DESCRIPTION, DEFAULT_AGENT_SYSTEM_PROMPT
|
|
43
|
+
initialize_workspace()
|
|
44
|
+
meta = load_identity()
|
|
45
|
+
DEFAULT_AGENT_NAME = os.getenv(
|
|
46
|
+
"DEFAULT_AGENT_NAME", meta.get("name", "ROM Manager")
|
|
47
|
+
)
|
|
48
|
+
DEFAULT_AGENT_DESCRIPTION = os.getenv(
|
|
49
|
+
"AGENT_DESCRIPTION",
|
|
50
|
+
meta.get(
|
|
51
|
+
"description",
|
|
52
|
+
"AI agent for converting and organizing game ROMs (CHD/RVZ).",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
DEFAULT_AGENT_SYSTEM_PROMPT = os.getenv(
|
|
56
|
+
"AGENT_SYSTEM_PROMPT",
|
|
57
|
+
meta.get("content") or build_system_prompt_from_workspace(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
|
|
61
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
|
|
62
|
+
|
|
63
|
+
print(f"{DEFAULT_AGENT_NAME} v{__version__}", file=sys.stderr)
|
|
64
|
+
parser = create_agent_parser()
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
|
|
67
|
+
if args.debug:
|
|
68
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
69
|
+
logger.debug("Debug mode enabled")
|
|
70
|
+
|
|
71
|
+
# Start server using the auto-discovery pattern (from mcp_config.json)
|
|
72
|
+
create_agent_server(
|
|
73
|
+
mcp_url=args.mcp_url,
|
|
74
|
+
mcp_config=args.mcp_config or "mcp_config.json",
|
|
75
|
+
host=args.host,
|
|
76
|
+
port=args.port,
|
|
77
|
+
provider=args.provider,
|
|
78
|
+
model_id=args.model_id,
|
|
79
|
+
router_model=args.model_id,
|
|
80
|
+
agent_model=args.model_id,
|
|
81
|
+
base_url=args.base_url,
|
|
82
|
+
api_key=args.api_key,
|
|
83
|
+
custom_skills_directory=args.custom_skills_directory,
|
|
84
|
+
enable_web_ui=args.web,
|
|
85
|
+
enable_otel=args.otel,
|
|
86
|
+
otel_endpoint=args.otel_endpoint,
|
|
87
|
+
otel_headers=args.otel_headers,
|
|
88
|
+
otel_public_key=args.otel_public_key,
|
|
89
|
+
otel_secret_key=args.otel_secret_key,
|
|
90
|
+
otel_protocol=args.otel_protocol,
|
|
91
|
+
debug=args.debug,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
agent_server()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Backwards-compatible shim re-exporting the API facade adapter.
|
|
2
|
+
|
|
3
|
+
The real implementation now lives in :mod:`rom_manager.adapters.api_client` (the
|
|
4
|
+
adapter layer). This shim preserves the historical ``rom_manager.api_client``
|
|
5
|
+
import path used by ``auth.get_client`` and the MCP tools. CONCEPT:ROM-001.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rom_manager.adapters.api_client import (
|
|
9
|
+
Api,
|
|
10
|
+
RomManager,
|
|
11
|
+
get_directory_size,
|
|
12
|
+
get_operating_system,
|
|
13
|
+
psx_codes,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Api",
|
|
18
|
+
"RomManager",
|
|
19
|
+
"psx_codes",
|
|
20
|
+
"get_operating_system",
|
|
21
|
+
"get_directory_size",
|
|
22
|
+
]
|
rom_manager/archives.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Backwards-compatible shim re-exporting the archives domain module.
|
|
2
|
+
|
|
3
|
+
The real implementation now lives in :mod:`rom_manager.core.archives` (the
|
|
4
|
+
domain layer). This shim preserves the historical ``rom_manager.archives``
|
|
5
|
+
import path. CONCEPT:ROM-001.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rom_manager.core.archives import (
|
|
9
|
+
ARCHIVE_FORMATS,
|
|
10
|
+
cue_file_generator,
|
|
11
|
+
extract_archive,
|
|
12
|
+
get_files,
|
|
13
|
+
is_archive,
|
|
14
|
+
pad_leading_zero,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ARCHIVE_FORMATS",
|
|
19
|
+
"cue_file_generator",
|
|
20
|
+
"extract_archive",
|
|
21
|
+
"get_files",
|
|
22
|
+
"is_archive",
|
|
23
|
+
"pad_leading_zero",
|
|
24
|
+
]
|
rom_manager/auth.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""ROM Manager local configuration / client factory.
|
|
2
|
+
|
|
3
|
+
Unlike network-backed connectors (e.g. ``gitlab-api``), ``rom-manager`` is a
|
|
4
|
+
**local CLI tool**: it extracts archives, renames ROMs via known game codes, and
|
|
5
|
+
converts ISO/WBFS images to CHD/RVZ using locally-installed binaries
|
|
6
|
+
(``chdman`` / ``dolphin-tool``). There is therefore **no service URL and no
|
|
7
|
+
required credentials**.
|
|
8
|
+
|
|
9
|
+
This module mirrors the ecosystem ``auth.py`` shape (a ``get_client`` factory
|
|
10
|
+
the MCP tools depend on) but degrades to a no-op/local configuration. The only
|
|
11
|
+
recognised setting is the optional ``ROM_DIRECTORY`` environment variable, used
|
|
12
|
+
as the default working directory when none is supplied.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from agent_utilities.base_utilities import get_logger, to_boolean
|
|
18
|
+
|
|
19
|
+
from rom_manager.api_client import Api
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_client(
|
|
25
|
+
directory: str | None = None,
|
|
26
|
+
iso_type: str = os.getenv("ROM_ISO_TYPE", "chd"),
|
|
27
|
+
verbose: bool = to_boolean(string=os.getenv("ROM_VERBOSE", "False")),
|
|
28
|
+
force: bool = to_boolean(string=os.getenv("ROM_FORCE", "False")),
|
|
29
|
+
config: dict | None = None,
|
|
30
|
+
) -> Api:
|
|
31
|
+
"""Factory returning the local ROM Manager :class:`Api` facade (CONCEPT:ROM-001).
|
|
32
|
+
|
|
33
|
+
No credentials are required. ``directory`` defaults to ``ROM_DIRECTORY`` (or
|
|
34
|
+
the current working directory). ``iso_type`` selects the conversion target
|
|
35
|
+
(``chd`` or ``rvz``).
|
|
36
|
+
"""
|
|
37
|
+
resolved_directory = directory or os.getenv("ROM_DIRECTORY") or os.path.curdir
|
|
38
|
+
logger.info(
|
|
39
|
+
"Creating local ROM Manager client",
|
|
40
|
+
extra={"directory": resolved_directory, "iso_type": iso_type},
|
|
41
|
+
)
|
|
42
|
+
return Api(
|
|
43
|
+
directory=resolved_directory,
|
|
44
|
+
iso_type=iso_type if iso_type in ("chd", "rvz") else "chd",
|
|
45
|
+
verbose=verbose,
|
|
46
|
+
force=force,
|
|
47
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Backwards-compatible shim re-exporting the conversion domain module.
|
|
2
|
+
|
|
3
|
+
The real implementation now lives in :mod:`rom_manager.core.conversion` (the
|
|
4
|
+
domain layer). This shim preserves the historical ``rom_manager.conversion``
|
|
5
|
+
import path. CONCEPT:ROM-001.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rom_manager.core.conversion import (
|
|
9
|
+
Converter,
|
|
10
|
+
Runner,
|
|
11
|
+
build_convert_command,
|
|
12
|
+
run_command,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Converter", "Runner", "build_convert_command", "run_command"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Domain (core) layer for ROM Manager.
|
|
2
|
+
|
|
3
|
+
CONCEPT:ROM-001, CONCEPT:ROM-002 — the pure pipeline responsibilities, free of
|
|
4
|
+
transport/CLI concerns: archive handling, conversion command construction, and
|
|
5
|
+
game-code naming. The top-level orchestrator (:class:`rom_manager.rom_manager.
|
|
6
|
+
RomManager`) composes these. Thin re-export shims at the package root
|
|
7
|
+
(``rom_manager.archives`` etc.) preserve the historical import paths.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from rom_manager.core import archives, conversion, naming
|
|
11
|
+
|
|
12
|
+
__all__ = ["archives", "conversion", "naming"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Archive extraction and ``.cue`` sheet generation.
|
|
3
|
+
|
|
4
|
+
CONCEPT:ROM-001 — ROM Conversion (pre-processing stage). Owns the archive
|
|
5
|
+
responsibilities split out of the former ``rom_manager`` god-module: detecting
|
|
6
|
+
supported archive formats, extracting them (via the optional ``patool`` native
|
|
7
|
+
extra), and synthesising missing ``.cue`` sheets from ``.bin`` tracks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import glob
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
# Supported archive container formats (extraction inputs).
|
|
15
|
+
ARCHIVE_FORMATS: tuple[str, ...] = (
|
|
16
|
+
".7z",
|
|
17
|
+
".zip",
|
|
18
|
+
".tar.gz",
|
|
19
|
+
".gz",
|
|
20
|
+
".gzip",
|
|
21
|
+
".bz2",
|
|
22
|
+
".bzip2",
|
|
23
|
+
".rar",
|
|
24
|
+
".tar",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_NULL_LOGGER = logging.getLogger("rom_manager.archives")
|
|
28
|
+
_NULL_LOGGER.addHandler(logging.NullHandler())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_archive(file: str) -> bool:
|
|
32
|
+
"""Return ``True`` if ``file`` has a supported archive extension."""
|
|
33
|
+
return file.lower().endswith(ARCHIVE_FORMATS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_files(directory, extensions) -> list[str]:
|
|
37
|
+
"""Recursively list files under ``directory`` matching ``extensions``."""
|
|
38
|
+
matching_files = []
|
|
39
|
+
for root, _dirs, files in os.walk(directory):
|
|
40
|
+
for file in files:
|
|
41
|
+
if any(file.endswith(ext) for ext in extensions):
|
|
42
|
+
matching_files.append(os.path.join(root, file))
|
|
43
|
+
return matching_files
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pad_leading_zero(number) -> str:
|
|
47
|
+
"""Zero-pad ``number`` to a two-character track index (e.g. ``2`` -> ``02``)."""
|
|
48
|
+
padded = "0" + str(number)
|
|
49
|
+
return padded[-2:]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cue_file_generator(directory, logger=None) -> str:
|
|
53
|
+
"""Generate a ``.cue`` sheet from the ``.bin`` tracks in ``directory``.
|
|
54
|
+
|
|
55
|
+
CONCEPT:ROM-001 — emits a standard CD cue sheet (track 01 ``MODE2/2352``,
|
|
56
|
+
subsequent tracks ``AUDIO``) so ``chdman`` can ingest multi-track ``.bin``
|
|
57
|
+
images. Returns the path to the (existing or newly written) cue file.
|
|
58
|
+
"""
|
|
59
|
+
file_names = get_files(directory=directory, extensions=[".bin"])
|
|
60
|
+
first_file = file_names.pop(0)
|
|
61
|
+
first_file = os.path.basename(first_file)
|
|
62
|
+
sheet = (
|
|
63
|
+
f'FILE "{first_file}" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n'
|
|
64
|
+
)
|
|
65
|
+
track_counter = 2
|
|
66
|
+
for file_name in file_names:
|
|
67
|
+
sheet += (
|
|
68
|
+
f'FILE "{file_name}" BINARY\n'
|
|
69
|
+
f" TRACK {pad_leading_zero(track_counter)} AUDIO\n"
|
|
70
|
+
f" INDEX 00 00:00:00\n"
|
|
71
|
+
f" INDEX 01 00:02:00\n"
|
|
72
|
+
)
|
|
73
|
+
track_counter += 1
|
|
74
|
+
cue_file_path = os.path.join(
|
|
75
|
+
directory, f"{os.path.splitext(os.path.basename(first_file))[0]}.cue"
|
|
76
|
+
)
|
|
77
|
+
if not os.path.exists(cue_file_path):
|
|
78
|
+
with open(cue_file_path, "w") as cue_file:
|
|
79
|
+
cue_file.write(sheet)
|
|
80
|
+
return cue_file_path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_archive(archive, archive_directory, verbose=False, logger=None) -> None:
|
|
84
|
+
"""Extract ``archive`` into ``archive_directory`` and backfill cue sheets.
|
|
85
|
+
|
|
86
|
+
CONCEPT:ROM-001 — the archive pre-processing stage of the conversion
|
|
87
|
+
pipeline. ``patool`` is imported lazily so the core package installs without
|
|
88
|
+
native extras; install with ``rom-manager[native]`` for extraction.
|
|
89
|
+
"""
|
|
90
|
+
log = logger or _NULL_LOGGER
|
|
91
|
+
try:
|
|
92
|
+
import patoolib as patool
|
|
93
|
+
except ImportError as e: # pragma: no cover - optional native dependency
|
|
94
|
+
raise ImportError(
|
|
95
|
+
"Archive extraction requires the 'patool' library. "
|
|
96
|
+
"Install the native extras with: pip install 'rom-manager[native]'"
|
|
97
|
+
) from e
|
|
98
|
+
|
|
99
|
+
log.info(f"Extracting {archive} to {archive_directory}...")
|
|
100
|
+
verbosity = 1 if verbose else -1
|
|
101
|
+
try:
|
|
102
|
+
patool.extract_archive(archive, outdir=archive_directory, verbosity=verbosity)
|
|
103
|
+
except patool.util.PatoolError as e:
|
|
104
|
+
log.info(f"Unable to extract: {archive}\nError: {e}")
|
|
105
|
+
|
|
106
|
+
log.info(f"Finished extracting {archive} to {archive_directory}")
|
|
107
|
+
log.info("Generating any missing cue file(s)")
|
|
108
|
+
if glob.glob(os.path.join(str(archive_directory), "*.bin")) and not glob.glob(
|
|
109
|
+
os.path.join(str(archive_directory), "*.cue")
|
|
110
|
+
):
|
|
111
|
+
cue_file_generator(archive_directory, logger=log)
|
|
112
|
+
log.info("Finished generating missing cue file(s)")
|