feat: opt-in per-platform subfolder scanning with non-destructive identity

Adds opt-in, per-platform subfolder scanning (closes #2050). When enabled
for a platform, RomM recurses that platform's subfolders and treats each
nested file as its own ROM, instead of collapsing a subfolder into one
multi-file ROM — so libraries organized into Hacks/, Translations/,
Homebrew/, etc. are scanned correctly.

Enable per platform in config.yml (default off, existing setups unaffected):

  scan:
    subfolders:
      nes: true                       # recurse every subfolder
      snes: ["Hacks", "Translations"] # recurse only these; keep others whole

Key difference from a naive path-identity approach: identity is reconciled
by content hash on scan. Moving or renaming a ROM between subfolders is
detected by its hash against a now-missing ROM and relocated in place, so
saves, play history, favorites and collection membership follow it. Falls
back to path identity when hashing is unavailable (skip_hash_calculation or
a non-hashable platform). A cheap size pre-filter avoids hashing every
newly-seen file (e.g. on first enable).

Details:
- Per-platform opt-in via scan.subfolders (fs_slug -> bool | list[str]); no
  DB schema change.
- Recursion skips hidden (dot-prefixed) folders, and keeps a folder whole as
  a single multi-file ROM when it holds a disc/playlist descriptor
  (.m3u/.cue/.gdi/.ccd/.toc) — covers cue+bin / multi-disc games.
- Path-based keying for the steady-state lookup (get_roms_by_fs_name and
  mark_missing_roms key on full path) so identically-named files in different
  subfolders stay distinct; the (platform_id, fs_name) index is non-unique.
- File resolution (download/delete/hash) uses the ROM's stored fs_path.
- Scan log flags entries that became "missing" because a folder is now
  recursed, so stale entries are easy to clean up.
- Frontend: the Files tab shows a ROM's on-disk Location (click-to-copy),
  built as a shared LocationChip mirroring HashChip's RTag-based pattern.
  New i18n keys added to all locales.

Tests: backend unit tests for recursion (collisions, hidden folders,
descriptor dirs kept whole, named-list form), full-path keying in
get_roms_by_fs_name / mark_missing_roms, and hash-based relocation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
This commit is contained in:
Claude
2026-06-21 20:46:50 +00:00
parent 49f2b1659c
commit 5b494c3bc4
28 changed files with 951 additions and 100 deletions
+37
View File
@@ -132,6 +132,7 @@ class Config:
SCAN_REGION_PRIORITY: list[str]
SCAN_LANGUAGE_PRIORITY: list[str]
SCAN_MEDIA: list[str]
SCAN_SUBFOLDERS: dict[str, bool | list[str]]
GAMELIST_MEDIA_THUMBNAIL: MetadataMediaType
GAMELIST_MEDIA_IMAGE: MetadataMediaType
@@ -160,6 +161,25 @@ class Config:
return False
def subfolder_scan_spec(self, fs_slug: str) -> bool | frozenset[str]:
"""How the scanner should recurse into a platform's subfolders.
Opt-in per platform via `scan.subfolders` in config.yml, where the
value is either:
- ``True`` -> recurse every subfolder (each nested file becomes its
own rom);
- a list of folder names -> recurse only those folders, leaving every
other folder as a single multi-file rom (so folder-based multi-file
games elsewhere on the platform stay intact);
- ``False`` / omitted -> don't recurse (default behavior).
Returns ``True``, a ``frozenset`` of folder names, or ``False``.
"""
value = getattr(self, "SCAN_SUBFOLDERS", {}).get(fs_slug, False)
if isinstance(value, list):
return frozenset(value)
return bool(value)
class ConfigManager:
"""
@@ -446,6 +466,7 @@ class ConfigManager:
PEGASUS_AUTO_EXPORT_ON_SCAN=pydash.get(
self._raw_config, "scan.pegasus.export", False
),
SCAN_SUBFOLDERS=pydash.get(self._raw_config, "scan.subfolders", {}),
)
def _get_ejs_controls(self) -> dict[str, EjsControls]:
@@ -660,6 +681,21 @@ class ConfigManager:
log.critical("Invalid config.yml: scan.media must be a list")
sys.exit(3)
if not isinstance(self.config.SCAN_SUBFOLDERS, dict):
log.critical("Invalid config.yml: scan.subfolders must be a dictionary")
sys.exit(3)
for fs_slug, value in self.config.SCAN_SUBFOLDERS.items():
is_bool = isinstance(value, bool)
is_str_list = isinstance(value, list) and all(
isinstance(name, str) for name in value
)
if not (is_bool or is_str_list):
log.critical(
f"Invalid config.yml: scan.subfolders.{fs_slug} must be a "
"boolean or a list of folder names"
)
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 = [
@@ -779,6 +815,7 @@ class ConfigManager:
"language": self.config.SCAN_LANGUAGE_PRIORITY,
},
"media": self.config.SCAN_MEDIA,
"subfolders": self.config.SCAN_SUBFOLDERS,
"gamelist": {
"export": self.config.GAMELIST_AUTO_EXPORT_ON_SCAN,
"media": {
+199 -6
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import asyncio
import os
from collections import defaultdict
from dataclasses import dataclass
from itertools import batched
from typing import Any, Final
@@ -30,7 +32,7 @@ from handler.filesystem import (
fs_resource_handler,
fs_rom_handler,
)
from handler.filesystem.roms_handler import FSRom
from handler.filesystem.roms_handler import FSRom, ParsedRomFiles
from handler.metadata import meta_gamelist_handler, meta_hltb_handler
from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types
from handler.redis_handler import get_job_func_name, high_prio_queue, redis_client
@@ -247,7 +249,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 a
# subfolder of the platform roms folder when subfolder scanning is enabled.
roms_path = fs_rom["fs_path"]
# Create the entry early so we have the ID
newly_added: bool = rom is None
@@ -491,6 +495,159 @@ async def _identify_rom(
)
def _fs_rom_size(fs_rom: FSRom) -> int:
"""On-disk size of a discovered rom (a file's size, or the sum of a
folder's files). Cheap stat-only pre-filter used before hashing during
relocation matching. Returns -1 when the path can't be read.
"""
abs_path = os.path.join(
fs_rom_handler.base_path, fs_rom["fs_path"], fs_rom["fs_name"]
)
try:
if os.path.isdir(abs_path):
total = 0
for root, _dirs, files in os.walk(abs_path):
for name in files:
try:
total += os.stat(os.path.join(root, name)).st_size
except OSError:
pass
return total
return os.stat(abs_path).st_size
except OSError:
return -1
def _hashes_match(rom: Rom, parsed: ParsedRomFiles) -> bool:
"""Whether an existing rom and freshly parsed files are the same content.
A single matching non-empty hash is enough — a collision across sha1/md5/
crc/ra is negligible, and different platforms populate different hashes.
"""
for stored, computed in (
(rom.sha1_hash, parsed.sha1_hash),
(rom.md5_hash, parsed.md5_hash),
(rom.crc_hash, parsed.crc_hash),
(rom.ra_hash, parsed.ra_hash),
):
if stored and computed and stored == computed:
return True
return False
async def _reconcile_relocated_roms(
platform: Platform,
fs_roms: list[FSRom],
) -> set[str]:
"""Relocate roms whose on-disk path changed instead of re-importing them.
When subfolder scanning is enabled, moving (or renaming) a file reads as a
new path. Rather than insert a fresh rom and mark the old one missing —
which would drop saves, play history, favorites and collection membership —
match a newly-seen file to a now-missing rom by content hash and update that
rom's path in place. Returns the set of full paths that were fully handled
this way (so the caller skips them in the normal scan loop).
Falls back to no-op (path-based identity) when hashes are unavailable
(``skip_hash_calculation`` or a non-hashable platform).
"""
existing = db_rom_handler.get_roms_for_relocation(platform.id)
existing_paths = {rom.full_path for rom in existing}
present_paths = {f"{fr['fs_path']}/{fr['fs_name']}" for fr in fs_roms}
# A rom that vanished from its stored path is a relocation candidate.
disappeared = [rom for rom in existing if rom.full_path not in present_paths]
if not disappeared:
return set()
# Group candidates by total size: a moved file keeps its size, so this is a
# cheap pre-filter that avoids hashing every newly-seen file (e.g. on first
# enable, where the whole subfolder tree reads as new).
by_size: dict[int, list[Rom]] = defaultdict(list)
for rom in disappeared:
by_size[rom.fs_size_bytes].append(rom)
handled: set[str] = set()
claimed: set[int] = set()
calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION
for fs_rom in fs_roms:
full_path = f"{fs_rom['fs_path']}/{fs_rom['fs_name']}"
if full_path in existing_paths:
continue # already matched by path; not a relocation
candidates = [
rom
for rom in by_size.get(_fs_rom_size(fs_rom), [])
if rom.id not in claimed
]
if not candidates:
continue
# Compute the file's identity hashes (and its rom files for the new
# location) once; reuse them for both matching and the relocation write.
transient = Rom(
fs_name=fs_rom["fs_name"],
fs_path=fs_rom["fs_path"],
platform_id=platform.id,
)
transient.platform = platform
parsed = await fs_rom_handler.get_rom_files(
transient, calculate_hashes=calculate_hashes
)
if not (
parsed.sha1_hash or parsed.md5_hash or parsed.crc_hash or parsed.ra_hash
):
continue # no usable hash to match on
match = next((rom for rom in candidates if _hashes_match(rom, parsed)), None)
if match is None:
continue
claimed.add(match.id)
handled.add(full_path)
old_path = match.full_path
db_rom_handler.update_rom(
match.id,
{
"fs_name": fs_rom["fs_name"],
"fs_path": fs_rom["fs_path"],
"fs_size_bytes": sum(f.file_size_bytes for f in parsed.rom_files),
"crc_hash": parsed.crc_hash,
"md5_hash": parsed.md5_hash,
"sha1_hash": parsed.sha1_hash,
"ra_hash": parsed.ra_hash,
"missing_from_fs": False,
},
)
db_rom_handler.purge_rom_files(match.id)
for file in parsed.rom_files:
db_rom_handler.add_rom_file(
RomFile(
rom_id=match.id,
file_name=file.file_name,
file_path=file.file_path,
file_size_bytes=file.file_size_bytes,
last_modified=file.last_modified,
category=file.category,
audio_meta=file.audio_meta,
crc_hash=file.crc_hash,
md5_hash=file.md5_hash,
sha1_hash=file.sha1_hash,
ra_hash=file.ra_hash,
chd_sha1_hash=file.chd_sha1_hash,
)
)
log.info(
f"{hl('Relocated', color=BLUE)} {hl(old_path)} → {hl(full_path)} "
"(moved on disk; metadata and user data preserved)"
)
return handled
async def _identify_platform(
platform_slug: str,
scan_type: ScanType,
@@ -582,6 +739,24 @@ async def _identify_platform(
else:
log.info(f"{hl(str(len(fs_roms)))} roms found in the file system")
# Detect roms that simply moved on disk (subfolder scanning) and relocate
# them in place so their saves/history/favorites/collections follow,
# instead of re-importing them as new and orphaning the old entry. Only
# runs when subfolder scanning is enabled for the platform, so default
# libraries pay no extra cost.
if cm.get_config().subfolder_scan_spec(platform.fs_slug):
relocated_paths = await _reconcile_relocated_roms(platform, fs_roms)
if relocated_paths:
await scan_stats.increment(
socket_manager=socket_manager,
scanned_roms=len(relocated_paths),
)
fs_roms = [
fs_rom
for fs_rom in fs_roms
if f"{fs_rom['fs_path']}/{fs_rom['fs_name']}" not in relocated_paths
]
# Create semaphore to limit concurrent ROM scanning
scan_semaphore = asyncio.Semaphore(SCAN_WORKERS)
@@ -602,7 +777,10 @@ async def _identify_platform(
)
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 subfolders don't
# collide when subfolder scanning is enabled.
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},
)
@@ -612,7 +790,7 @@ async def _identify_platform(
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,
@@ -644,12 +822,27 @@ async def _identify_platform(
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 that subfolder scanning now recurses 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.
recursed_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 recursed_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]
+52 -6
View File
@@ -1171,7 +1171,11 @@ class DBRomsHandler(DBBaseHandler):
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 subfolders (subfolder
scanning) remain distinct.
Eager-loads only `platform` (used downstream by the scan loop via
`rom.platform_slug` / `rom.platform.fs_slug`). This deliberately
@@ -1195,7 +1199,7 @@ class DBRomsHandler(DBBaseHandler):
.all()
)
return {rom.fs_name: rom for rom in roms}
return {rom.full_path: rom for rom in roms}
@begin_session
def update_rom(
@@ -1278,19 +1282,23 @@ class DBRomsHandler(DBBaseHandler):
) -> 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 subfolders are tracked
independently when subfolder scanning is enabled.
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)
@@ -1305,8 +1313,12 @@ class DBRomsHandler(DBBaseHandler):
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,
@@ -1319,6 +1331,40 @@ class DBRomsHandler(DBBaseHandler):
.all()
)
@begin_session
def get_roms_for_relocation(
self,
platform_id: int,
session: Session = None, # type: ignore
) -> Sequence[Rom]:
"""Light-weight load of a platform's roms for relocation matching.
Returns detached rom instances carrying only the columns needed to
detect a moved/renamed file by content (identity hashes + size) and to
resolve its current `full_path`. Used by the scan loop to relocate a
rom whose on-disk path changed instead of re-importing it as new.
"""
return (
session.scalars(
select(Rom)
.options(
load_only(
Rom.id,
Rom.fs_name,
Rom.fs_path,
Rom.fs_size_bytes,
Rom.crc_hash,
Rom.md5_hash,
Rom.sha1_hash,
Rom.ra_hash,
)
)
.where(Rom.platform_id == platform_id)
)
.unique()
.all()
)
@begin_session
def add_rom_user(
self,
+114 -38
View File
@@ -88,6 +88,7 @@ NON_HASHABLE_PLATFORMS = frozenset(
class FSRom(TypedDict):
fs_name: str
fs_path: str
flat: bool
nested: bool
files: list[RomFile]
@@ -316,10 +317,11 @@ class FSRomsHandler(FSHandler):
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 platform subfolder (subfolder scanning) 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
@@ -392,14 +394,19 @@ class FSRomsHandler(FSHandler):
try:
if is_top_level:
# Include this file in the main ROM hash calculation
crc_c, rom_crc_c, md5_h, rom_md5_h, sha1_h, rom_sha1_h = (
await asyncio.to_thread(
self._calculate_rom_hashes,
Path(f_path, file_name),
rom_crc_c,
rom_md5_h,
rom_sha1_h,
)
(
crc_c,
rom_crc_c,
md5_h,
rom_md5_h,
sha1_h,
rom_sha1_h,
) = await asyncio.to_thread(
self._calculate_rom_hashes,
Path(f_path, file_name),
rom_crc_c,
rom_md5_h,
rom_sha1_h,
)
else:
# Calculate individual file hash only
@@ -529,14 +536,19 @@ class FSRomsHandler(FSHandler):
)
elif hashable_platform:
try:
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,
)
(
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
@@ -664,21 +676,93 @@ class FSRomsHandler(FSHandler):
rom_sha1_h,
)
# Disc/playlist descriptors that mark a folder as a single multi-file game
# (a multi-disc title), so it is kept whole instead of being recursed into
# when subfolder scanning is enabled — preventing it from being split into
# one rom per disc.
_MULTI_DISC_DESCRIPTOR_EXTS = (".m3u", ".cue", ".gdi", ".ccd", ".toc")
@staticmethod
def _should_recurse_dir(directory: str, recurse: bool | frozenset[str]) -> bool:
"""Whether to descend into ``directory`` (collecting its contents as
individual roms) rather than treat it as one multi-file rom.
Hidden (dot-prefixed) folders are never descended into. Otherwise
``recurse`` is ``True`` (all folders), a set of folder names (only
those), or ``False`` (none).
"""
if directory.startswith("."):
return False
if isinstance(recurse, frozenset):
return directory in recurse
return recurse
async def _is_multi_disc_dir(self, rel_dir_path: str) -> bool:
"""True if a directory directly contains a disc/playlist descriptor.
Such a directory is a single multi-file rom (a multi-disc game), so it
is kept whole instead of being recursed into when subfolder scanning is
enabled — preventing a multi-disc game from being split into one rom per
disc. Covers ``.m3u`` playlists as well as ``.cue``/``.gdi``/``.ccd``/
``.toc`` track descriptors (the ``.cue``+``.bin`` case raised in review).
"""
return any(
f.lower().endswith(self._MULTI_DISC_DESCRIPTOR_EXTS)
for f in await self.list_files(rel_dir_path)
)
async def _collect_fs_roms(
self, rel_roms_path: str, recurse: bool | frozenset[str]
) -> list[dict]:
"""Discover roms under ``rel_roms_path``.
Single files are flat roms (``fs_path`` = their parent directory). A
directory is a single multi-file rom unless ``recurse`` says to descend
into it, in which case its contents are collected as individual roms.
``recurse`` is ``True`` (descend every folder), a set of folder names
(descend only those — every other folder stays a multi-file rom), or
``False`` (descend none).
Hidden (dot-prefixed) directories are never descended into, and a
directory holding a disc/playlist descriptor is kept whole as a single
multi-file rom (a multi-disc game) even while recursing.
"""
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))
]
for directory in self.exclude_multi_roms(
await self.list_directories(rel_roms_path)
):
dir_path = f"{rel_roms_path}/{directory}"
if self._should_recurse_dir(
directory, recurse
) and not await self._is_multi_disc_dir(dir_path):
fs_roms.extend(await self._collect_fs_roms(dir_path, recurse))
else:
fs_roms.append(
{
"fs_name": directory,
"fs_path": rel_roms_path,
"flat": False,
"nested": True,
}
)
return fs_roms
async def count_roms(self, platform: Platform) -> int:
"""Return the number of filesystem roms for a platform without
materializing FSRom objects.
"""
recurse = cm.get_config().subfolder_scan_spec(platform.fs_slug)
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(rel_roms_path, recurse))
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
@@ -687,28 +771,20 @@ class FSRomsHandler(FSHandler):
Returns:
list with all the filesystem roms for a platform
"""
recurse = cm.get_config().subfolder_scan_spec(platform.fs_slug)
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(rel_roms_path, recurse)
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=[],
@@ -719,7 +795,7 @@ class FSRomsHandler(FSHandler):
)
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:
+105 -2
View File
@@ -1,12 +1,21 @@
import hashlib
from pathlib import Path
from unittest.mock import Mock
import pytest
import socketio
from endpoints.sockets.scan import ScanStats, _should_scan_rom
from handler.filesystem.roms_handler import FSRomsHandler
from endpoints.sockets.scan import (
ScanStats,
_reconcile_relocated_roms,
_should_scan_rom,
)
from handler.database import db_rom_handler
from handler.filesystem import fs_rom_handler
from handler.filesystem.roms_handler import FSRom, FSRomsHandler
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from handler.scan_handler import ScanType
from models.platform import Platform
from models.rom import Rom
@@ -326,3 +335,97 @@ class TestGetPico8CoverUrl:
assert url is not None
assert fs_path in url
assert fs_name in url
def _fs_rom(fs_name: str, fs_path: str) -> FSRom:
return FSRom(
fs_name=fs_name,
fs_path=fs_path,
flat=True,
nested=False,
files=[],
crc_hash="",
md5_hash="",
sha1_hash="",
ra_hash="",
)
class TestReconcileRelocatedRoms:
"""A rom whose on-disk path changed (subfolder scanning) is relocated in
place by content hash instead of being re-imported as new, so its DB row —
and the saves/history/favorites/collections attached to it — survives."""
@pytest.mark.asyncio
async def test_moved_file_is_relocated_not_reimported(
self, platform: Platform, tmp_path: Path, monkeypatch
):
content = b"relocate me please"
sha1 = hashlib.sha1(content, usedforsecurity=False).hexdigest()
base = f"{platform.fs_slug}/roms"
# An existing rom recorded at the platform root, with its content hash.
rom = db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name="Mover",
slug="mover",
fs_name="Mover.bin",
fs_path=base,
sha1_hash=sha1,
fs_size_bytes=len(content),
)
)
# On disk the file now lives inside a subfolder (same bytes).
sub = tmp_path / base / "Hacks"
sub.mkdir(parents=True)
(sub / "Mover.bin").write_bytes(content)
monkeypatch.setattr(fs_rom_handler, "base_path", tmp_path)
fs_roms = [_fs_rom("Mover.bin", f"{base}/Hacks")]
handled = await _reconcile_relocated_roms(platform, fs_roms)
# The moved file is reported handled, so the scan loop skips it.
assert handled == {f"{base}/Hacks/Mover.bin"}
# Same row, new path, present again — not a fresh import.
all_roms = db_rom_handler.get_roms_for_relocation(platform.id)
assert len(all_roms) == 1
updated = db_rom_handler.get_rom(rom.id)
assert updated is not None
assert updated.id == rom.id
assert updated.fs_path == f"{base}/Hacks"
assert updated.fs_name == "Mover.bin"
assert updated.missing_from_fs is False
@pytest.mark.asyncio
async def test_no_hash_match_is_left_for_normal_import(
self, platform: Platform, tmp_path: Path, monkeypatch
):
base = f"{platform.fs_slug}/roms"
# Existing rom whose stored hash won't match anything on disk.
db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name="Other",
slug="other",
fs_name="Other.bin",
fs_path=base,
sha1_hash=hashlib.sha1(b"unrelated", usedforsecurity=False).hexdigest(),
fs_size_bytes=len(b"unrelated"),
)
)
content = b"a brand new game entirely"
sub = tmp_path / base / "Hacks"
sub.mkdir(parents=True)
(sub / "New.bin").write_bytes(content)
monkeypatch.setattr(fs_rom_handler, "base_path", tmp_path)
handled = await _reconcile_relocated_roms(
platform, [_fs_rom("New.bin", f"{base}/Hacks")]
)
# Nothing relocated: the new file falls through to a normal import.
assert handled == set()
@@ -377,6 +377,190 @@ class TestFSRomsHandler:
# Check excluded files are not present
assert "excluded_test.tmp" not in rom_names
def _make_subfolder_config(self, subfolders: dict[str, bool | 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",
SCAN_SUBFOLDERS=subfolders,
)
def _build_subfolder_library(self, tmp_path: Path, platform: Platform) -> None:
"""Library with a flat rom, a grouping subfolder (with a nested
sub-subfolder and a basename colliding with the root), a hidden folder,
and a folder-style multi-file rom."""
roms = tmp_path / platform.fs_slug / "roms"
roms.mkdir(parents=True)
(roms / "Game A.zip").write_text("a")
group = roms / "All but the Best"
group.mkdir()
(group / "Game A.zip").write_text("dup") # same basename, different folder
(group / "Hidden Gem.zip").write_text("c")
deeper = group / "deeper"
deeper.mkdir()
(deeper / "Way Down.zip").write_text("d")
hidden = roms / ".hidden"
hidden.mkdir()
(hidden / "disc.chd").write_text("x")
multi = roms / "Multi Disc Game"
multi.mkdir()
(multi / "disc1.bin").write_text("p")
@pytest.mark.asyncio
async def test_get_roms_subfolders_disabled(
self, platform: Platform, tmp_path: Path
):
"""By default a subfolder is a single multi-file rom, not a group."""
self._build_subfolder_library(tmp_path, platform)
handler = FSRomsHandler()
handler.base_path = tmp_path
with patch(
"handler.filesystem.roms_handler.cm.get_config",
lambda: self._make_subfolder_config({}),
):
roms = await handler.get_roms(platform)
count = await handler.count_roms(platform)
keys = {(r["fs_path"], r["fs_name"]) for r in roms}
base = f"{platform.fs_slug}/roms"
assert (base, "Game A.zip") in keys
# Directories surface as multi-file roms, not as groups.
assert (base, "All but the Best") in keys
assert (base, "Multi Disc Game") in keys
assert (base, ".hidden") in keys
# Nothing inside any subfolder is surfaced.
assert not any(r["fs_path"] != base for r in roms)
assert len(roms) == 4
assert count == len(roms)
@pytest.mark.asyncio
async def test_get_roms_subfolders_enabled(
self, platform: Platform, tmp_path: Path
):
"""With subfolder scanning on, groups are descended into (recursively),
hidden folders are skipped, and identically-named files in different
folders stay distinct via their full path."""
self._build_subfolder_library(tmp_path, platform)
handler = FSRomsHandler()
handler.base_path = tmp_path
with patch(
"handler.filesystem.roms_handler.cm.get_config",
lambda: self._make_subfolder_config({platform.fs_slug: True}),
):
roms = await handler.get_roms(platform)
count = await handler.count_roms(platform)
base = f"{platform.fs_slug}/roms"
keys = {(r["fs_path"], r["fs_name"]) for r in roms}
assert (base, "Game A.zip") in keys
assert (f"{base}/All but the Best", "Game A.zip") in keys # collision kept
assert (f"{base}/All but the Best", "Hidden Gem.zip") in keys
assert (f"{base}/All but the Best/deeper", "Way Down.zip") in keys
# Group folders are descended into, so "Multi Disc Game" parts surface.
assert (f"{base}/Multi Disc Game", "disc1.bin") in keys
# Hidden folder is not descended into; it remains a multi-file rom.
assert (base, ".hidden") in keys
assert (f"{base}/.hidden", "disc.chd") not in keys
# All full-path keys are unique and count matches.
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"] == "Game A.zip") == 2
assert count == len(roms)
@pytest.mark.asyncio
async def test_get_roms_subfolders_descriptor_dir_kept_whole(
self, platform: Platform, tmp_path: Path
):
"""A subfolder holding a disc/playlist descriptor (.m3u, .cue, ...) is a
multi-disc game: it stays a single multi-file rom even with subfolder
scanning on (not split per disc), while a plain grouping folder is still
recursed."""
roms = tmp_path / platform.fs_slug / "roms"
roms.mkdir(parents=True)
(roms / "Flat Game.zip").write_text("a")
# Multi-disc game declared by an .m3u playlist -> one rom.
md = roms / "Final Fantasy VII"
md.mkdir()
(md / "disc1.chd").write_text("1")
(md / "disc2.chd").write_text("2")
(md / "Final Fantasy VII.m3u").write_text("disc1.chd\ndisc2.chd")
# Multi-file game declared by a .cue descriptor (cue+bin) -> one rom.
cue = roms / "Some CD Game"
cue.mkdir()
(cue / "Some CD Game.cue").write_text('FILE "Some CD Game.bin" BINARY')
(cue / "Some CD Game.bin").write_text("data")
# Plain grouping folder (no descriptor) -> recursed into.
grp = roms / "Hacks"
grp.mkdir()
(grp / "Hack A.zip").write_text("h")
handler = FSRomsHandler()
handler.base_path = tmp_path
with patch(
"handler.filesystem.roms_handler.cm.get_config",
lambda: self._make_subfolder_config({platform.fs_slug: True}),
):
roms_found = await handler.get_roms(platform)
count = await handler.count_roms(platform)
base = f"{platform.fs_slug}/roms"
keys = {(r["fs_path"], r["fs_name"]) for r in roms_found}
# The .m3u folder stays a single multi-file rom (not split per disc).
assert (base, "Final Fantasy VII") in keys
assert (f"{base}/Final Fantasy VII", "disc1.chd") not in keys
# The .cue+.bin folder stays whole too (the case raised in review).
assert (base, "Some CD Game") in keys
assert (f"{base}/Some CD Game", "Some CD Game.bin") not in keys
# A normal grouping folder is still recursed.
assert (f"{base}/Hacks", "Hack A.zip") in keys
assert (base, "Flat Game.zip") in keys
assert count == len(roms_found)
@pytest.mark.asyncio
async def test_get_roms_subfolders_named_list(
self, platform: Platform, tmp_path: Path
):
"""A list value recurses only the named folders; every other folder
(including a folder-based multi-file game) stays a single multi-file
rom, and a non-named folder nested inside a named one is not split."""
self._build_subfolder_library(tmp_path, platform)
handler = FSRomsHandler()
handler.base_path = tmp_path
with patch(
"handler.filesystem.roms_handler.cm.get_config",
lambda: self._make_subfolder_config(
{platform.fs_slug: ["All but the Best"]}
),
):
roms = await handler.get_roms(platform)
count = await handler.count_roms(platform)
base = f"{platform.fs_slug}/roms"
keys = {(r["fs_path"], r["fs_name"]) for r in roms}
# Named folder is recursed -> its files become individual roms.
assert (f"{base}/All but the Best", "Game A.zip") in keys
assert (f"{base}/All but the Best", "Hidden Gem.zip") in keys
# A non-named folder nested inside it is kept whole (not recursed).
assert (f"{base}/All but the Best", "deeper") in keys
assert (f"{base}/All but the Best/deeper", "Way Down.zip") not in keys
# Folders NOT in the list stay as single multi-file roms.
assert (base, "Multi Disc Game") in keys
assert (f"{base}/Multi Disc Game", "disc1.bin") not in keys
# Top-level flat file and hidden folder behave as usual.
assert (base, "Game A.zip") in keys
assert (base, ".hidden") in keys
assert count == len(roms)
@pytest.mark.asyncio
async def test_get_rom_files_single_rom(
self, handler: FSRomsHandler, rom_single, config
@@ -738,19 +922,19 @@ class TestFSRomsHandler:
# The main ROM hash should be different from the translation file hash
# (this verifies that the translation is not included in the main hash)
assert (
parsed_rom_files.md5_hash == base_game_rom_file.md5_hash
), "Main ROM hash should include base game file"
assert (
parsed_rom_files.md5_hash != translation_rom_file.md5_hash
), "Main ROM hash should not include translation file"
assert parsed_rom_files.md5_hash == base_game_rom_file.md5_hash, (
"Main ROM hash should include base game file"
)
assert parsed_rom_files.md5_hash != translation_rom_file.md5_hash, (
"Main ROM hash should not include translation file"
)
assert (
parsed_rom_files.sha1_hash == base_game_rom_file.sha1_hash
), "Main ROM hash should include base game file"
assert (
parsed_rom_files.sha1_hash != translation_rom_file.sha1_hash
), "Main ROM hash should not include translation file"
assert parsed_rom_files.sha1_hash == base_game_rom_file.sha1_hash, (
"Main ROM hash should include base game file"
)
assert parsed_rom_files.sha1_hash != translation_rom_file.sha1_hash, (
"Main ROM hash should not include translation file"
)
@pytest.mark.asyncio
async def test_get_rom_files_with_chd_v5_uses_internal_hash(
@@ -797,9 +981,9 @@ class TestFSRomsHandler:
assert len(parsed_rom_files.rom_files) == 1
assert parsed_rom_files.crc_hash != "", "CRC should be computed from raw bytes"
assert parsed_rom_files.md5_hash != "", "MD5 should be computed from raw bytes"
assert (
parsed_rom_files.sha1_hash != ""
), "SHA1 should be computed from raw bytes"
assert parsed_rom_files.sha1_hash != "", (
"SHA1 should be computed from raw bytes"
)
# Raw file SHA1 is NOT the header SHA1
assert parsed_rom_files.sha1_hash != internal_sha1
@@ -1077,15 +1261,15 @@ class TestFSRomsHandler:
# All hashes should be populated (calculated from file content)
assert len(parsed_rom_files.rom_files) == 1
assert (
parsed_rom_files.crc_hash != ""
), "CRC hash should be calculated for non-v5 CHD"
assert (
parsed_rom_files.md5_hash != ""
), "MD5 hash should be calculated for non-v5 CHD"
assert (
parsed_rom_files.sha1_hash != ""
), "SHA1 hash should be calculated for non-v5 CHD"
assert parsed_rom_files.crc_hash != "", (
"CRC hash should be calculated for non-v5 CHD"
)
assert parsed_rom_files.md5_hash != "", (
"MD5 hash should be calculated for non-v5 CHD"
)
assert parsed_rom_files.sha1_hash != "", (
"SHA1 hash should be calculated for non-v5 CHD"
)
# Verify they're actual hash values (not from an internal header)
assert parsed_rom_files.rom_files[0].crc_hash == parsed_rom_files.crc_hash
@@ -1498,9 +1682,9 @@ class TestExtractCHDHash:
result = extract_chd_hash(chd_file)
assert (
result == expected
), f"Failed for size {size}: got {result}, expected {expected}"
assert result == expected, (
f"Failed for size {size}: got {result}, expected {expected}"
)
def test_extract_chd_hash_corrupted_header_data(self, tmp_path):
"""Test handling of corrupted/invalid data in header fields"""
+49 -4
View File
@@ -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
@@ -544,6 +545,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 subfolders 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(
@@ -583,12 +616,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
@@ -627,7 +670,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)
@@ -662,7 +707,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)
]
+4
View File
@@ -70,6 +70,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": [
@@ -160,6 +161,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": [],
@@ -230,6 +232,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": [],
@@ -292,6 +295,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": [],
+32
View File
@@ -145,6 +145,38 @@
# image: screenshot # Use as the <image> tag in the exported gamelist.xml
# pegasus:
# export: false # Whether to export metadata.pegasus.txt for Pegasus
# # Recurse into platform subfolders, treating nested files as their own
# # ROMs rather than collapsing each subfolder into a single multi-file ROM.
# # Opt-in per platform. The value is either:
# # - true -> recurse EVERY subfolder of the platform;
# # - a list of folder names -> recurse ONLY those folders, leaving every
# # other folder as a single multi-file ROM (so folder-based multi-file
# # games elsewhere on the platform stay intact — the recommended form
# # when a platform mixes grouping folders with multi-file games);
# # - false / omitted -> default behavior (a subfolder is one multi-file ROM).
# #
# # Behavior & notes (read before enabling):
# # - Hidden (dot-prefixed) folders are never descended into.
# # - A subfolder containing a disc/playlist descriptor (.m3u, .cue, .gdi,
# # .ccd, .toc) is kept whole as a single multi-file ROM (a multi-disc
# # game) and is NOT split into one ROM per disc, even when recursed.
# # - With `true`, a multi-file game stored as a bare folder with no such
# # descriptor WOULD be split into separate ROMs. Use the named-list form
# # (don't list that game's folder) to keep it whole.
# # - Identity is content-based on scan: moving (or renaming) a ROM between
# # subfolders 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 previously scanned as one multi-file ROM is now recursed, that
# # 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 subfolders become distinct
# # ROMs; gamelist.xml metadata matching is by filename, so both may match
# # the same gamelist entry.
# subfolders:
# nes: true # recurse every NES subfolder
# snes: ["Hacks", "Translations"] # recurse only these; keep other folders whole
# megadrive: false
# EmulatorJS per-core options
# emulatorjs:
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Направи публична",
"screenshot-make-private": "Направи лична",
"screenshot-public": "Публична",
"screenshot-visibility-failed": "Видимостта не може да се промени: {error}"
"screenshot-visibility-failed": "Видимостта не може да се промени: {error}",
"location": "Местоположение",
"location-copied": "Местоположението е копирано в клипборда."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Zveřejnit",
"screenshot-make-private": "Nastavit jako soukromé",
"screenshot-public": "Veřejné",
"screenshot-visibility-failed": "Nepodařilo se změnit viditelnost: {error}"
"screenshot-visibility-failed": "Nepodařilo se změnit viditelnost: {error}",
"location": "Umístění",
"location-copied": "Umístění zkopírováno do schránky."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Veröffentlichen",
"screenshot-make-private": "Privat machen",
"screenshot-public": "Öffentlich",
"screenshot-visibility-failed": "Sichtbarkeit konnte nicht geändert werden: {error}"
"screenshot-visibility-failed": "Sichtbarkeit konnte nicht geändert werden: {error}",
"location": "Speicherort",
"location-copied": "Speicherort in die Zwischenablage kopiert."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Make public",
"screenshot-make-private": "Make private",
"screenshot-public": "Public",
"screenshot-visibility-failed": "Couldn't update visibility: {error}"
"screenshot-visibility-failed": "Couldn't update visibility: {error}",
"location": "Location",
"location-copied": "Location copied to clipboard."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Make public",
"screenshot-make-private": "Make private",
"screenshot-public": "Public",
"screenshot-visibility-failed": "Couldn't update visibility: {error}"
"screenshot-visibility-failed": "Couldn't update visibility: {error}",
"location": "Location",
"location-copied": "Location copied to clipboard."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Hacer pública",
"screenshot-make-private": "Hacer privada",
"screenshot-public": "Pública",
"screenshot-visibility-failed": "No se pudo actualizar la visibilidad: {error}"
"screenshot-visibility-failed": "No se pudo actualizar la visibilidad: {error}",
"location": "Ubicación",
"location-copied": "Ubicación copiada al portapapeles."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Rendre publique",
"screenshot-make-private": "Rendre privée",
"screenshot-public": "Publique",
"screenshot-visibility-failed": "Impossible de mettre à jour la visibilité : {error}"
"screenshot-visibility-failed": "Impossible de mettre à jour la visibilité : {error}",
"location": "Emplacement",
"location-copied": "Emplacement copié dans le presse-papiers."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Közzététel",
"screenshot-make-private": "Priváttá tétel",
"screenshot-public": "Nyilvános",
"screenshot-visibility-failed": "A láthatóság nem frissíthető: {error}"
"screenshot-visibility-failed": "A láthatóság nem frissíthető: {error}",
"location": "Hely",
"location-copied": "A hely a vágólapra másolva."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Rendi pubblico",
"screenshot-make-private": "Rendi privato",
"screenshot-public": "Pubblico",
"screenshot-visibility-failed": "Impossibile aggiornare la visibilità: {error}"
"screenshot-visibility-failed": "Impossibile aggiornare la visibilità: {error}",
"location": "Posizione",
"location-copied": "Posizione copiata negli appunti."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "公開する",
"screenshot-make-private": "非公開にする",
"screenshot-public": "公開",
"screenshot-visibility-failed": "表示設定を更新できませんでした:{error}"
"screenshot-visibility-failed": "表示設定を更新できませんでした:{error}",
"location": "場所",
"location-copied": "場所をクリップボードにコピーしました。"
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "공개로 전환",
"screenshot-make-private": "비공개로 전환",
"screenshot-public": "공개",
"screenshot-visibility-failed": "공개 설정을 변경할 수 없습니다: {error}"
"screenshot-visibility-failed": "공개 설정을 변경할 수 없습니다: {error}",
"location": "위치",
"location-copied": "위치를 클립보드에 복사했습니다."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Upublicznij",
"screenshot-make-private": "Ustaw jako prywatne",
"screenshot-public": "Publiczne",
"screenshot-visibility-failed": "Nie udało się zaktualizować widoczności: {error}"
"screenshot-visibility-failed": "Nie udało się zaktualizować widoczności: {error}",
"location": "Lokalizacja",
"location-copied": "Skopiowano lokalizację do schowka."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Tornar pública",
"screenshot-make-private": "Tornar privada",
"screenshot-public": "Pública",
"screenshot-visibility-failed": "Não foi possível atualizar a visibilidade: {error}"
"screenshot-visibility-failed": "Não foi possível atualizar a visibilidade: {error}",
"location": "Localização",
"location-copied": "Localização copiada para a área de transferência."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Fă publică",
"screenshot-make-private": "Fă privată",
"screenshot-public": "Publică",
"screenshot-visibility-failed": "Vizibilitatea nu a putut fi actualizată: {error}"
"screenshot-visibility-failed": "Vizibilitatea nu a putut fi actualizată: {error}",
"location": "Locație",
"location-copied": "Locație copiată în clipboard."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "Сделать публичной",
"screenshot-make-private": "Сделать приватной",
"screenshot-public": "Публичная",
"screenshot-visibility-failed": "Не удалось изменить видимость: {error}"
"screenshot-visibility-failed": "Не удалось изменить видимость: {error}",
"location": "Расположение",
"location-copied": "Расположение скопировано в буфер обмена."
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "设为公开",
"screenshot-make-private": "设为私密",
"screenshot-public": "公开",
"screenshot-visibility-failed": "无法更新可见性:{error}"
"screenshot-visibility-failed": "无法更新可见性:{error}",
"location": "位置",
"location-copied": "位置已复制到剪贴板。"
}
+3 -1
View File
@@ -405,5 +405,7 @@
"screenshot-make-public": "設為公開",
"screenshot-make-private": "設為私密",
"screenshot-public": "公開",
"screenshot-visibility-failed": "無法更新可見性:{error}"
"screenshot-visibility-failed": "無法更新可見性:{error}",
"location": "位置",
"location-copied": "位置已複製到剪貼簿。"
}
@@ -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<RomHash[]>(() => {
</div>
</header>
<div class="r-v2-files-summary__location">
<LocationChip :path="fullPath" />
</div>
<div v-if="hashes.length > 0" class="r-v2-files-summary__hashes">
<HashChip
v-for="h in hashes"
@@ -109,6 +120,10 @@ const hashes = computed<RomHash[]>(() => {
.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;
@@ -0,0 +1,82 @@
<script setup lang="ts">
// LocationChip — click-to-copy chip surfacing a ROM's on-disk location
// (the directory it lives in plus its name). Mirrors HashChip's pattern —
// a keyboard-accessible <button> shell wrapping the shared RTag primitive —
// so it reads as a sibling of the hash pills it sits next to. Unlike a hash,
// a path isn't mid-abbreviated: it ellipsis-truncates on overflow while the
// full untruncated path is what gets copied.
import { RTag } from "@v2/lib";
import { useI18n } from "vue-i18n";
import { useSnackbar } from "@/v2/composables/useSnackbar";
defineOptions({ inheritAttrs: false });
const props = defineProps<{ path: string }>();
const { t } = useI18n();
const snackbar = useSnackbar();
async function copy() {
try {
await navigator.clipboard.writeText(props.path);
snackbar.success(t("rom.location-copied"), { icon: "mdi-check-bold" });
} catch {
snackbar.error(t("common.clipboard-unavailable"), {
icon: "mdi-close-circle",
});
}
}
</script>
<template>
<button
type="button"
class="r-v2-location-chip"
:title="`${t('rom.location')}: ${path}`"
:aria-label="`${t('rom.location')}: ${path}`"
@click="copy"
>
<RTag
prepend-icon="mdi-map-marker-outline"
:text="path"
append-icon="mdi-content-copy"
size="small"
mono
/>
</button>
</template>
<style scoped>
/* The button is just the interactive shell — RTag owns the visuals.
Stripping native chrome so hover / active styles lean on the inner tag. */
.r-v2-location-chip {
appearance: none;
border: 0;
background: transparent;
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
display: inline-flex;
min-width: 0;
max-width: 100%;
border-radius: var(--r-radius-chip);
transition: transform var(--r-motion-fast) var(--r-motion-ease-out);
}
.r-v2-location-chip:hover :deep(.r-tag) {
border-color: var(--r-color-fg-muted);
background: var(--r-color-surface-hover);
}
.r-v2-location-chip:active {
transform: scale(0.98);
}
/* Ellipsis-truncate the path text rather than mid-abbreviate it. */
.r-v2-location-chip :deep(.r-tag) {
max-width: 100%;
}
.r-v2-location-chip :deep(.r-tag__text) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>