mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -405,5 +405,7 @@
|
||||
"screenshot-make-public": "Направи публична",
|
||||
"screenshot-make-private": "Направи лична",
|
||||
"screenshot-public": "Публична",
|
||||
"screenshot-visibility-failed": "Видимостта не може да се промени: {error}"
|
||||
"screenshot-visibility-failed": "Видимостта не може да се промени: {error}",
|
||||
"location": "Местоположение",
|
||||
"location-copied": "Местоположението е копирано в клипборда."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -405,5 +405,7 @@
|
||||
"screenshot-make-public": "公開する",
|
||||
"screenshot-make-private": "非公開にする",
|
||||
"screenshot-public": "公開",
|
||||
"screenshot-visibility-failed": "表示設定を更新できませんでした:{error}"
|
||||
"screenshot-visibility-failed": "表示設定を更新できませんでした:{error}",
|
||||
"location": "場所",
|
||||
"location-copied": "場所をクリップボードにコピーしました。"
|
||||
}
|
||||
|
||||
@@ -405,5 +405,7 @@
|
||||
"screenshot-make-public": "공개로 전환",
|
||||
"screenshot-make-private": "비공개로 전환",
|
||||
"screenshot-public": "공개",
|
||||
"screenshot-visibility-failed": "공개 설정을 변경할 수 없습니다: {error}"
|
||||
"screenshot-visibility-failed": "공개 설정을 변경할 수 없습니다: {error}",
|
||||
"location": "위치",
|
||||
"location-copied": "위치를 클립보드에 복사했습니다."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -405,5 +405,7 @@
|
||||
"screenshot-make-public": "Сделать публичной",
|
||||
"screenshot-make-private": "Сделать приватной",
|
||||
"screenshot-public": "Публичная",
|
||||
"screenshot-visibility-failed": "Не удалось изменить видимость: {error}"
|
||||
"screenshot-visibility-failed": "Не удалось изменить видимость: {error}",
|
||||
"location": "Расположение",
|
||||
"location-copied": "Расположение скопировано в буфер обмена."
|
||||
}
|
||||
|
||||
@@ -405,5 +405,7 @@
|
||||
"screenshot-make-public": "设为公开",
|
||||
"screenshot-make-private": "设为私密",
|
||||
"screenshot-public": "公开",
|
||||
"screenshot-visibility-failed": "无法更新可见性:{error}"
|
||||
"screenshot-visibility-failed": "无法更新可见性:{error}",
|
||||
"location": "位置",
|
||||
"location-copied": "位置已复制到剪贴板。"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user