Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Final, NotRequired, TypedDict

Expand All @@ -28,6 +29,117 @@
from logger.formatter import highlight as hl
from logger.logger import log

# Terminal macros for a custom library structure template. Exactly one must
# appear, as the last path segment.
STRUCTURE_GAME_FILE: Final = "gameFile"
STRUCTURE_GAME_DIR: Final = "gameDir"
# Macros that belong to Retrom's full-path templates but are resolved by RomM
# itself (library base + roms folder + platform discovery). A per-platform
# template is relative to the platform's ROM folder, so these are rejected with
# a helpful message instead of being silently treated as wildcard levels.
_STRUCTURE_RESERVED_NONTERMINAL: Final = frozenset({"platform", "library"})


@dataclass(frozen=True)
class StructureLevel:
"""One intermediate directory level of a custom structure template.

``literal`` is the exact folder name to match, or ``None`` for a wildcard
macro level (``{region}``, ``{category}``, …) that matches any folder.
"""

literal: str | None


@dataclass(frozen=True)
class LibraryStructure:
"""A parsed per-platform custom library structure.

Relative to the platform's ROM folder: ``levels`` are the intermediate
directory levels to descend (literal or wildcard), and ``each_file_is_game``
selects the terminal — ``{gameFile}`` (each file is a ROM) vs ``{gameDir}``
(each directory is a multi-file ROM).
"""

levels: tuple[StructureLevel, ...]
each_file_is_game: bool


def parse_library_structure(template: str) -> LibraryStructure:
"""Parse a custom structure template into a ``LibraryStructure``.

Template syntax mirrors Retrom: ``/``-separated path sections where a
section wrapped in braces is a macro and a bare section is a literal folder
name. The last section must be the terminal ``{gameFile}`` or ``{gameDir}``;
every other braced section is a wildcard directory level. The template is
relative to the platform's ROM folder, so ``{platform}`` / ``{library}`` are
not used here.

Raises ``ValueError`` on an invalid template.
"""
sections = [s for s in template.split("/") if s != ""]
if not sections:
raise ValueError("template is empty")

levels: list[StructureLevel] = []
each_file_is_game: bool | None = None

for index, section in enumerate(sections):
is_last = index == len(sections) - 1
is_macro = section.startswith("{") and section.endswith("}")

if not is_macro:
if "{" in section or "}" in section:
raise ValueError(f"malformed macro in section '{section}'")
levels.append(StructureLevel(literal=section))
continue

name = section[1:-1].strip()
if name in (STRUCTURE_GAME_FILE, STRUCTURE_GAME_DIR):
if not is_last:
raise ValueError(
f"'{{{name}}}' must be the last path section of the template"
)
each_file_is_game = name == STRUCTURE_GAME_FILE
continue

if name in _STRUCTURE_RESERVED_NONTERMINAL:
raise ValueError(
f"'{{{name}}}' is not supported: a platform template is relative "
"to that platform's ROM folder (RomM resolves library root, roms "
"folder and platform on its own)"
)
if not name:
raise ValueError("empty macro '{}'")
# Any other braced section is an organizational wildcard directory level.
levels.append(StructureLevel(literal=None))

if each_file_is_game is None:
raise ValueError("template must end with '{gameFile}' or '{gameDir}'")

return LibraryStructure(levels=tuple(levels), each_file_is_game=each_file_is_game)


def parse_platform_structures(
value: str | list[str],
) -> tuple[LibraryStructure, ...]:
"""Parse a platform's custom structure config into one or more structures.

A platform may declare a single template (string) or several (list) — the
list form lets one platform mix layouts, e.g. loose games at the root plus
games inside grouping subfolders::

nes:
- "{gameFile}"
- "{category}/{gameFile}"

Discovery is the union of all listed templates. Raises ``ValueError`` if any
template is invalid.
"""
templates = [value] if isinstance(value, str) else list(value)
return tuple(parse_library_structure(template) for template in templates)


ROMM_USER_CONFIG_PATH: Final = f"{ROMM_BASE_PATH}/config"
ROMM_USER_CONFIG_FILE: Final = f"{ROMM_USER_CONFIG_PATH}/config.yml"
SQLITE_DB_BASE_PATH: Final = f"{ROMM_BASE_PATH}/database"
Expand Down Expand Up @@ -119,6 +231,7 @@
PLATFORMS_VERSIONS: dict[str, str]
ROMS_FOLDER_NAME: str
FIRMWARE_FOLDER_NAME: str
STRUCTURE_TEMPLATES: dict[str, str | list[str]]
SKIP_HASH_CALCULATION: bool
EJS_DEBUG: bool
EJS_CACHE_LIMIT: int | None
Expand Down Expand Up @@ -161,6 +274,21 @@

return False

def platform_structure(self, fs_slug: str) -> tuple[LibraryStructure, ...] | None:
"""The custom library structure(s) for a platform, or ``None``.

Opt-in per platform via `filesystem.structure` in config.yml (a map of
``fs_slug -> template`` or ``fs_slug -> [template, ...]``). When unset,
the platform uses RomM's default discovery (top-level files and
folders). Returns the parsed structures whose discovery is unioned;
templates are validated at load time, so parsing here is expected to
succeed.
"""
value = getattr(self, "STRUCTURE_TEMPLATES", {}).get(fs_slug)
if not value:
return None
return parse_platform_structures(value)


class ConfigManager:
"""
Expand Down Expand Up @@ -447,6 +575,9 @@
PEGASUS_AUTO_EXPORT_ON_SCAN=pydash.get(
self._raw_config, "scan.pegasus.export", False
),
STRUCTURE_TEMPLATES=pydash.get(
self._raw_config, "filesystem.structure", {}
),
)

def _get_ejs_controls(self) -> dict[str, EjsControls]:
Expand Down Expand Up @@ -661,6 +792,33 @@
log.critical("Invalid config.yml: scan.media must be a list")
sys.exit(3)

if not isinstance(self.config.STRUCTURE_TEMPLATES, dict):
log.critical(
"Invalid config.yml: filesystem.structure must be a dictionary"
)
sys.exit(3)
for fs_slug, value in self.config.STRUCTURE_TEMPLATES.items():
is_str = isinstance(value, str)
is_str_list = isinstance(value, list) and all(
isinstance(t, str) for t in value
)
if not (is_str or is_str_list):
log.critical(
f"Invalid config.yml: filesystem.structure.{fs_slug} must be a "
"template string or a list of template strings"
)
sys.exit(3)
templates = [value] if is_str else value
for template in templates:
try:
parse_library_structure(template)

Check failure on line 814 in backend/config/config_manager.py

View workflow job for this annotation

GitHub Actions / Trunk Check

mypy(arg-type)

[new] Argument 1 to "parse_library_structure" has incompatible type "str | list[str]"; expected "str"
except ValueError as exc:
log.critical(
f"Invalid config.yml: filesystem.structure.{fs_slug} "
f"('{template}'): {exc}"
)
sys.exit(3)

# Drop unknown media types rather than exiting, since a newer release
# may ship sample configs referencing media types this version doesn't know.
unknown_media = [
Expand Down Expand Up @@ -754,6 +912,7 @@
"filesystem": {
"roms_folder": self.config.ROMS_FOLDER_NAME,
"firmware_folder": self.config.FIRMWARE_FOLDER_NAME,
"structure": self.config.STRUCTURE_TEMPLATES,
"skip_hash_calculation": self.config.SKIP_HASH_CALCULATION,
},
"system": {
Expand Down
Loading
Loading