diff --git a/backend/alembic/versions/0091_unique_platform_fs_name.py b/backend/alembic/versions/0091_unique_platform_fs_name.py index e2f320471d..b7d8a3f985 100644 --- a/backend/alembic/versions/0091_unique_platform_fs_name.py +++ b/backend/alembic/versions/0091_unique_platform_fs_name.py @@ -1,7 +1,8 @@ -"""Enforce unique (platform_id, fs_name) on roms +"""Enforce unique (platform_id, fs_path, fs_name) on roms -This migration removes any pre-existing duplicates (keeping the lowest id) and -upgrades the index to unique so the duplicate can never be created again. +This migration removes any pre-existing full-path duplicates (keeping the +lowest id) and upgrades the index to unique so the duplicate can never be +created again. Revision ID: 0091_unique_platform_fs_name Revises: 0090_roms_sibling_cover_index @@ -18,27 +19,29 @@ branch_labels = None depends_on = None -INDEX_NAME = "idx_roms_platform_id_fs_name" -INDEX_COLUMNS = ["platform_id", "fs_name"] +OLD_INDEX_NAME = "idx_roms_platform_id_fs_name" +INDEX_NAME = "idx_roms_platform_id_fs_path_fs_name" +INDEX_COLUMNS = ["platform_id", "fs_path", "fs_name"] def upgrade() -> None: connection = op.get_bind() - # Drop duplicate roms sharing (platform_id, fs_name), keeping the lowest id. + # Drop duplicate roms sharing (platform_id, fs_path, fs_name), keeping the + # lowest id. connection.execute(sa.text(""" DELETE FROM roms WHERE id NOT IN ( SELECT keep_id FROM ( SELECT MIN(id) AS keep_id FROM roms - GROUP BY platform_id, fs_name + GROUP BY platform_id, fs_path, fs_name ) AS keepers ) """)) with op.batch_alter_table("roms", schema=None) as batch_op: - batch_op.drop_index(INDEX_NAME, if_exists=True) + batch_op.drop_index(OLD_INDEX_NAME, if_exists=True) batch_op.create_index( INDEX_NAME, INDEX_COLUMNS, @@ -51,8 +54,8 @@ def downgrade() -> None: with op.batch_alter_table("roms", schema=None) as batch_op: batch_op.drop_index(INDEX_NAME, if_exists=True) batch_op.create_index( - INDEX_NAME, - INDEX_COLUMNS, + OLD_INDEX_NAME, + ["platform_id", "fs_name"], unique=False, if_not_exists=True, ) diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 87b6d30a45..9b19d65f1a 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -4,6 +4,7 @@ import json import os import sys +from dataclasses import dataclass from pathlib import Path from typing import Final, NotRequired, TypedDict @@ -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" @@ -185,6 +297,7 @@ class Config: 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 @@ -231,6 +344,21 @@ def has_structure_path_b(self) -> bool: 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: """ @@ -536,6 +664,9 @@ def _parse_config(self): PEGASUS_AUTO_EXPORT_ON_SCAN=pydash.get( self._raw_config, "scan.pegasus.export", False ), + STRUCTURE_TEMPLATES=pydash.get( + self._raw_config, "filesystem.structure", {} + ), STREAMING_ENABLED=pydash.get(self._raw_config, "streaming.enabled", False), STREAMING_CONTAINERS=pydash.get( self._raw_config, "streaming.containers", [] @@ -782,6 +913,33 @@ def _validate_config(self): 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(): + if isinstance(value, str): + templates = [value] + elif isinstance(value, list) and all(isinstance(t, str) for t in value): + templates = value + else: + log.critical( + f"Invalid config.yml: filesystem.structure.{fs_slug} must be a " + "template string or a list of template strings" + ) + sys.exit(3) + + for template in templates: + try: + parse_library_structure(template) + 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 = [ @@ -873,6 +1031,7 @@ def _update_config_file(self) -> None: "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": { diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 899ea838a9..65fb8e9557 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -327,7 +327,9 @@ async def _identify_rom( # Update properties that don't require metadata parsed_tags = fs_rom_handler.parse_tags(fs_rom["fs_name"]) - roms_path = fs_rom_handler.get_roms_fs_structure(platform.fs_slug) + # The discovered path is the rom's actual directory, which may be nested + # under the platform roms folder when a custom structure is configured. + roms_path = fs_rom["fs_path"] rom_attrs = { "fs_name": fs_rom["fs_name"], @@ -714,7 +716,9 @@ async def _identify_platform( # moved ROM (a new file with no fs_name match) can be reassociated by hash # with its now-missing entry instead of spawning a duplicate. The end-of-scan # call below re-syncs and logs, unmarking any entry that got reassociated. - db_rom_handler.mark_missing_roms(platform.id, [rom["fs_name"] for rom in fs_roms]) + db_rom_handler.mark_missing_roms( + platform.id, [f"{rom['fs_path']}/{rom['fs_name']}" for rom in fs_roms] + ) # Create semaphore to limit concurrent ROM scanning scan_semaphore = asyncio.Semaphore(SCAN_WORKERS) @@ -736,7 +740,10 @@ async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None: ) for fs_roms_batch in batched(fs_roms, 200, strict=False): - roms_by_fs_name = db_rom_handler.get_roms_by_fs_name( + # Key matches on the rom's full path (fs_path/fs_name), not just the + # file name, so identically-named files in different folders don't + # collide under a custom library structure. + roms_by_full_path = db_rom_handler.get_roms_by_fs_name( platform_id=platform.id, fs_names={fs_rom["fs_name"] for fs_rom in fs_roms_batch}, ) @@ -747,7 +754,7 @@ async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None: roms_to_scan: list[tuple[FSRom, Rom | None]] = [] for fs_rom in fs_roms_batch: - rom = roms_by_fs_name.get(fs_rom["fs_name"]) + rom = roms_by_full_path.get(f"{fs_rom['fs_path']}/{fs_rom['fs_name']}") if should_scan_rom( scan_type=scan_type, rom=rom, @@ -807,12 +814,27 @@ async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None: log.error(f"Error scanning ROM {fs_rom['fs_name']}: {result}") missing_roms = db_rom_handler.mark_missing_roms( - platform.id, [rom["fs_name"] for rom in fs_roms] + platform.id, [f"{rom['fs_path']}/{rom['fs_name']}" for rom in fs_roms] ) if len(missing_roms) > 0: log.warning(f"{hl('Missing')} roms from filesystem:") + # A folder a custom structure now descends into used to be a single + # multi-file rom; that old entry shows up here as missing. Flag those so + # it's clear the "missing" is expected and the stale entry can be + # deleted. A superseded folder's path is a parent of a discovered rom. + descended_paths = {rom["fs_path"] for rom in fs_roms} for r in missing_roms: - log.warning(f" - {r.fs_name}") + superseded = any( + p == r.full_path or p.startswith(f"{r.full_path}/") + for p in descended_paths + ) + if superseded: + log.warning( + f" - {r.fs_name} (now scanned as a folder of roms — " + "delete this stale entry to clean up)" + ) + else: + log.warning(f" - {r.fs_name}") missing_firmware = db_firmware_handler.mark_missing_firmware( platform.id, [fw for fw in fs_firmware] diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index a6528571fb..819df7450d 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1709,7 +1709,11 @@ def get_roms_by_fs_name( fs_names: Iterable[str], session: Session = None, # type: ignore ) -> dict[str, Rom]: - """Retrieve a dictionary of roms by their filesystem names. + """Retrieve a dictionary of roms keyed by their full path (fs_path/fs_name). + + Filters by file name for an indexed lookup, but keys the result on the + full path so identically-named files in different folders (custom + library structures) remain distinct. Eager-loads only `platform` (used downstream by the scan loop via `rom.platform_slug` / `rom.platform.fs_slug`). This deliberately @@ -1733,7 +1737,7 @@ def get_roms_by_fs_name( .all() ) - return {rom.fs_name: rom for rom in roms} + return {rom.full_path: rom for rom in roms} @begin_session def update_rom( @@ -1863,19 +1867,23 @@ def mark_missing_roms( ) -> Sequence[Rom]: """Sync `missing_from_fs` for a platform against the keep-list. + The keep-list holds rom full paths (fs_path/fs_name) so that + identically-named files in different folders are tracked + independently under a custom library structure. + Reads the rows once and writes only those whose state actually changes, so a re-scan of an unchanged platform issues no updates. """ keep_set = set(fs_roms_to_keep) rows = session.execute( - select(Rom.id, Rom.fs_name, Rom.missing_from_fs).where( + select(Rom.id, Rom.fs_path, Rom.fs_name, Rom.missing_from_fs).where( Rom.platform_id == platform_id ) ).all() flips: dict[bool, list[int]] = {True: [], False: []} - for rom_id, fs_name, was_missing in rows: - is_missing = fs_name not in keep_set + for rom_id, fs_path, fs_name, was_missing in rows: + is_missing = f"{fs_path}/{fs_name}" not in keep_set if is_missing != was_missing: flips[is_missing].append(rom_id) @@ -1890,8 +1898,12 @@ def mark_missing_roms( return ( session.scalars( + # `fs_path` is eager-loaded alongside `fs_name` so callers can + # read `rom.full_path` after the session closes (the returned + # instances are detached); without it that lazy-loads and raises + # DetachedInstanceError. select(Rom) - .options(load_only(Rom.id, Rom.fs_name)) + .options(load_only(Rom.id, Rom.fs_name, Rom.fs_path)) .where( and_( Rom.platform_id == platform_id, diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 1b2946a0ff..cb648ffeb9 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -15,6 +15,7 @@ from config.config_manager import ( DEFAULT_EXCLUDED_EXTENSIONS, DEFAULT_EXCLUDED_FILES, + LibraryStructure, ) from config.config_manager import config_manager as cm from exceptions.fs_exceptions import ( @@ -90,6 +91,7 @@ class FSRom(TypedDict): fs_name: str + fs_path: str flat: bool nested: bool files: list[RomFile] @@ -329,10 +331,11 @@ async def get_rom_files( from adapters.services.rahasher import RAHasherService from handler.metadata import meta_ra_handler - rel_roms_path = self.get_roms_fs_structure( - rom.platform.fs_slug - ) # Relative path to roms - abs_fs_path = self.validate_path(rel_roms_path) # Absolute path to roms + # The rom's stored directory is the source of truth for its location, so + # roms inside a nested folder (custom library structure) resolve to their + # real path rather than the platform roms root. + rel_roms_path = rom.fs_path # Relative path to the rom's directory + abs_fs_path = self.validate_path(rel_roms_path) # Absolute path to that dir rom_files: list[RomFile] = [] # Skip hashing games for platforms that don't have a hash database or when hashes are disabled @@ -544,9 +547,14 @@ def _hash_raw_archive(crc: int) -> int: ) elif hashable_platform: try: - crc_c, _, md5_h, _, sha1_h, _ = await asyncio.to_thread( - self._calculate_rom_hashes, - Path(abs_fs_path, rom.fs_name), + crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = ( + await asyncio.to_thread( + self._calculate_rom_hashes, + Path(abs_fs_path, rom.fs_name), + rom_crc_c, + rom_md5_h, + rom_sha1_h, + ) ) except zlib.error: crc_c = 0 @@ -684,21 +692,110 @@ def update_hashes(chunk: bytes | bytearray): rom_sha1_h, ) + async def _discover_default_roms(self, rel_roms_path: str) -> list[dict]: + """Default discovery: top-level files and folders of the roms path. + + Each top-level file is a flat rom; each top-level directory is a single + multi-file rom. This is RomM's behavior for platforms without a custom + structure template. + """ + fs_roms: list[dict] = [ + {"fs_name": rom, "fs_path": rel_roms_path, "flat": True, "nested": False} + for rom in self.exclude_single_files(await self.list_files(rel_roms_path)) + ] + fs_roms += [ + {"fs_name": rom, "fs_path": rel_roms_path, "flat": False, "nested": True} + for rom in self.exclude_multi_roms( + await self.list_directories(rel_roms_path) + ) + ] + return fs_roms + + async def _discover_structured_roms( + self, rel_roms_path: str, structure: LibraryStructure + ) -> list[dict]: + """Discover roms following a custom library structure template. + + Descends the template's intermediate directory levels (literal names + matched exactly, wildcard macros matching any folder), then collects + roms at the terminal: ``{gameFile}`` makes each file a rom, ``{gameDir}`` + makes each directory a (multi-file) rom. Each rom records its real + ``fs_path``. Hidden (dot-prefixed) folders are never descended into or + surfaced. + """ + dirs = [rel_roms_path] + for level in structure.levels: + next_dirs: list[str] = [] + for directory in dirs: + for sub in await self.list_directories(directory): + if sub.startswith("."): + continue + if level.literal is not None and sub != level.literal: + continue + next_dirs.append(f"{directory}/{sub}") + dirs = next_dirs + + fs_roms: list[dict] = [] + for directory in dirs: + if structure.each_file_is_game: + for name in self.exclude_single_files(await self.list_files(directory)): + fs_roms.append( + { + "fs_name": name, + "fs_path": directory, + "flat": True, + "nested": False, + } + ) + else: + for name in self.exclude_multi_roms( + await self.list_directories(directory) + ): + if name.startswith("."): + continue + fs_roms.append( + { + "fs_name": name, + "fs_path": directory, + "flat": False, + "nested": True, + } + ) + return fs_roms + + async def _collect_fs_roms(self, platform: Platform) -> list[dict]: + """Discover a platform's roms, honoring its custom structure if set. + + A platform may declare several structure templates (e.g. loose games at + the root plus games inside grouping subfolders); discovery is their + union, deduplicated by full path so overlapping templates don't surface + a rom twice. + """ + rel_roms_path = self.get_roms_fs_structure(platform.fs_slug) + structures = cm.get_config().platform_structure(platform.fs_slug) + if structures is None: + return await self._discover_default_roms(rel_roms_path) + + fs_roms: list[dict] = [] + seen: set[tuple[str, str]] = set() + for structure in structures: + for rom in await self._discover_structured_roms(rel_roms_path, structure): + key = (rom["fs_path"], rom["fs_name"]) + if key in seen: + continue + seen.add(key) + fs_roms.append(rom) + return fs_roms + async def count_roms(self, platform: Platform) -> int: """Return the number of filesystem roms for a platform without materializing FSRom objects. """ try: - rel_roms_path = self.get_roms_fs_structure(platform.fs_slug) - fs_single_roms = await self.list_files(path=rel_roms_path) - fs_multi_roms = await self.list_directories(path=rel_roms_path) + return len(await self._collect_fs_roms(platform)) except FileNotFoundError as e: raise RomsNotFoundException(platform=platform.fs_slug) from e - return len(self.exclude_single_files(fs_single_roms)) + len( - self.exclude_multi_roms(fs_multi_roms) - ) - async def get_roms(self, platform: Platform) -> list[FSRom]: """Gets all filesystem roms for a platform @@ -708,27 +805,15 @@ async def get_roms(self, platform: Platform) -> list[FSRom]: list with all the filesystem roms for a platform """ try: - rel_roms_path = self.get_roms_fs_structure( - platform.fs_slug - ) # Relative path to roms - - fs_single_roms = await self.list_files(path=rel_roms_path) - fs_multi_roms = await self.list_directories(path=rel_roms_path) + fs_roms = await self._collect_fs_roms(platform) except FileNotFoundError as e: raise RomsNotFoundException(platform=platform.fs_slug) from e - fs_roms: list[dict] = [ - {"fs_name": rom, "flat": True, "nested": False} - for rom in self.exclude_single_files(fs_single_roms) - ] + [ - {"fs_name": rom, "flat": False, "nested": True} - for rom in self.exclude_multi_roms(fs_multi_roms) - ] - return sorted( [ FSRom( fs_name=rom["fs_name"], + fs_path=rom["fs_path"], flat=rom["flat"], nested=rom["nested"], files=[], @@ -739,7 +824,7 @@ async def get_roms(self, platform: Platform) -> list[FSRom]: ) for rom in fs_roms ], - key=lambda rom: rom["fs_name"], + key=lambda rom: (rom["fs_path"], rom["fs_name"]), ) async def rename_fs_rom(self, old_name: str, new_name: str, fs_path: str) -> None: diff --git a/backend/models/rom.py b/backend/models/rom.py index ec4681b30a..396d72e30d 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -335,8 +335,15 @@ class Rom(BaseModel): libretro_id: Mapped[str | None] = mapped_column(String(length=64), default=None) __table_args__ = ( - # Enforce unique fs name per platform to avoid duplicates - Index("idx_roms_platform_id_fs_name", "platform_id", "fs_name", unique=True), + # Enforce unique full path per platform to avoid duplicate ROM entries + # while allowing custom library subfolders to contain the same file name. + Index( + "idx_roms_platform_id_fs_path_fs_name", + "platform_id", + "fs_path", + "fs_name", + unique=True, + ), # Covers the sibling_roms view self-join and the group_by_meta_id dedup # window. Both read only these columns, so the index has to carry every # one of them: a single missing column (flashpoint_id or fs_name_no_ext, diff --git a/backend/tests/config/fixtures/config/config.yml b/backend/tests/config/fixtures/config/config.yml index f4949a8a40..af49b42125 100644 --- a/backend/tests/config/fixtures/config/config.yml +++ b/backend/tests/config/fixtures/config/config.yml @@ -30,6 +30,11 @@ filesystem: roms_folder: "ROMS" firmware_folder: "BIOS" skip_hash_calculation: true + structure: + psx: "{category}/{gameDir}" + nes: + - "{gameFile}" + - "{category}/{gameFile}" scan: priority: diff --git a/backend/tests/config/test_config_loader.py b/backend/tests/config/test_config_loader.py index 746542a33d..d754bc2c9d 100644 --- a/backend/tests/config/test_config_loader.py +++ b/backend/tests/config/test_config_loader.py @@ -1,11 +1,15 @@ import os from pathlib import Path +import pytest + from config.config_manager import ( DEFAULT_EXCLUDED_DIRS, DEFAULT_EXCLUDED_EXTENSIONS, DEFAULT_EXCLUDED_FILES, ConfigManager, + parse_library_structure, + parse_platform_structures, ) @@ -84,6 +88,22 @@ def test_config_loader(): assert loader.config.SCAN_LANGUAGE_PRIORITY == ["jp", "es"] assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box3d" assert loader.config.GAMELIST_MEDIA_IMAGE == "title_screen" + assert loader.config.STRUCTURE_TEMPLATES == { + "psx": "{category}/{gameDir}", + "nes": ["{gameFile}", "{category}/{gameFile}"], + } + # The accessor parses templates on demand; unset platforms get None. + psx = loader.config.platform_structure("psx") + assert psx is not None and len(psx) == 1 + assert psx[0].each_file_is_game is False + assert len(psx[0].levels) == 1 and psx[0].levels[0].literal is None + # The list form yields one structure per template (union on discovery). + nes = loader.config.platform_structure("nes") + assert nes is not None and len(nes) == 2 + assert nes[0].each_file_is_game is True and nes[0].levels == () + assert nes[1].each_file_is_game is True + assert len(nes[1].levels) == 1 and nes[1].levels[0].literal is None + assert loader.config.platform_structure("snes") is None def test_scan_priority_sources_match_metadata_source_enum(): @@ -129,6 +149,59 @@ def test_empty_config_loader(): assert loader.config.SCAN_REGION_MODE == "prefer_rom_tags" assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box2d" assert loader.config.GAMELIST_MEDIA_IMAGE == "screenshot" + assert loader.config.STRUCTURE_TEMPLATES == {} + + +@pytest.mark.parametrize( + ("template", "levels", "each_file_is_game"), + [ + ("{gameFile}", (), True), + ("{gameDir}", (), False), + ("{category}/{gameFile}", (None,), True), + ("Hacks/{gameFile}", ("Hacks",), True), + ("{region}/{system}/{gameDir}", (None, None), False), + ("roms/{region}/{gameFile}", ("roms", None), True), + ], +) +def test_parse_library_structure_valid(template, levels, each_file_is_game): + structure = parse_library_structure(template) + assert structure.each_file_is_game is each_file_is_game + assert tuple(level.literal for level in structure.levels) == levels + + +@pytest.mark.parametrize( + "template", + [ + "", + "justliteral", + "{category}", # no terminal + "{gameFile}/{gameDir}", # terminal not last + "{gameDir}/extra", # terminal not last + "{platform}/{gameFile}", # reserved macro RomM resolves itself + "{library}/{gameFile}", + "{}/{gameFile}", # empty macro + ], +) +def test_parse_library_structure_invalid(template): + with pytest.raises(ValueError): + parse_library_structure(template) + + +def test_parse_platform_structures_string_and_list(): + # A bare string yields a single structure. + single = parse_platform_structures("{gameFile}") + assert len(single) == 1 and single[0].each_file_is_game is True + + # A list yields one structure per template, preserving order. + multi = parse_platform_structures(["{gameFile}", "{category}/{gameDir}"]) + assert len(multi) == 2 + assert multi[0].levels == () and multi[0].each_file_is_game is True + assert len(multi[1].levels) == 1 and multi[1].each_file_is_game is False + + +def test_parse_platform_structures_propagates_invalid(): + with pytest.raises(ValueError): + parse_platform_structures(["{gameFile}", "nope"]) def test_missing_config_file_is_created(tmp_path): diff --git a/backend/tests/endpoints/sockets/test_scan.py b/backend/tests/endpoints/sockets/test_scan.py index f52430f61c..9827a70e80 100644 --- a/backend/tests/endpoints/sockets/test_scan.py +++ b/backend/tests/endpoints/sockets/test_scan.py @@ -595,6 +595,7 @@ def _platform(self): async def _run(self, db): fs_rom: FSRom = { "fs_name": "New Name.zip", + "fs_path": "test/roms/Hacks", "flat": True, "nested": False, "files": [], @@ -635,6 +636,7 @@ async def test_reassociates_with_missing_entry(self, patched): assert rom_id == 42 assert data["missing_from_fs"] is False assert data["fs_name"] == "New Name.zip" + assert data["fs_path"] == "test/roms/Hacks" # No brand-new row is inserted; add_rom only persists the scan result. assert db.add_rom.call_count == 1 @@ -717,6 +719,7 @@ async def test_mark_missing_runs_before_identify(self, mocker): ) fs_rom: FSRom = { "fs_name": "New Name.zip", + "fs_path": "test/roms/Hacks", "flat": True, "nested": False, "files": [], @@ -797,6 +800,7 @@ def patched(self, mocker): fs_rom: FSRom = { "fs_name": "Game.zip", + "fs_path": "test/roms/Hacks", "flat": True, "nested": False, "files": [], @@ -813,7 +817,7 @@ def patched(self, mocker): rom.id = 42 db_rom = mocker.patch.object(scan_module, "db_rom_handler") - db_rom.get_roms_by_fs_name.return_value = {"Game.zip": rom} + db_rom.get_roms_by_fs_name.return_value = {"test/roms/Hacks/Game.zip": rom} db_rom.mark_missing_roms.return_value = [] db_rom.get_rom.return_value = rom diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index dc981d413d..065a351829 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -436,6 +436,223 @@ async def test_get_roms(self, handler: FSRomsHandler, platform, config): # Check excluded files are not present assert "excluded_test.tmp" not in rom_names + def _make_structure_config(self, templates: dict[str, str | list[str]]) -> Config: + return Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=["tmp"], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=["@eaDir"], + EXCLUDED_MULTI_PARTS_EXT=["tmp"], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + STRUCTURE_TEMPLATES=templates, + ) + + def _build_structure_library(self, tmp_path: Path, base: str) -> None: + """Library exercising literal/wildcard levels, file-vs-folder terminals, + a name collision across folders, a hidden folder, and depth > 1.""" + roms = tmp_path / base + roms.mkdir(parents=True) + (roms / "Top.zip").write_text("t") + hacks = roms / "Hacks" + hacks.mkdir() + (hacks / "Shared.zip").write_text("a") + (hacks / "HackOnly.zip").write_text("b") + inner = hacks / "Inner" + inner.mkdir() + (inner / "x.bin").write_text("x") + trans = roms / "Translations" + trans.mkdir() + (trans / "Shared.zip").write_text("c") # collides with Hacks/Shared.zip + hidden = roms / ".hidden" + hidden.mkdir() + (hidden / "secret.zip").write_text("s") + region = roms / "Region" + region.mkdir() + usa = region / "USA" + usa.mkdir() + (usa / "usagame.zip").write_text("u") + + @pytest.mark.asyncio + async def test_get_roms_no_structure_is_default( + self, platform: Platform, tmp_path: Path + ): + """Without a template, only top-level files and folders are surfaced + (each file a flat rom, each folder a single multi-file rom).""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config({}), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + assert (base, "Top.zip") in keys + assert (base, "Hacks") in keys + assert (base, "Translations") in keys + assert (base, "Region") in keys + assert (base, ".hidden") in keys + # Nothing nested is surfaced. + assert all(r["fs_path"] == base for r in roms) + assert len(roms) == 5 + assert count == len(roms) + + @pytest.mark.asyncio + async def test_get_roms_structure_wildcard_file_terminal( + self, platform: Platform, tmp_path: Path + ): + """`{category}/{gameFile}` descends one wildcard level and treats each + file as a rom; hidden folders are skipped and identically-named files in + different folders stay distinct via their full path.""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config( + {platform.fs_slug: "{category}/{gameFile}"} + ), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + assert (f"{base}/Hacks", "Shared.zip") in keys + assert (f"{base}/Hacks", "HackOnly.zip") in keys + assert (f"{base}/Translations", "Shared.zip") in keys # collision kept + # Top-level file isn't surfaced (template requires one level down). + assert (base, "Top.zip") not in keys + # A folder at the terminal level isn't a {gameFile} rom. + assert (f"{base}/Hacks", "Inner") not in keys + # Hidden folder is never descended into. + assert not any(r["fs_path"].endswith("/.hidden") for r in roms) + full_paths = [f"{r['fs_path']}/{r['fs_name']}" for r in roms] + assert len(full_paths) == len(set(full_paths)) + assert sum(1 for r in roms if r["fs_name"] == "Shared.zip") == 2 + assert count == len(roms) + + @pytest.mark.asyncio + async def test_get_roms_structure_wildcard_dir_terminal( + self, platform: Platform, tmp_path: Path + ): + """`{category}/{gameDir}` treats each folder at the terminal level as a + single multi-file rom (kept whole — no disc-splitting guesswork).""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config( + {platform.fs_slug: "{category}/{gameDir}"} + ), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + # Folders at the terminal level become whole multi-file roms... + assert (f"{base}/Hacks", "Inner") in keys + assert (f"{base}/Region", "USA") in keys + # ...and are not split into their contents. + assert (f"{base}/Hacks/Inner", "x.bin") not in keys + # Files at the terminal level are not {gameDir} roms. + assert (f"{base}/Hacks", "Shared.zip") not in keys + assert all(r["nested"] for r in roms) + assert count == len(roms) + + @pytest.mark.asyncio + async def test_get_roms_structure_two_wildcard_levels( + self, platform: Platform, tmp_path: Path + ): + """Multiple wildcard levels descend depth-first to the terminal.""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config( + {platform.fs_slug: "{region}/{system}/{gameFile}"} + ), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + assert (f"{base}/Hacks/Inner", "x.bin") in keys + assert (f"{base}/Region/USA", "usagame.zip") in keys + # Depth-1 files are not surfaced at a depth-2 terminal. + assert (f"{base}/Hacks", "Shared.zip") not in keys + assert count == len(roms) + + @pytest.mark.asyncio + async def test_get_roms_structure_literal_level( + self, platform: Platform, tmp_path: Path + ): + """A literal section matches that exact folder only; sibling folders are + ignored.""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config({platform.fs_slug: "Hacks/{gameFile}"}), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + assert (f"{base}/Hacks", "Shared.zip") in keys + assert (f"{base}/Hacks", "HackOnly.zip") in keys + # The literal only matched "Hacks"; "Translations" is ignored. + assert (f"{base}/Translations", "Shared.zip") not in keys + assert all(r["fs_path"] == f"{base}/Hacks" for r in roms) + assert count == len(roms) + + @pytest.mark.asyncio + async def test_get_roms_structure_list_unions_loose_and_grouped( + self, platform: Platform, tmp_path: Path + ): + """A list of templates unions their discovery: loose top-level games + (`{gameFile}`) plus games inside grouping subfolders + (`{category}/{gameFile}`) — the common mixed layout — without dropping + either, deduplicated by full path.""" + handler = FSRomsHandler() + handler.base_path = tmp_path + with patch( + "handler.filesystem.roms_handler.cm.get_config", + lambda: self._make_structure_config( + {platform.fs_slug: ["{gameFile}", "{category}/{gameFile}"]} + ), + ): + base = handler.get_roms_fs_structure(platform.fs_slug) + self._build_structure_library(tmp_path, base) + roms = await handler.get_roms(platform) + count = await handler.count_roms(platform) + + keys = {(r["fs_path"], r["fs_name"]) for r in roms} + # Loose top-level game ({gameFile}). + assert (base, "Top.zip") in keys + # Grouped games one level down ({category}/{gameFile}). + assert (f"{base}/Hacks", "Shared.zip") in keys + assert (f"{base}/Hacks", "HackOnly.zip") in keys + assert (f"{base}/Translations", "Shared.zip") in keys + # Hidden folder still skipped; no duplicate full paths. + assert not any(r["fs_path"].endswith("/.hidden") for r in roms) + full_paths = [f"{r['fs_path']}/{r['fs_name']}" for r in roms] + assert len(full_paths) == len(set(full_paths)) + assert count == len(roms) + @pytest.mark.asyncio async def test_get_rom_files_single_rom( self, handler: FSRomsHandler, rom_single, config diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 00b91b78c3..3b9862b735 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone import pytest +from sqlalchemy import inspect as sa_inspect from sqlalchemy.exc import IntegrityError from config import ROMM_DB_DRIVER @@ -616,6 +617,38 @@ def test_bulk_mark_present_chunking(platform: Platform): assert updated.missing_from_fs is False +def test_get_roms_by_fs_name_keys_on_full_path(platform: Platform): + """Identically-named files in different folders must stay distinct: the + result is keyed on full path (fs_path/fs_name), not just the file name.""" + root = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name="dup_root", + slug="dup-root", + fs_name="Game.zip", + fs_path=f"{platform.slug}/roms", + ) + ) + nested = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name="dup_nested", + slug="dup-nested", + fs_name="Game.zip", + fs_path=f"{platform.slug}/roms/Hacks", + ) + ) + + result = db_rom_handler.get_roms_by_fs_name(platform.id, ["Game.zip"]) + + assert set(result) == { + f"{platform.slug}/roms/Game.zip", + f"{platform.slug}/roms/Hacks/Game.zip", + } + assert result[f"{platform.slug}/roms/Game.zip"].id == root.id + assert result[f"{platform.slug}/roms/Hacks/Game.zip"].id == nested.id + + def test_mark_missing_roms_small_platform(platform: Platform): """mark_missing_roms correctly identifies missing ROMs with a small keep list.""" rom_a = db_rom_handler.add_rom( @@ -655,12 +688,22 @@ def test_mark_missing_roms_small_platform(platform: Platform): ) ) - # Keep only rom_a and rom_c - missing = db_rom_handler.mark_missing_roms(platform.id, ["rom_a.zip", "rom_c.zip"]) + # Keep only rom_a and rom_c (keep-list holds full paths) + missing = db_rom_handler.mark_missing_roms( + platform.id, + [f"{platform.slug}/roms/rom_a.zip", f"{platform.slug}/roms/rom_c.zip"], + ) assert len(missing) == 1 assert missing[0].fs_name == "rom_b.zip" + # Regression: returned (detached) instances must carry `fs_path` so callers + # can read `rom.full_path` after the session closes without a + # DetachedInstanceError (the missing-rom warning in scan.py does exactly + # that). + assert "fs_path" not in sa_inspect(missing[0]).unloaded + assert missing[0].full_path == f"{platform.slug}/roms/rom_b.zip" + updated_b = db_rom_handler.get_rom(rom_b.id) assert updated_b is not None assert updated_b.missing_from_fs is True @@ -699,7 +742,9 @@ def test_mark_missing_roms_large_platform(platform: Platform): # Build a large keep list to verify mark_missing_roms() handles many entries. # Only rom_present.zip actually exists in DB; the rest are just filler. - fs_roms_to_keep = ["rom_present.zip"] + [f"filler_{i}.zip" for i in range(501)] + fs_roms_to_keep = [f"{platform.slug}/roms/rom_present.zip"] + [ + f"filler_{i}.zip" for i in range(501) + ] missing = db_rom_handler.mark_missing_roms(platform.id, fs_roms_to_keep) @@ -734,7 +779,7 @@ def test_mark_missing_roms_large_platform_all_present(platform: Platform): roms.append(rom) # Keep list has all real ROMs plus filler to exceed 500 - fs_roms_to_keep = [f"rom_{i}.zip" for i in range(3)] + [ + fs_roms_to_keep = [f"{platform.slug}/roms/rom_{i}.zip" for i in range(3)] + [ f"filler_{i}.zip" for i in range(500) ] diff --git a/backend/tests/handler/test_fastapi.py b/backend/tests/handler/test_fastapi.py index 6ead7608dc..10ccbb1091 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -86,6 +86,7 @@ async def test_scan_rom(): rom=rom, fs_rom={ "fs_name": "Paper Mario (USA).z64", + "fs_path": "n64/Paper Mario (USA)", "flat": True, "nested": False, "files": [ @@ -176,6 +177,7 @@ async def test_scan_rom_complete_clears_unselected_metadata( rom=rom, fs_rom={ "fs_name": "Paper Mario (USA).z64", + "fs_path": "n64/Paper Mario (USA)", "flat": True, "nested": False, "files": [], @@ -246,6 +248,7 @@ async def test_scan_rom_unmatched_fetches_ra_when_id_set_but_no_metadata( rom=rom, fs_rom={ "fs_name": "Jak and Daxter.chd", + "fs_path": "ps2", "flat": True, "nested": False, "files": [], @@ -308,6 +311,7 @@ async def test_scan_rom_unmatched_skips_ra_when_id_and_metadata_exist( rom=rom, fs_rom={ "fs_name": "Jak and Daxter.chd", + "fs_path": "ps2", "flat": True, "nested": False, "files": [], @@ -853,6 +857,7 @@ def _ss_quota_platform() -> Platform: def _ss_quota_fs_rom(fs_name: str) -> FSRom: return { "fs_name": fs_name, + "fs_path": "snes", "flat": True, "nested": False, "files": [], diff --git a/examples/config.example.yml b/examples/config.example.yml index d6baa570a5..811888e5d4 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -76,6 +76,53 @@ # roms_folder: 'roms' # # Skip file hash calculations on low power devices (eg. Raspberry PI) # skip_hash_calculation: false +# # Custom library structure, per platform. By default a platform's ROM folder +# # is scanned one level deep: each top-level file is a game and each top-level +# # folder is a single multi-file game. A template lets you describe a deeper / +# # organized layout instead. It is opt-in per platform (by fs_slug); platforms +# # left out keep the default behavior. +# # +# # Syntax (Retrom-style): `/`-separated path sections, relative to the +# # platform's ROM folder (RomM already resolves the library root, the +# # roms_folder above, and the platform directory — so {library}/{platform} +# # are not used here). A section wrapped in braces is a macro; a bare section +# # is a literal folder name matched exactly. The LAST section must be a +# # terminal macro: +# # - {gameFile} -> each file at that level is its own game; +# # - {gameDir} -> each folder at that level is a single multi-file game. +# # Any other braced section (e.g. {region}, {category}) is a wildcard +# # directory level: it matches any folder and is purely organizational. +# # +# # A platform may also declare a LIST of templates instead of one; discovery +# # is their union. This covers the common mixed layout — loose games at the +# # platform root AND games inside grouping subfolders below it — which a +# # single fixed-depth template can't express on its own. +# # +# # Notes: +# # - Hidden (dot-prefixed) folders are never descended into or surfaced. +# # - {gameDir} keeps a folder whole (multi-disc / cue+bin games), so there +# # is no guessing — you declare file-vs-folder explicitly. +# # - Identity is content-based on scan: moving or renaming a game within the +# # structure is detected by its hash and relocated in place, so its saves, +# # play history, favorites and collection membership follow it (as long as +# # hashing is enabled and the platform is hashable). When a folder that was +# # previously scanned as one multi-file game is now descended into, the old +# # entry is marked "missing from filesystem" (the scan log flags it); +# # delete the stale entry to clean up. +# # - Two files with the same name in different folders become distinct games; +# # gamelist.xml metadata matching is by filename, so both may match the +# # same gamelist entry. +# structure: +# # Mixed: loose top-level games AND grouping subfolders below them, e.g. +# # roms/nes/game01.nes (matched by {gameFile}) +# # roms/nes/Hacks/game03.nes (matched by {category}/{gameFile}) +# nes: +# - '{gameFile}' +# - '{category}/{gameFile}' +# # roms/ps3/Disc/Game/, roms/ps3/PSN/Game/ -> each folder a multi-file game +# ps3: '{category}/{gameDir}' +# # roms/snes/USA/foo.sfc, roms/snes/Japan/bar.sfc +# snes: '{region}/{gameFile}' # scan: # # Metadata priority during scans diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 7eeee53024..4dbf6e2694 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Зареди запис или бърз запис", "loading-metadata": "Зареждане на метаданни…", "loading-rom": "Зареждане на ROM…", + "location": "Местоположение", + "location-copied": "Местоположението е копирано в клипборда.", "main-plus-extra": "Основна + Допълнителна", "main-story": "Основна история", "make-private": "Направи частно", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index a2ff016ff7..3fa18c459e 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Načíst uloženou pozici nebo stav", "loading-metadata": "Načítání metadat…", "loading-rom": "Načítání ROM…", + "location": "Umístění", + "location-copied": "Umístění zkopírováno do schránky.", "main-plus-extra": "Hlavní + Extra", "main-story": "Hlavní příběh", "make-private": "Učinit soukromým", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 355797433a..d1b17ace6f 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Spielstand oder Zustand laden", "loading-metadata": "Metadaten werden geladen…", "loading-rom": "ROM wird geladen…", + "location": "Speicherort", + "location-copied": "Speicherort in die Zwischenablage kopiert.", "main-plus-extra": "Hauptspiel + Extra", "main-story": "Hauptgeschichte", "make-private": "Privat Machen", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index c41981557a..a9685b18f0 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Load save or state", "loading-metadata": "Loading metadata…", "loading-rom": "Loading ROM…", + "location": "Location", + "location-copied": "Location copied to clipboard.", "main-plus-extra": "Main + Extra", "main-story": "Main Story", "make-private": "Make Private", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index baf6a7f9fb..6c5d522307 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Load save or state", "loading-metadata": "Loading metadata…", "loading-rom": "Loading ROM…", + "location": "Location", + "location-copied": "Location copied to clipboard.", "main-plus-extra": "Main + Extra", "main-story": "Main Story", "make-private": "Make Private", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index d3b4622075..d9ed473c9a 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Cargar partida o estado", "loading-metadata": "Cargando metadatos…", "loading-rom": "Cargando ROM…", + "location": "Ubicación", + "location-copied": "Ubicación copiada al portapapeles.", "main-plus-extra": "Principal + Extra", "main-story": "Historia Principal", "make-private": "Hacer Privada", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index f47b99d3a6..4d5babd37b 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Charger une sauvegarde ou un état", "loading-metadata": "Chargement des métadonnées…", "loading-rom": "Chargement de la ROM…", + "location": "Emplacement", + "location-copied": "Emplacement copié dans le presse-papiers.", "main-plus-extra": "Principal + Extra", "main-story": "Histoire Principale", "make-private": "Rendre Privée", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 78440d3fe7..3c1bfca1e5 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Mentés vagy állás betöltése", "loading-metadata": "Metaadatok betöltése…", "loading-rom": "ROM betöltése…", + "location": "Hely", + "location-copied": "A hely a vágólapra másolva.", "main-plus-extra": "Fősztori + Extra", "main-story": "Fősztori", "make-private": "Priváttá tétele", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 3d4a558093..d0d5b7b1cb 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Carica salvataggio o stato", "loading-metadata": "Caricamento metadati…", "loading-rom": "Caricamento ROM…", + "location": "Posizione", + "location-copied": "Posizione copiata negli appunti.", "main-plus-extra": "Principale + Extra", "main-story": "Storia Principale", "make-private": "Rendi Privata", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 177d27d986..0bc18522df 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "セーブまたはステートセーブを読み込む", "loading-metadata": "メタデータを読み込み中…", "loading-rom": "ROMを読み込み中…", + "location": "場所", + "location-copied": "場所をクリップボードにコピーしました。", "main-plus-extra": "メイン+サブ", "main-story": "メインストーリー", "make-private": "非公開にする", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 8683455e63..6b5485d991 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "세이브 또는 상태 불러오기", "loading-metadata": "메타데이터 불러오는 중…", "loading-rom": "ROM 불러오는 중…", + "location": "위치", + "location-copied": "위치를 클립보드에 복사했습니다.", "main-plus-extra": "메인+서브", "main-story": "메인 스토리", "make-private": "비공개로 설정", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index a13e84e348..79e4fe2b2d 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Wczytaj zapis lub stan", "loading-metadata": "Ładowanie metadanych…", "loading-rom": "Ładowanie ROM-u…", + "location": "Lokalizacja", + "location-copied": "Skopiowano lokalizację do schowka.", "main-plus-extra": "Główna+Dodatkowa", "main-story": "Główna Fabuła", "make-private": "Ustaw jako Prywatną", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 7636650fa8..726d983123 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Carregar save ou state", "loading-metadata": "Carregando metadados…", "loading-rom": "Carregando ROM…", + "location": "Localização", + "location-copied": "Localização copiada para a área de transferência.", "main-plus-extra": "Principal + Extra", "main-story": "História Principal", "make-private": "Tornar Privada", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index a8fcf22c06..bac577edbf 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Încarcă salvare sau stare", "loading-metadata": "Se încarcă metadatele…", "loading-rom": "Se încarcă ROM-ul…", + "location": "Locație", + "location-copied": "Locație copiată în clipboard.", "main-plus-extra": "Principal + Extra", "main-story": "Povestea Principală", "make-private": "Fa Privată", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 861c5480b4..163d0458ac 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "Загрузить сохранение или состояние", "loading-metadata": "Загрузка метаданных…", "loading-rom": "Загрузка ROM…", + "location": "Расположение", + "location-copied": "Расположение скопировано в буфер обмена.", "main-plus-extra": "Основное+Дополнительное", "main-story": "Основная История", "make-private": "Сделать Приватной", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 5da52a95dc..2eef704e20 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "加载存档或状态", "loading-metadata": "正在加载元数据…", "loading-rom": "正在加载 ROM…", + "location": "位置", + "location-copied": "位置已复制到剪贴板。", "main-plus-extra": "主线+支线", "main-story": "主线剧情", "make-private": "设为私人", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 07e40752e5..f5a21adb41 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -157,6 +157,8 @@ "load-save-or-state": "載入存檔或即時存檔", "loading-metadata": "正在載入元數據…", "loading-rom": "正在載入 ROM…", + "location": "位置", + "location-copied": "位置已複製到剪貼簿。", "main-plus-extra": "主線+支線", "main-story": "主線劇情", "make-private": "設為私人", diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FilesSummary.vue b/frontend/src/v2/components/GameDetails/FilesTab/FilesSummary.vue index 71beee439d..5518a518b4 100644 --- a/frontend/src/v2/components/GameDetails/FilesTab/FilesSummary.vue +++ b/frontend/src/v2/components/GameDetails/FilesTab/FilesSummary.vue @@ -7,6 +7,7 @@ import { computed } from "vue"; import type { DetailedRomSchema } from "@/__generated__"; import { formatBytes } from "@/utils"; import HashChip from "@/v2/components/shared/HashChip.vue"; +import LocationChip from "@/v2/components/shared/LocationChip.vue"; import MissingFSBadge from "@/v2/components/shared/MissingFSBadge.vue"; defineOptions({ inheritAttrs: false }); @@ -15,6 +16,12 @@ const props = defineProps<{ rom: DetailedRomSchema }>(); const fileCount = computed(() => props.rom.files?.length ?? 0); +// Full on-disk path of the ROM — its directory plus its name. Surfaces +// *where* a ROM sits in the library (e.g. a platform subfolder like +// `roms/nes/Hacks/…`), which is otherwise only implicit. Distinct from +// the per-file relative paths shown in the file list below. +const fullPath = computed(() => `${props.rom.fs_path}/${props.rom.fs_name}`); + interface RomHash { label: string; value: string | null; @@ -57,6 +64,10 @@ const hashes = computed(() => { +
+ +
+
(() => { .r-v2-files-summary__sep { opacity: 0.5; } +.r-v2-files-summary__location { + display: flex; + min-width: 0; +} .r-v2-files-summary__hashes { display: flex; align-items: center; diff --git a/frontend/src/v2/components/shared/LocationChip.vue b/frontend/src/v2/components/shared/LocationChip.vue new file mode 100644 index 0000000000..5f1d5414c7 --- /dev/null +++ b/frontend/src/v2/components/shared/LocationChip.vue @@ -0,0 +1,82 @@ + + + + +