diff --git a/Dockerfile b/Dockerfile index 69ff807b8..74c0b26f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ git \ make \ + cmake \ gcc \ g++ \ libmariadb3 \ @@ -77,6 +78,24 @@ RUN uv sync --all-extras ENV PATH="/app/.venv/bin:${PATH}" +# Build and install sigil (optional, for title ID extraction) +# Placed after `uv sync` because the extension is compiled with the venv's +# Python so the ABI matches. +ARG SIGIL_VERSION=9665f03c04d0f547ed38dd5e5e31916c1da5f2e9 +RUN git clone --recurse-submodules https://github.com/rommforge/argosy-sigil.git /tmp/argosy-sigil +WORKDIR /tmp/argosy-sigil +RUN git checkout "${SIGIL_VERSION}" \ + && git submodule update --init --recursive \ + && cmake -B ./build-python -S . -DSIGIL_BUILD_CLI=OFF -DSIGIL_BUILD_TESTS=OFF \ + && cmake --build ./build-python --target sigil +WORKDIR /tmp/argosy-sigil/bindings/python +RUN uv pip install --python /app/.venv/bin/python cffi setuptools \ + && /app/.venv/bin/python build_sigil.py \ + && mkdir -p /app/.venv/lib/python3.13/site-packages/sigil \ + && cp ./sigil/*.py ./sigil/_sigil.*.so /app/.venv/lib/python3.13/site-packages/sigil/ +WORKDIR /app +RUN rm -rf /tmp/argosy-sigil + # Copy entrypoint script COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/backend/adapters/services/sigil.py b/backend/adapters/services/sigil.py new file mode 100644 index 000000000..569dcd121 --- /dev/null +++ b/backend/adapters/services/sigil.py @@ -0,0 +1,101 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, Final + +from handler.metadata.base_handler import UniversalPlatformSlug as UPS +from logger.logger import log + +try: + import sigil +except ImportError: + sigil = None # type: ignore[assignment] + log.debug("sigil binding not installed, title id extraction disabled") + +SWITCH_SIGIL_SLUG: Final = "switch" + +SIGIL_PLATFORM_SLUGS: Final[dict[UPS, str]] = { + UPS.PSP: "psp", + UPS.PSX: "psx", + UPS.PS2: "ps2", + UPS.PSVITA: "psvita", + UPS.SWITCH: SWITCH_SIGIL_SLUG, + UPS.SWITCH_2: SWITCH_SIGIL_SLUG, + UPS.N3DS: "3ds", + UPS.WII: "wii", + UPS.WIIU: "wiiu", + UPS.NGC: "gamecube", + UPS.DC: "dreamcast", + UPS.PS3: "ps3", + UPS.XBOX: "xbox", + UPS.XBOX360: "xbox360", +} + +# The Switch family needs prod.keys to decrypt headers, and is the only family +# whose files may have their title id embedded in the filename. +SWITCH_PLATFORM_SLUGS: Final = frozenset( + slug + for slug, sigil_slug in SIGIL_PLATFORM_SLUGS.items() + if sigil_slug == SWITCH_SIGIL_SLUG +) + +# Errors that are expected for arbitrary library files (no title id present, +# format sigil can't parse, missing decryption keys). Logged at debug level. +ROUTINE_SIGIL_ERROR_CODES: Final = frozenset( + {"NOT_FOUND", "UNSUPPORTED", "UNSUPPORTED_FORMAT", "NEEDS_KEY"} +) + + +@dataclass(frozen=True) +class SigilExtractionResult: + title_id: str + save_id: str + usage: str + content_type: str | None = None + version: int | None = None + + +class SigilService: + """Service to extract platform-native title ids from ROM binaries via the + optional `sigil` cffi binding.""" + + async def extract_title_id( + self, + platform_slug: UPS | str, + file_path: str, + prod_keys_path: str | None = None, + ) -> SigilExtractionResult | None: + if sigil is None: + return None + + sigil_slug = SIGIL_PLATFORM_SLUGS.get(platform_slug) # type: ignore[arg-type] + if sigil_slug is None: + return None + + kwargs: dict[str, Any] = {"platform": sigil_slug, "filename_fallback": False} + if sigil_slug == SWITCH_SIGIL_SLUG: + kwargs["prod_keys_path"] = prod_keys_path + + try: + result = await asyncio.to_thread(sigil.extract, file_path, **kwargs) + except Exception as exc: + code = getattr(exc, "code", None) + if code in ROUTINE_SIGIL_ERROR_CODES: + log.debug(f"Sigil found no title id for {file_path}: {code}") + else: + log.error(f"Sigil extraction failed for {file_path}: {exc}") + return None + + raw_content_type = getattr(result, "switch_content_type", None) + content_type = ( + raw_content_type if raw_content_type not in (None, "", "unknown") else None + ) + + return SigilExtractionResult( + title_id=result.title_id, + save_id=result.save_id, + usage=result.usage, + content_type=content_type, + # Version 0 is a valid base-game version, so keep the int as-is; + # a missing field (non-Switch, older binding) maps to None. + version=getattr(result, "title_version", None), + ) diff --git a/backend/alembic/versions/0113_sigil_title_ids.py b/backend/alembic/versions/0113_sigil_title_ids.py new file mode 100644 index 000000000..db3c62d14 --- /dev/null +++ b/backend/alembic/versions/0113_sigil_title_ids.py @@ -0,0 +1,100 @@ +"""Add sigil-extracted title id columns: title_id/save_id/title_version on +rom_files, plus rom-level title_id/save_id/save_usage on roms. + +Revision ID: 0113_sigil_title_ids +Revises: 0112_publisher_developer_split +Create Date: 2026-07-23 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import ENUM + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0113_sigil_title_ids" +down_revision = "0112_publisher_developer_split" +branch_labels = None +depends_on = None + +SAVE_USAGE_VALUES = ( + "FOLDER_EXACT", + "FOLDER_PREFIX", + "FILE_EXACT", + "FILE_PREFIX", + "FOLDER_SPLIT", +) + + +def _save_usage_enum(connection) -> sa.Enum: + if is_postgresql(connection): + enum = ENUM(*SAVE_USAGE_VALUES, name="saveusage", create_type=False) + enum.create(connection, checkfirst=True) + return enum + return sa.Enum(*SAVE_USAGE_VALUES, name="saveusage") + + +def upgrade() -> None: + connection = op.get_bind() + save_usage_enum = _save_usage_enum(connection) + + with op.batch_alter_table("rom_files", schema=None) as batch_op: + batch_op.add_column( + sa.Column("title_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + batch_op.add_column( + sa.Column("save_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + # BigInteger: Switch title versions are u32 and can exceed signed int32. + batch_op.add_column( + sa.Column("title_version", sa.BigInteger(), nullable=True), + if_not_exists=True, + ) + batch_op.create_index( + "idx_rom_files_title_id", + ["title_id"], + unique=False, + if_not_exists=True, + ) + + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column( + sa.Column("title_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + batch_op.add_column( + sa.Column("save_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + batch_op.add_column( + sa.Column("save_usage", save_usage_enum, nullable=True), + if_not_exists=True, + ) + batch_op.create_index( + "idx_roms_title_id", + ["title_id"], + unique=False, + if_not_exists=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_index("idx_roms_title_id", if_exists=True) + batch_op.drop_column("save_usage", if_exists=True) + batch_op.drop_column("save_id", if_exists=True) + batch_op.drop_column("title_id", if_exists=True) + + with op.batch_alter_table("rom_files", schema=None) as batch_op: + batch_op.drop_index("idx_rom_files_title_id", if_exists=True) + batch_op.drop_column("title_version", if_exists=True) + batch_op.drop_column("save_id", if_exists=True) + batch_op.drop_column("title_id", if_exists=True) + + connection = op.get_bind() + if is_postgresql(connection): + ENUM(name="saveusage").drop(connection, checkfirst=True) diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index e12f7222e..08af23e1e 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -187,6 +187,8 @@ class Config: ROMS_FOLDER_NAME: str FIRMWARE_FOLDER_NAME: str SKIP_HASH_CALCULATION: bool + SKIP_TITLE_ID_EXTRACTION: bool + EMBED_SWITCH_TITLE_IDS: bool EJS_DEBUG: bool EJS_CACHE_LIMIT: int | None EJS_DISABLE_AUTO_UNLOAD: bool @@ -439,6 +441,12 @@ def _parse_config(self): SKIP_HASH_CALCULATION=pydash.get( self._raw_config, "filesystem.skip_hash_calculation", False ), + SKIP_TITLE_ID_EXTRACTION=pydash.get( + self._raw_config, "filesystem.skip_title_id_extraction", False + ), + EMBED_SWITCH_TITLE_IDS=pydash.get( + self._raw_config, "filesystem.embed_switch_title_ids", False + ), EJS_DEBUG=pydash.get(self._raw_config, "emulatorjs.debug", False), EJS_CACHE_LIMIT=pydash.get( self._raw_config, "emulatorjs.cache_limit", None @@ -875,6 +883,8 @@ def _update_config_file(self) -> None: "roms_folder": self.config.ROMS_FOLDER_NAME, "firmware_folder": self.config.FIRMWARE_FOLDER_NAME, "skip_hash_calculation": self.config.SKIP_HASH_CALCULATION, + "skip_title_id_extraction": self.config.SKIP_TITLE_ID_EXTRACTION, + "embed_switch_title_ids": self.config.EMBED_SWITCH_TITLE_IDS, }, "system": { "platforms": self.config.PLATFORMS_BINDING, diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 63a5ba8a5..b345d7df2 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -33,6 +33,7 @@ RomFile, RomFileCategory, RomUserStatus, + SaveUsage, ) from .base import BaseModel, UTCDatetime @@ -220,6 +221,9 @@ class RomFileSchema(BaseModel): sha1_hash: str | None ra_hash: str | None chd_sha1_hash: str | None + title_id: str | None + save_id: str | None + title_version: int | None archive_members: list[RomArchiveMember] | None category: RomFileCategory | None track_meta: TrackMetaSchema | None = None @@ -371,6 +375,9 @@ class RomSchema(BaseModel): md5_hash: str | None sha1_hash: str | None ra_hash: str | None + title_id: str | None + save_id: str | None + save_usage: SaveUsage | None has_simple_single_file: bool has_nested_single_file: bool diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index a6c32ca76..a8177e26c 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -10,7 +10,15 @@ from rq.job import Job, JobStatus from sqlalchemy.exc import IntegrityError -from config import DEV_MODE, REDIS_URL, SCAN_TIMEOUT, SCAN_WORKERS, TASK_RESULT_TTL +from adapters.services.sigil import SWITCH_PLATFORM_SLUGS +from config import ( + DEV_MODE, + LIBRARY_BASE_PATH, + REDIS_URL, + SCAN_TIMEOUT, + SCAN_WORKERS, + TASK_RESULT_TTL, +) from config.config_manager import MetadataMediaType from config.config_manager import config_manager as cm from endpoints.responses import TaskType @@ -37,7 +45,11 @@ fs_resource_handler, fs_rom_handler, ) -from handler.filesystem.roms_handler import FSRom +from handler.filesystem.roms_handler import ( + FSRom, + ParsedRomFiles, + ScannedFSRomValues, +) from handler.metadata import ( meta_gamelist_handler, meta_hltb_handler, @@ -373,6 +385,32 @@ def _should_hash_firmware( ) +def _resolve_prod_keys_path(platform: Platform) -> str | None: + """Locate the console's prod.keys, which sigil needs to decrypt Switch + XCI/NSP headers, among the platform's scanned firmware.""" + if platform.slug not in SWITCH_PLATFORM_SLUGS: + return None + + prod_keys = db_firmware_handler.get_firmware_by_filename(platform.id, "prod.keys") + return f"{LIBRARY_BASE_PATH}/{prod_keys.full_path}" if prod_keys else None + + +def _apply_scanned_values(fs_rom: FSRom, parsed: ParsedRomFiles) -> None: + values = ScannedFSRomValues( + files=parsed.rom_files, + crc_hash=parsed.crc_hash, + md5_hash=parsed.md5_hash, + sha1_hash=parsed.sha1_hash, + ra_hash=parsed.ra_hash, + title_id=parsed.title_id, + save_id=parsed.save_id, + save_usage=parsed.save_usage, + ) + if parsed.renamed_rom_fs_name: + values["fs_name"] = parsed.renamed_rom_fs_name + fs_rom.update(values) + + # There's an order of operations here that is important: # 1. Read the list of roms from the filesystem # 2. Check if ROM should be scanned based on the scan type @@ -390,6 +428,7 @@ async def _identify_rom( playmatch_enabled: bool, socket_manager: socketio.AsyncRedisManager, scan_stats: ScanStats, + prod_keys_path: str | None = None, ) -> None: # Break early if the flag is set if redis_client.get(STOP_SCAN_FLAG): @@ -414,7 +453,10 @@ async def _identify_rom( "url_screenshots": [], } - calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION + cnfg = cm.get_config() + calculate_hashes = not cnfg.SKIP_HASH_CALCULATION + extract_title_ids = not cnfg.SKIP_TITLE_ID_EXTRACTION + embed_title_ids = cnfg.EMBED_SWITCH_TITLE_IDS newly_added: bool = rom is None reassociated: bool = False @@ -431,16 +473,14 @@ async def _identify_rom( platform=platform, ), calculate_hashes=calculate_hashes, + extract_title_ids=extract_title_ids, + prod_keys_path=prod_keys_path, + embed_title_ids=embed_title_ids, ) - fs_rom.update( - { - "files": parsed_rom_files.rom_files, - "crc_hash": parsed_rom_files.crc_hash, - "md5_hash": parsed_rom_files.md5_hash, - "sha1_hash": parsed_rom_files.sha1_hash, - "ra_hash": parsed_rom_files.ra_hash, - } - ) + _apply_scanned_values(fs_rom, parsed_rom_files) + # The new-entry insert reads its name from rom_attrs, not fs_rom. + if parsed_rom_files.renamed_rom_fs_name: + rom_attrs["fs_name"] = parsed_rom_files.renamed_rom_fs_name files_built = True missing_match = db_rom_handler.get_matching_missing_rom( @@ -448,6 +488,7 @@ async def _identify_rom( crc_hash=parsed_rom_files.crc_hash, md5_hash=parsed_rom_files.md5_hash, sha1_hash=parsed_rom_files.sha1_hash, + title_id=parsed_rom_files.title_id, ) if missing_match is not None: # Move the existing entry onto the new file, clearing its missing state. @@ -505,17 +546,16 @@ async def _identify_rom( log.debug(f"Calculating file hashes for {rom.fs_name}...") parsed_rom_files = await fs_rom_handler.get_rom_files( - rom, calculate_hashes=calculate_hashes - ) - fs_rom.update( - { - "files": parsed_rom_files.rom_files, - "crc_hash": parsed_rom_files.crc_hash, - "md5_hash": parsed_rom_files.md5_hash, - "sha1_hash": parsed_rom_files.sha1_hash, - "ra_hash": parsed_rom_files.ra_hash, - } + rom, + calculate_hashes=calculate_hashes, + extract_title_ids=extract_title_ids, + prod_keys_path=prod_keys_path, + embed_title_ids=embed_title_ids, ) + _apply_scanned_values(fs_rom, parsed_rom_files) + # Keep the in-memory rom's identity matching the renamed file. + if parsed_rom_files.renamed_rom_fs_name: + rom.fs_name = parsed_rom_files.renamed_rom_fs_name # For a COMPLETE rescan, wipe all downloaded resources before re-fetching so # stale files (e.g. a cover from the wrong region) can't be reused. The @@ -654,6 +694,7 @@ async def _scan_selected_roms( ) scan_semaphore = asyncio.Semaphore(SCAN_WORKERS) + prod_keys_path = _resolve_prod_keys_path(platform) async def scan_rom_with_semaphore(rom: Rom) -> None: async with scan_semaphore: @@ -685,6 +726,7 @@ async def scan_rom_with_semaphore(rom: Rom) -> None: playmatch_enabled=playmatch_enabled, socket_manager=socket_manager, scan_stats=scan_stats, + prod_keys_path=prod_keys_path, ) results = await asyncio.gather( @@ -814,6 +856,7 @@ async def _identify_platform( # Create semaphore to limit concurrent ROM scanning scan_semaphore = asyncio.Semaphore(SCAN_WORKERS) + prod_keys_path = _resolve_prod_keys_path(platform) async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None: """Scan a single ROM with semaphore limiting""" @@ -829,6 +872,7 @@ async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None: playmatch_enabled=playmatch_enabled, socket_manager=socket_manager, scan_stats=scan_stats, + prod_keys_path=prod_keys_path, ) for fs_roms_batch in batched(fs_roms, 200, strict=False): diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index b26060ad0..c954f822b 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -211,6 +211,9 @@ "chd_sha1_hash", "archive_members", "category", + "title_id", + "save_id", + "title_version", ) TRACK_META_SCANNED_COLUMNS = ( @@ -2954,17 +2957,37 @@ def get_matching_missing_rom( crc_hash: str | None = None, md5_hash: str | None = None, sha1_hash: str | None = None, + title_id: str | None = None, session: Session = None, # type: ignore ) -> Rom | None: - """Find a ROM marked missing on a platform whose hashes match the file. + """Find a ROM marked missing on a platform that identifies the file. Used during scanning to reassociate a renamed or moved file with its existing entry (preserving collections, notes, and assets) instead of - creating a duplicate. Requires the CRC, MD5, and SHA1 hashes to all - match. Any missing hash yields no match, so non-hashable platforms and - pre-hash entries safely fall back to creating a new entry. + creating a duplicate. All three hashes must match; any missing hash + falls through to the binary title id, which is what non-hashable + platforms like Switch carry instead. With neither, a new entry is + created. + + Args: + platform_id: Platform whose missing entries are searched. + crc_hash: CRC hash of the scanned file, if hashed. + md5_hash: MD5 hash of the scanned file, if hashed. + sha1_hash: SHA1 hash of the scanned file, if hashed. + title_id: Binary title id of the scanned file, if extracted. + Returns: + The single matching ROM, or None when there is no unambiguous one. """ - if not (crc_hash and md5_hash and sha1_hash): + identity: tuple[ColumnElement[bool], ...] + if crc_hash and md5_hash and sha1_hash: + identity = ( + Rom.crc_hash == crc_hash, + Rom.md5_hash == md5_hash, + Rom.sha1_hash == sha1_hash, + ) + elif title_id: + identity = (Rom.title_id == title_id,) + else: return None matches = session.scalars( @@ -2973,9 +2996,7 @@ def get_matching_missing_rom( and_( Rom.platform_id == platform_id, Rom.missing_from_fs.is_(True), - Rom.crc_hash == crc_hash, - Rom.md5_hash == md5_hash, - Rom.sha1_hash == sha1_hash, + *identity, ) ) .limit(2) diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index bf01afdeb..2918a6aab 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -7,10 +7,16 @@ import zlib from dataclasses import dataclass from pathlib import Path -from typing import Any, TypedDict +from typing import Any, NotRequired, TypedDict from anyio import Path as AnyioPath +from adapters.services.sigil import ( + SIGIL_PLATFORM_SLUGS, + SWITCH_PLATFORM_SLUGS, + SigilExtractionResult, + SigilService, +) from config import LIBRARY_BASE_PATH from config.config_manager import ( DEFAULT_EXCLUDED_EXTENSIONS, @@ -24,7 +30,7 @@ from handler.metadata.base_handler import UniversalPlatformSlug as UPS from logger.logger import log from models.platform import Platform -from models.rom import Rom, RomFile, RomFileCategory, TrackMeta +from models.rom import Rom, RomFile, RomFileCategory, SaveUsage, TrackMeta from utils.archives import ( ArchiveReadError, detect_mime_type, @@ -97,6 +103,23 @@ class FSRom(TypedDict): md5_hash: str sha1_hash: str ra_hash: str + title_id: NotRequired[str | None] + save_id: NotRequired[str | None] + save_usage: NotRequired[SaveUsage | None] + + +class ScannedFSRomValues(TypedDict, total=False): + """The subset of `FSRom` a file rebuild produces, for `FSRom.update`.""" + + fs_name: str + files: list[RomFile] + crc_hash: str + md5_hash: str + sha1_hash: str + ra_hash: str + title_id: str | None + save_id: str | None + save_usage: SaveUsage | None class FileHash(TypedDict): @@ -169,6 +192,113 @@ class ParsedRomFiles: md5_hash: str sha1_hash: str ra_hash: str + title_id: str | None = None + save_id: str | None = None + save_usage: SaveUsage | None = None + # Set when a single-file rom's file was renamed on disk to embed its title + # id, so the caller can reconcile Rom.fs_name. + renamed_rom_fs_name: str | None = None + + +# Maps sigil's Switch CNMT content type to the RomFile category. Authoritative +# over folder-derived categories when the binary parse succeeds. +SWITCH_CONTENT_TYPE_CATEGORIES: dict[str, RomFileCategory] = { + "application": RomFileCategory.GAME, + "patch": RomFileCategory.UPDATE, + "addon": RomFileCategory.DLC, +} + +SWITCH_TITLE_ID_REGEX = re.compile(r"[0-9A-Fa-f]{16}") +# An already-embedded id, e.g. [0100F4700B2E0000]. +SWITCH_TITLE_ID_BRACKET_REGEX = re.compile(rf"\[{SWITCH_TITLE_ID_REGEX.pattern}\]") + + +def _embed_switch_title_id_in_name( + abs_file_path: Path, title_id: str, title_version: int | None +) -> Path | None: + """Rename a Switch ROM file to embed ` [TITLEID][vVERSION]` before the + extension. + + Returns: + The new absolute path, or None when the file was left untouched. + """ + name = abs_file_path.name + + if SWITCH_TITLE_ID_BRACKET_REGEX.search(name): + log.debug(f"{name} already has an embedded title id, skipping rename") + return None + + if not SWITCH_TITLE_ID_REGEX.fullmatch(title_id): + log.debug(f"Title id {title_id!r} is not a 16-hex value, skipping rename") + return None + + version = title_version if title_version is not None else 0 + new_name = ( + f"{abs_file_path.stem} [{title_id.upper()}][v{version}]{abs_file_path.suffix}" + ) + new_path = abs_file_path.with_name(new_name) + + if new_path.exists(): + log.warning( + f"Cannot embed title id: target {new_name} already exists, skipping rename" + ) + return None + + os.rename(abs_file_path, new_path) + log.info(f"Embedded Switch title id: renamed {name} to {new_name}") + return new_path + + +def _parse_save_usage(usage: str) -> SaveUsage | None: + try: + return SaveUsage(usage) + except ValueError: + return None + + +def _derive_switch_base_title_id(title_id: str) -> str | None: + """Derive the base-game title id from an update/DLC id: clear the low 12 + bits and decrement the program-index nibble when it is odd.""" + if len(title_id) < 4: + return None + nibble_char = title_id[-4] + try: + nibble = int(nibble_char, 16) + except ValueError: + return None + if nibble % 2 == 1: + nibble -= 1 + formatted = format(nibble, "x" if nibble_char.islower() else "X") + return f"{title_id[:-4]}{formatted}000" + + +def _is_switch_base_title_id(title_id: str) -> bool: + """A base-game id is its own derived base.""" + return _derive_switch_base_title_id(title_id) == title_id + + +def _rom_level_title_values( + platform_slug: str, + extractions: list[SigilExtractionResult], +) -> tuple[str | None, str | None, SaveUsage | None]: + if not extractions: + return None, None, None + + if platform_slug in SWITCH_PLATFORM_SLUGS: + base = next( + (e for e in extractions if _is_switch_base_title_id(e.title_id)), None + ) + if base is not None: + return base.title_id, base.save_id, _parse_save_usage(base.usage) + + derived = _derive_switch_base_title_id(extractions[0].title_id) + if derived is None: + return None, None, None + # Switch saves are keyed by the base title id itself. + return derived, derived, SaveUsage.FOLDER_EXACT + + first = extractions[0] + return first.title_id, first.save_id, _parse_save_usage(first.usage) class FSRomsHandler(FSHandler): @@ -334,7 +464,12 @@ def _build_rom_file( ) async def get_rom_files( - self, rom: Rom, calculate_hashes: bool = True + self, + rom: Rom, + calculate_hashes: bool = True, + extract_title_ids: bool = True, + prod_keys_path: str | None = None, + embed_title_ids: bool = False, ) -> ParsedRomFiles: from adapters.services.rahasher import RAHasherService from handler.metadata import meta_ra_handler @@ -350,6 +485,66 @@ async def get_rom_files( rom.platform_slug not in NON_HASHABLE_PLATFORMS and calculate_hashes ) + # Title id extraction is independent of hashing support: it covers + # non-hashable platforms like Switch. + sigil_platform = extract_title_ids and rom.platform_slug in SIGIL_PLATFORM_SLUGS + sigil_extractions: list[SigilExtractionResult] = [] + sigil_service = SigilService() + + # Filename embedding is Switch-only, even though sigil covers more + # platforms; other platforms never have their files renamed. + embed_switch = embed_title_ids and rom.platform_slug in SWITCH_PLATFORM_SLUGS + renamed_rom_fs_name: str | None = None + + async def _extract_title_id( + rom_file: RomFile, abs_file_path: Path + ) -> str | None: + """Populate the file's title id fields, returning its new name if + embedding renamed it on disk.""" + if not sigil_platform: + return None + + extraction = await sigil_service.extract_title_id( + rom.platform_slug, str(abs_file_path), prod_keys_path + ) + if extraction is None: + return None + rom_file.title_id = extraction.title_id + rom_file.save_id = extraction.save_id + rom_file.title_version = extraction.version + if extraction.content_type is not None: + category = SWITCH_CONTENT_TYPE_CATEGORIES.get(extraction.content_type) + if category is not None: + rom_file.category = category + sigil_extractions.append(extraction) + + if not (embed_switch and extraction.title_id): + return None + + new_path = _embed_switch_title_id_in_name( + abs_file_path, extraction.title_id, extraction.version + ) + if new_path is None: + return None + rom_file.file_name = new_path.name + return new_path.name + + async def _append_rom_level_file(file_hash: FileHash) -> str | None: + """Emit the single RomFile that stands for the whole rom, returning + its new name if embedding renamed it on disk.""" + rom_file = self._build_rom_file( + rom=rom, + rom_path=Path(rel_roms_path), + file_name=rom.fs_name, + file_hash=file_hash, + ) + rom_files.append(rom_file) + # Archives keep hashes only; sigil reads title ids from the ROM + # binary itself. + if rom_ext in ARCHIVE_READERS: + return None + return await _extract_title_id(rom_file, rom_dir) + cnfg = cm.get_config() excluded_file_names = cnfg.EXCLUDED_MULTI_PARTS_FILES excluded_file_exts = cnfg.EXCLUDED_MULTI_PARTS_EXT @@ -450,14 +645,16 @@ def _largest_chd_file() -> Path | None: chd_sha1_hash="", ) - rom_files.append( - self._build_rom_file( - rom=rom, - rom_path=f_path.relative_to(self.base_path), - file_name=file_name, - file_hash=file_hash, - ) + rom_file = self._build_rom_file( + rom=rom, + rom_path=f_path.relative_to(self.base_path), + file_name=file_name, + file_hash=file_hash, ) + # Extract from every file (base, updates, DLC in subfolders), not + # just the top-level one: sigil returns None for unparseable files. + await _extract_title_id(rom_file, abs_file_path) + rom_files.append(rom_file) elif hashable_platform and rom_ext in ARCHIVE_READERS: # Multi-file archive: compute a composite hash across all # internal entries (in ASCII path order) for hash-database @@ -579,36 +776,28 @@ def _hash_raw_archive(crc: int) -> int: f"{abs_fs_path}/{rom.fs_name}", ) - file_hash = _make_file_hash( - crc_c, - md5_h, - sha1_h, - chd_sha1_hash=_chd_sha1_hash(rom_dir), - ) - rom_files.append( - self._build_rom_file( - rom=rom, - rom_path=Path(rel_roms_path), - file_name=rom.fs_name, - file_hash=file_hash, + renamed_rom_fs_name = await _append_rom_level_file( + _make_file_hash( + crc_c, + md5_h, + sha1_h, + chd_sha1_hash=_chd_sha1_hash(rom_dir), ) ) else: - file_hash = FileHash( - crc_hash="", - md5_hash="", - sha1_hash="", - chd_sha1_hash="", - ) - rom_files.append( - self._build_rom_file( - rom=rom, - rom_path=Path(rel_roms_path), - file_name=rom.fs_name, - file_hash=file_hash, + renamed_rom_fs_name = await _append_rom_level_file( + FileHash( + crc_hash="", + md5_hash="", + sha1_hash="", + chd_sha1_hash="", ) ) + rom_title_id, rom_save_id, rom_save_usage = _rom_level_title_values( + rom.platform_slug, sigil_extractions + ) + return ParsedRomFiles( rom_files=rom_files, crc_hash=crc32_to_hex(rom_crc_c) if rom_crc_c != DEFAULT_CRC_C else "", @@ -623,6 +812,10 @@ def _hash_raw_archive(crc: int) -> int: else "" ), ra_hash=rom_ra_h, + title_id=rom_title_id, + save_id=rom_save_id, + save_usage=rom_save_usage, + renamed_rom_fs_name=renamed_rom_fs_name, ) def _calculate_rom_hashes( diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 80652bb55..744042bf8 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -454,6 +454,9 @@ async def scan_rom( "md5_hash": rom.md5_hash, "sha1_hash": rom.sha1_hash, "ra_hash": rom.ra_hash, + "title_id": rom.title_id, + "save_id": rom.save_id, + "save_usage": rom.save_usage, "fs_size_bytes": rom.fs_size_bytes, "is_physical": rom.is_physical, "upc": rom.upc, @@ -472,6 +475,17 @@ async def scan_rom( } ) + # Only overwrite title id values when extraction produced them, so a + # hash-only or extraction-disabled rescan can't wipe existing ones. + if fs_rom.get("title_id"): + rom_attrs.update( + { + "title_id": fs_rom.get("title_id"), + "save_id": fs_rom.get("save_id"), + "save_usage": fs_rom.get("save_usage"), + } + ) + # Update properties from existing rom if not a complete rescan if not newly_added and scan_type != ScanType.COMPLETE: rom_attrs.update( diff --git a/backend/models/rom.py b/backend/models/rom.py index 02d923367..aca43b048 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -108,6 +108,14 @@ class RomFileCategory(enum.StrEnum): SCREENSHOT = "screenshot" +class SaveUsage(enum.StrEnum): + FOLDER_EXACT = "folder-exact" + FOLDER_PREFIX = "folder-prefix" + FILE_EXACT = "file-exact" + FILE_PREFIX = "file-prefix" + FOLDER_SPLIT = "folder-split" + + # Document-category files (manuals, walkthroughs) share one substrate: a # RomFile plus an optional RomFileDocMeta sidecar for provenance. DOCUMENT_CATEGORIES = frozenset({RomFileCategory.MANUAL, RomFileCategory.WALKTHROUGH}) @@ -153,6 +161,7 @@ class RomFile(BaseModel): __table_args__ = ( Index("idx_rom_files_rom_id", "rom_id"), + Index("idx_rom_files_title_id", "title_id"), Index("idx_rom_files_rom_id_category", "rom_id", "category"), # Searching the gallery by a hash digest Index("idx_rom_files_crc_hash", "crc_hash"), @@ -173,6 +182,10 @@ class RomFile(BaseModel): sha1_hash: Mapped[str | None] = mapped_column(String(100)) ra_hash: Mapped[str | None] = mapped_column(String(100)) chd_sha1_hash: Mapped[str | None] = mapped_column(String(100)) + title_id: Mapped[str | None] = mapped_column(String(100)) + save_id: Mapped[str | None] = mapped_column(String(100)) + # BigInteger: Switch title versions are u32 and can exceed signed int32. + title_version: Mapped[int | None] = mapped_column(BigInteger, default=None) archive_members: Mapped[list[RomArchiveMember] | None] = mapped_column( CustomJSON(), default=None, nullable=True ) @@ -512,6 +525,7 @@ class Rom(BaseModel): Index("idx_roms_hltb_id", "hltb_id"), Index("idx_roms_gamelist_id", "gamelist_id"), Index("idx_roms_libretro_id", "libretro_id"), + Index("idx_roms_title_id", "title_id"), # Searching the gallery by a hash digest Index("idx_roms_crc_hash", "crc_hash"), Index("idx_roms_md5_hash", "md5_hash"), @@ -610,6 +624,9 @@ class Rom(BaseModel): md5_hash: Mapped[str | None] = mapped_column(String(length=100)) sha1_hash: Mapped[str | None] = mapped_column(String(length=100)) ra_hash: Mapped[str | None] = mapped_column(String(length=100)) + title_id: Mapped[str | None] = mapped_column(String(length=100)) + save_id: Mapped[str | None] = mapped_column(String(length=100)) + save_usage: Mapped[SaveUsage | None] = mapped_column(Enum(SaveUsage), default=None) missing_from_fs: Mapped[bool] = mapped_column(default=False, nullable=False) diff --git a/backend/tests/adapters/services/test_sigil.py b/backend/tests/adapters/services/test_sigil.py new file mode 100644 index 000000000..6f5f0f715 --- /dev/null +++ b/backend/tests/adapters/services/test_sigil.py @@ -0,0 +1,206 @@ +import types +from unittest.mock import Mock + +import pytest + +import adapters.services.sigil as sigil_adapter +from adapters.services.sigil import ( + SIGIL_PLATFORM_SLUGS, + SigilExtractionResult, + SigilService, +) +from handler.metadata.base_handler import UniversalPlatformSlug as UPS + + +class FakeSigilError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def make_fake_sigil(extract: Mock) -> types.SimpleNamespace: + return types.SimpleNamespace(SigilError=FakeSigilError, extract=extract) + + +def make_result( + title_id: str = "0100ABCD12340000", + save_id: str = "0100ABCD12340000", + usage: str = "folder-exact", + switch_content_type: str | None = None, + title_version: int | None = None, +) -> types.SimpleNamespace: + return types.SimpleNamespace( + title_id=title_id, + raw_serial="serial", + save_id=save_id, + platform="switch", + source="binary", + usage=usage, + experimental=False, + switch_content_type=switch_content_type, + title_version=title_version, + ) + + +class TestSigilService: + @pytest.fixture + def service(self): + return SigilService() + + @pytest.mark.asyncio + async def test_returns_none_when_binding_absent( + self, service: SigilService, monkeypatch + ): + monkeypatch.setattr(sigil_adapter, "sigil", None) + + result = await service.extract_title_id(UPS.SWITCH, "/roms/switch/game.nsp") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_unsupported_platform( + self, service: SigilService, monkeypatch + ): + extract = Mock(return_value=make_result()) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.N64, "/roms/n64/game.z64") + + assert result is None + extract.assert_not_called() + + @pytest.mark.asyncio + async def test_successful_extraction_maps_fields( + self, service: SigilService, monkeypatch + ): + extract = Mock( + return_value=make_result( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + ) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.SWITCH, "/roms/switch/game.nsp") + + assert result == SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + @pytest.mark.asyncio + async def test_maps_switch_content_type_and_version( + self, service: SigilService, monkeypatch + ): + extract = Mock( + return_value=make_result( + switch_content_type="patch", + title_version=196608, + ) + ) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.SWITCH, "/roms/switch/game.nsp") + + assert result is not None + assert result.content_type == "patch" + assert result.version == 196608 + + @pytest.mark.asyncio + async def test_version_zero_is_preserved(self, service: SigilService, monkeypatch): + extract = Mock( + return_value=make_result( + switch_content_type="application", + title_version=0, + ) + ) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.SWITCH, "/roms/switch/game.xci") + + assert result is not None + assert result.content_type == "application" + assert result.version == 0 + + @pytest.mark.asyncio + @pytest.mark.parametrize("raw", ["unknown", "", None]) + async def test_absent_content_type_maps_to_none( + self, service: SigilService, monkeypatch, raw: str | None + ): + extract = Mock(return_value=make_result(switch_content_type=raw)) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.SWITCH, "/roms/switch/game.nsp") + + assert result is not None + assert result.content_type is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("code", ["NOT_FOUND", "UNSUPPORTED_FORMAT", "NEEDS_KEY"]) + async def test_routine_sigil_error_returns_none( + self, service: SigilService, monkeypatch, code: str + ): + extract = Mock(side_effect=FakeSigilError(code)) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.PSX, "/roms/psx/game.bin") + + assert result is None + + @pytest.mark.asyncio + async def test_unexpected_error_returns_none( + self, service: SigilService, monkeypatch + ): + extract = Mock(side_effect=OSError("native crash")) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + result = await service.extract_title_id(UPS.PS2, "/roms/ps2/game.iso") + + assert result is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("platform_slug", [UPS.SWITCH, UPS.SWITCH_2]) + async def test_prod_keys_passed_for_switch_platforms( + self, service: SigilService, monkeypatch, platform_slug: UPS + ): + extract = Mock(return_value=make_result()) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + await service.extract_title_id( + platform_slug, "/roms/switch/game.xci", "/bios/switch/prod.keys" + ) + + extract.assert_called_once_with( + "/roms/switch/game.xci", + platform="switch", + filename_fallback=False, + prod_keys_path="/bios/switch/prod.keys", + ) + + @pytest.mark.asyncio + async def test_prod_keys_not_passed_for_non_switch_platforms( + self, service: SigilService, monkeypatch + ): + extract = Mock(return_value=make_result(usage="file-exact")) + monkeypatch.setattr(sigil_adapter, "sigil", make_fake_sigil(extract)) + + await service.extract_title_id( + UPS.PSP, "/roms/psp/game.iso", "/bios/switch/prod.keys" + ) + + extract.assert_called_once_with( + "/roms/psp/game.iso", + platform="psp", + filename_fallback=False, + ) + + def test_platform_slug_mapping(self): + assert SIGIL_PLATFORM_SLUGS[UPS.SWITCH_2] == "switch" + assert SIGIL_PLATFORM_SLUGS[UPS.N3DS] == "3ds" + assert SIGIL_PLATFORM_SLUGS[UPS.NGC] == "gamecube" + assert SIGIL_PLATFORM_SLUGS[UPS.DC] == "dreamcast" + assert SIGIL_PLATFORM_SLUGS[UPS.PS3] == "ps3" + assert SIGIL_PLATFORM_SLUGS[UPS.XBOX] == "xbox" + assert SIGIL_PLATFORM_SLUGS[UPS.XBOX360] == "xbox360" diff --git a/backend/tests/endpoints/sockets/test_scan.py b/backend/tests/endpoints/sockets/test_scan.py index fe93ec1d6..74906ee2e 100644 --- a/backend/tests/endpoints/sockets/test_scan.py +++ b/backend/tests/endpoints/sockets/test_scan.py @@ -21,7 +21,7 @@ from exceptions.fs_exceptions import FolderStructureNotMatchException from exceptions.socket_exceptions import ScanStoppedException from handler.auth.constants import Scope -from handler.database.roms_handler import SyncedRomFiles +from handler.database.roms_handler import ROM_FILE_SCANNED_COLUMNS, SyncedRomFiles from handler.filesystem.roms_handler import ( FSRom, FSRomsHandler, @@ -32,7 +32,7 @@ from handler.scan_handler import MetadataSource, ScanType from models.firmware import Firmware from models.platform import Platform -from models.rom import Rom +from models.rom import Rom, RomFile, RomFileCategory def test_scan_stats(): @@ -723,6 +723,79 @@ async def test_stop_scan_handler_does_not_cancel_when_unauthorized( get_jobs.assert_not_called() +def patch_identify_rom( + mocker, + *, + parsed: ParsedRomFiles, + roms_fs_structure: str, + name_with_no_tags: str, + platform_slug: str, + **config_flags: bool, +) -> tuple[Mock, Platform]: + """Stub out everything `_identify_rom` reaches so only the wiring under test + is exercised, returning the patched db handler and the platform to scan.""" + mocker.patch.object(scan_module, "redis_client", Mock(get=Mock(return_value=None))) + + fs = scan_module.fs_rom_handler + mocker.patch.object( + fs, + "parse_tags", + return_value=ParsedTags( + version="", revision="", regions=[], languages=[], other_tags=[] + ), + ) + mocker.patch.object(fs, "get_roms_fs_structure", return_value=roms_fs_structure) + mocker.patch.object( + fs, "get_file_name_with_no_tags", return_value=name_with_no_tags + ) + mocker.patch.object(fs, "get_rom_files", AsyncMock(return_value=parsed)) + + config = MagicMock() + config.SKIP_HASH_CALCULATION = False + for flag, value in config_flags.items(): + setattr(config, flag, value) + mocker.patch.object(scan_module.cm, "get_config", return_value=config) + + mocker.patch.object( + scan_module, "scan_rom", AsyncMock(return_value=MagicMock(is_identified=False)) + ) + + db = mocker.patch.object(scan_module, "db_rom_handler") + db.add_rom.return_value = MagicMock(is_identified=False, id=99) + + platform = Platform(name=platform_slug, slug=platform_slug, fs_slug=platform_slug) + platform.id = 1 + return db, platform + + +async def run_identify_rom(platform: Platform, fs_rom: FSRom) -> None: + await _identify_rom( + platform=platform, + fs_rom=fs_rom, + rom=None, + scan_type=ScanType.HASHES, + roms_ids=[], + metadata_sources=[], + launchbox_remote_enabled=False, + playmatch_enabled=False, + socket_manager=AsyncMock(), + scan_stats=AsyncMock(), + ) + + +def make_fs_rom(fs_name: str) -> FSRom: + return { + "fs_name": fs_name, + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + } + + class TestIdentifyRomReassociation: """`_identify_rom` reassociates a renamed/moved file with its missing entry. @@ -816,6 +889,7 @@ async def test_reassociates_with_missing_entry(self, patched): crc_hash="crc", md5_hash="md5", sha1_hash="sha1", + title_id=None, ) db.update_rom.assert_called_once() rom_id, data = db.update_rom.call_args.args @@ -825,6 +899,41 @@ async def test_reassociates_with_missing_entry(self, patched): # No brand-new row is inserted; add_rom only persists the scan result. assert db.add_rom.call_count == 1 + async def test_title_id_is_offered_when_the_platform_is_not_hashed( + self, patched, mocker + ): + """Switch files carry no hashes, so the title id is the only identity. + + Without it a renamed Switch file lands as a duplicate and strands the + original entry, along with its collections and notes, as missing. + """ + db = patched + db.get_matching_missing_rom.return_value = None + mocker.patch.object( + scan_module.fs_rom_handler, + "get_rom_files", + AsyncMock( + return_value=ParsedRomFiles( + rom_files=[], + crc_hash="", + md5_hash="", + sha1_hash="", + ra_hash="", + title_id="0100ABCD12340000", + ) + ), + ) + + await self._run(db) + + db.get_matching_missing_rom.assert_called_once_with( + platform_id=1, + crc_hash="", + md5_hash="", + sha1_hash="", + title_id="0100ABCD12340000", + ) + async def test_files_are_reconciled_in_place(self, patched): db = patched db.get_matching_missing_rom.return_value = None @@ -865,6 +974,121 @@ async def test_creates_new_entry_when_no_match(self, patched): assert created.fs_name == "New Name.zip" +class TestIdentifyRomTitleIdEmbedRename: + """`_identify_rom` reconciles Rom.fs_name when a single-file Switch rom is + renamed on disk to embed its title id.""" + + NEW_NAME = "Game [0100ABCD12340000][v0].nsp" + + @pytest.fixture + def patched(self, mocker): + db, platform = patch_identify_rom( + mocker, + parsed=ParsedRomFiles( + rom_files=[], + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + ra_hash="", + title_id="0100ABCD12340000", + renamed_rom_fs_name=self.NEW_NAME, + ), + roms_fs_structure="switch/roms", + name_with_no_tags="Game", + platform_slug="switch", + SKIP_TITLE_ID_EXTRACTION=False, + EMBED_SWITCH_TITLE_IDS=True, + ) + db.get_matching_missing_rom.return_value = None + return db, platform + + async def test_new_entry_carries_embedded_name(self, patched): + db, platform = patched + fs_rom = make_fs_rom("Game.nsp") + + await run_identify_rom(platform, fs_rom) + + # The initial insert carries the renamed on-disk name, and the shared + # fs_rom dict is updated so scan_rom persists the same name. + created = db.add_rom.call_args_list[0].args[0] + assert created.fs_name == self.NEW_NAME + assert fs_rom["fs_name"] == self.NEW_NAME + + +def test_scanned_columns_include_sigil_fields(): + """title_id/save_id/title_version must ride along on `sync_rom_files`. + + These columns are persisted only if listed in `ROM_FILE_SCANNED_COLUMNS`. + Dropping them silently nulls every extracted id/version on a rescan, which + already bit us once for `title_version`. + """ + for column in ("title_id", "save_id", "title_version"): + assert column in ROM_FILE_SCANNED_COLUMNS + + +class TestIdentifyRomPersistsFileTitleVersion: + """`_identify_rom` must feed per-file `title_version` into `sync_rom_files`. + + A Switch update file carries a `title_version` extracted during scan. The + reconciliation in `_identify_rom` hands `fs_rom["files"]` to + `sync_rom_files`, which copies `ROM_FILE_SCANNED_COLUMNS` onto each row; if + the scanned file loses `title_version` before that call, the version is + silently dropped to NULL on persist. A HASHES scan reaches the file-sync + step and returns right after it. + """ + + UPDATE_TITLE_ID = "0100F4700B2E0800" + UPDATE_TITLE_VERSION = 655360 + + @pytest.fixture + def patched(self, mocker): + update_file = RomFile( + file_name="Game [UPD][v655360].nsp", + file_path="switch/roms", + file_size_bytes=1024, + category=RomFileCategory.UPDATE, + title_id=self.UPDATE_TITLE_ID, + title_version=self.UPDATE_TITLE_VERSION, + ) + db, platform = patch_identify_rom( + mocker, + parsed=ParsedRomFiles( + rom_files=[update_file], + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + ra_hash="", + title_id=self.UPDATE_TITLE_ID, + ), + roms_fs_structure="switch/roms", + name_with_no_tags="Game", + platform_slug="switch", + SKIP_TITLE_ID_EXTRACTION=False, + EMBED_SWITCH_TITLE_IDS=False, + ) + # The persist loop calls this per saved file; keep it inert. + mocker.patch.object(scan_module, "persist_soundtrack_cover") + db.get_matching_missing_rom.return_value = None + db.sync_rom_files.side_effect = lambda rom_id, files: SyncedRomFiles( + files=list(files), orphaned_cover_paths=[] + ) + return db, platform + + async def test_rebuilt_file_keeps_title_version(self, patched): + db, platform = patched + + await run_identify_rom(platform, make_fs_rom("Game.nsp")) + + db.sync_rom_files.assert_called_once() + rom_id, synced_files = db.sync_rom_files.call_args.args + assert rom_id == 99 + assert len(synced_files) == 1 + persisted = synced_files[0] + assert isinstance(persisted, RomFile) + assert persisted.title_id == self.UPDATE_TITLE_ID + assert persisted.title_version == self.UPDATE_TITLE_VERSION + + class TestIdentifyPlatformMarksMissingBeforeScan: """`_identify_platform` must flag missing entries before identifying files. diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index 27101330b..0a19e098c 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -3,13 +3,14 @@ import shutil import tempfile from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from hypothesis import assume, given from hypothesis import strategies as st from tests._zipfile_shim import reload_zipfile +from adapters.services.sigil import SigilExtractionResult from config.config_manager import LIBRARY_BASE_PATH, Config from handler.filesystem.base_handler import ( LANGUAGES_BY_SHORTCODE, @@ -18,9 +19,10 @@ from handler.filesystem.roms_handler import ( FileHash, FSRomsHandler, + _embed_switch_title_id_in_name, ) from models.platform import Platform -from models.rom import Rom, RomFile, RomFileCategory +from models.rom import Rom, RomFile, RomFileCategory, SaveUsage from utils.archives import extract_chd_hash @@ -1371,6 +1373,704 @@ async def test_get_rom_files_archive_skips_ra_hash_for_disc_platform( assert parsed.ra_hash == "" +SIGIL_PATCH_TARGET = "adapters.services.sigil.SigilService.extract_title_id" + + +@pytest.fixture +def sigil_config(monkeypatch): + cnfg = Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=[], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=[], + EXCLUDED_MULTI_PARTS_EXT=[], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ) + cnfg.has_structure_path_b = True + monkeypatch.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: cnfg) + return cnfg + + +def make_sigil_handler(tmp_path: Path) -> FSRomsHandler: + handler = FSRomsHandler() + handler.base_path = tmp_path + return handler + + +def make_single_file_rom(tmp_path: Path, platform: Platform, fs_name: str) -> Rom: + roms_path = tmp_path / platform.fs_slug / "roms" + roms_path.mkdir(parents=True, exist_ok=True) + (roms_path / fs_name).write_bytes(b"rom-bytes") + return Rom( + id=1, + fs_name=fs_name, + fs_path=f"{platform.fs_slug}/roms", + platform=platform, + ) + + +def make_multi_part_rom( + tmp_path: Path, platform: Platform, fs_name: str, file_names: list[str] +) -> Rom: + rom_dir = tmp_path / platform.fs_slug / "roms" / fs_name + rom_dir.mkdir(parents=True, exist_ok=True) + for file_name in file_names: + file_path = rom_dir / file_name + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(b"rom-bytes") + return Rom( + id=1, + fs_name=fs_name, + fs_extension="", + fs_path=f"{platform.fs_slug}/roms", + platform=platform, + ) + + +class TestSigilTitleIdExtraction: + """Title id extraction via the sigil adapter during get_rom_files.""" + + @pytest.mark.asyncio + async def test_single_file_extraction_attaches_values( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.nsp") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + mock_extract = AsyncMock(return_value=extraction) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files( + rom, prod_keys_path="/bios/switch/prod.keys" + ) + + assert len(parsed.rom_files) == 1 + assert parsed.rom_files[0].title_id == "0100ABCD12340000" + assert parsed.rom_files[0].save_id == "0100ABCD12340000" + assert parsed.title_id == "0100ABCD12340000" + assert parsed.save_id == "0100ABCD12340000" + assert parsed.save_usage == SaveUsage.FOLDER_EXACT + mock_extract.assert_awaited_once_with( + "switch", + str(tmp_path / "switch/roms/Game.nsp"), + "/bios/switch/prod.keys", + ) + + @pytest.mark.asyncio + async def test_multi_part_extraction_covers_nested_files( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_multi_part_rom( + tmp_path, + platform, + "Zelda", + [ + "Zelda [base].nsp", + "updates/Zelda [update].nsp", + "dlc/Zelda [dlc].nsp", + ], + ) + + async def fake_extract( + platform_slug: str, file_path: str, prod_keys_path: str | None = None + ) -> SigilExtractionResult: + if "update" in file_path: + return SigilExtractionResult( + title_id="0100ABCD12340800", + save_id="0100ABCD12340800", + usage="folder-exact", + content_type="patch", + version=196608, + ) + if "dlc" in file_path: + return SigilExtractionResult( + title_id="0100ABCD12341001", + save_id="0100ABCD12341001", + usage="folder-exact", + content_type="addon", + version=0, + ) + return SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + content_type="application", + version=0, + ) + + mock_extract = AsyncMock(side_effect=fake_extract) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + by_name = {rf.file_name: rf for rf in parsed.rom_files} + + base = by_name["Zelda [base].nsp"] + assert base.title_id == "0100ABCD12340000" + assert base.title_version == 0 + assert base.category == RomFileCategory.GAME + + update = by_name["Zelda [update].nsp"] + assert update.title_id == "0100ABCD12340800" + assert update.title_version == 196608 + assert update.category == RomFileCategory.UPDATE + + dlc = by_name["Zelda [dlc].nsp"] + assert dlc.title_id == "0100ABCD12341001" + assert dlc.title_version == 0 + assert dlc.category == RomFileCategory.DLC + + # All three files, including the nested update/DLC, are extracted. + assert mock_extract.await_count == 3 + + # The base game is still selected for the rom-level values. + assert parsed.title_id == "0100ABCD12340000" + assert parsed.save_id == "0100ABCD12340000" + assert parsed.save_usage == SaveUsage.FOLDER_EXACT + + @pytest.mark.asyncio + async def test_content_type_overrides_folder_category( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + # Folder name "dlc" would otherwise derive category DLC, but the binary + # content type says this is the base application. + rom = make_multi_part_rom(tmp_path, platform, "Zelda", ["dlc/Zelda [base].nsp"]) + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + content_type="application", + version=0, + ) + ) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + assert parsed.rom_files[0].category == RomFileCategory.GAME + + @pytest.mark.asyncio + async def test_folder_category_preserved_when_content_type_absent( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_multi_part_rom( + tmp_path, platform, "Zelda", ["dlc/Zelda [addon].nsp"] + ) + + # CNMT miss: title id is present but content type is None. + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0100ABCD12341001", + save_id="0100ABCD12341001", + usage="folder-exact", + content_type=None, + version=None, + ) + ) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + rom_file = parsed.rom_files[0] + # Folder-derived category survives, and title_version stays None. + assert rom_file.category == RomFileCategory.DLC + assert rom_file.title_version is None + + @pytest.mark.asyncio + async def test_switch_base_derivation_from_update_only_folder( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_multi_part_rom(tmp_path, platform, "Zelda", ["Zelda [update].nsp"]) + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0100ABCD12340800", + save_id="0100ABCD12340800", + usage="folder-exact", + ) + ) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + assert parsed.rom_files[0].title_id == "0100ABCD12340800" + assert parsed.title_id == "0100ABCD12340000" + assert parsed.save_id == "0100ABCD12340000" + assert parsed.save_usage == SaveUsage.FOLDER_EXACT + + @pytest.mark.asyncio + async def test_switch_base_derivation_from_dlc_odd_nibble( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game [DLC].nsp") + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0100ABCD12351064", + save_id="0100ABCD12351064", + usage="folder-exact", + ) + ) + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + assert parsed.title_id == "0100ABCD12350000" + assert parsed.save_id == "0100ABCD12350000" + assert parsed.save_usage == SaveUsage.FOLDER_EXACT + + @pytest.mark.asyncio + async def test_non_switch_platform_uses_first_extraction( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="PlayStation Portable", slug="psp", fs_slug="psp") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.iso") + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="ULUS-10041", + save_id="ULUS10041", + usage="folder-prefix", + ) + ) + + with ( + patch(SIGIL_PATCH_TARGET, mock_extract), + patch( + "adapters.services.rahasher.RAHasherService.calculate_hash", + new_callable=AsyncMock, + return_value="", + ), + ): + parsed = await handler.get_rom_files(rom) + + assert parsed.rom_files[0].title_id == "ULUS-10041" + assert parsed.rom_files[0].save_id == "ULUS10041" + assert parsed.title_id == "ULUS-10041" + assert parsed.save_id == "ULUS10041" + assert parsed.save_usage == SaveUsage.FOLDER_PREFIX + + @pytest.mark.asyncio + async def test_3ds_extraction_maps_folder_split_usage( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo 3DS", slug="3ds", fs_slug="3ds") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.3ds") + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0004000E0011C500", + # save_id carries the on-disk split, not the flat id, and is + # lowercase because that is the case the emulator writes. + save_id="0004000e/0011c500", + usage="folder-split", + ) + ) + + with ( + patch(SIGIL_PATCH_TARGET, mock_extract), + patch( + "adapters.services.rahasher.RAHasherService.calculate_hash", + new_callable=AsyncMock, + return_value="", + ), + ): + parsed = await handler.get_rom_files(rom) + + assert parsed.rom_files[0].save_id == "0004000e/0011c500" + assert parsed.rom_files[0].title_id == "0004000E0011C500" + assert parsed.save_id == "0004000e/0011c500" + assert parsed.title_id == "0004000E0011C500" + assert parsed.save_usage == SaveUsage.FOLDER_SPLIT + + @pytest.mark.asyncio + async def test_dreamcast_extraction_maps_file_prefix_usage( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Dreamcast", slug="dc", fs_slug="dc") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.chd") + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="MK-51035", + save_id="MK-51035", + usage="file-prefix", + ) + ) + + with ( + patch(SIGIL_PATCH_TARGET, mock_extract), + patch( + "adapters.services.rahasher.RAHasherService.calculate_hash", + new_callable=AsyncMock, + return_value="", + ), + ): + parsed = await handler.get_rom_files(rom) + + assert parsed.rom_files[0].title_id == "MK-51035" + assert parsed.rom_files[0].save_id == "MK-51035" + assert parsed.save_id == "MK-51035" + assert parsed.save_usage == SaveUsage.FILE_PREFIX + + @pytest.mark.asyncio + async def test_non_sigil_platform_skips_extraction( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo 64", slug="n64", fs_slug="n64") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.z64") + + mock_extract = AsyncMock() + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom, calculate_hashes=False) + + mock_extract.assert_not_awaited() + assert parsed.rom_files[0].title_id is None + assert parsed.title_id is None + assert parsed.save_usage is None + + @pytest.mark.asyncio + async def test_extraction_disabled_skips_extraction( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.nsp") + + mock_extract = AsyncMock() + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom, extract_title_ids=False) + + mock_extract.assert_not_awaited() + assert parsed.title_id is None + + @pytest.mark.asyncio + async def test_archive_rom_skips_extraction( + self, tmp_path: Path, sigil_config: Config + ): + import io + import zipfile + + reload_zipfile() + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("game.bin", b"fake PSX disc content") + + platform = Platform(name="PlayStation", slug="psx", fs_slug="psx") + handler = make_sigil_handler(tmp_path) + roms_path = tmp_path / "psx" / "roms" + roms_path.mkdir(parents=True) + (roms_path / "game.zip").write_bytes(buf.getvalue()) + rom = Rom( + id=1, + fs_name="game.zip", + fs_path="psx/roms", + platform=platform, + ) + + mock_extract = AsyncMock() + + with ( + patch(SIGIL_PATCH_TARGET, mock_extract), + patch( + "adapters.services.rahasher.RAHasherService.calculate_hash", + new_callable=AsyncMock, + return_value="", + ), + ): + parsed = await handler.get_rom_files(rom) + + mock_extract.assert_not_awaited() + assert parsed.rom_files[0].title_id is None + assert parsed.title_id is None + + @pytest.mark.asyncio + async def test_non_hashable_archive_rom_skips_extraction( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "game.zip") + + mock_extract = AsyncMock() + + with patch(SIGIL_PATCH_TARGET, mock_extract): + parsed = await handler.get_rom_files(rom) + + mock_extract.assert_not_awaited() + assert parsed.title_id is None + + +class TestEmbedSwitchTitleIdInName: + """The pure rename helper that embeds a Switch title id into a filename.""" + + def test_embeds_id_and_version(self, tmp_path: Path): + src = tmp_path / "Moonlighter.xci" + src.write_bytes(b"rom") + + new_path = _embed_switch_title_id_in_name(src, "0100f4700b2e0000", 3) + + assert new_path is not None + assert new_path.name == "Moonlighter [0100F4700B2E0000][v3].xci" + assert new_path.exists() + assert not src.exists() + + def test_version_none_defaults_to_zero(self, tmp_path: Path): + src = tmp_path / "Game.nsp" + src.write_bytes(b"rom") + + new_path = _embed_switch_title_id_in_name(src, "0100ABCD12340000", None) + + assert new_path is not None + assert new_path.name == "Game [0100ABCD12340000][v0].nsp" + + def test_preserves_existing_tags(self, tmp_path: Path): + src = tmp_path / "Game (USA) (Rev 1).xci" + src.write_bytes(b"rom") + + new_path = _embed_switch_title_id_in_name(src, "0100ABCD12340000", 0) + + assert new_path is not None + assert new_path.name == "Game (USA) (Rev 1) [0100ABCD12340000][v0].xci" + + def test_idempotent_when_bracket_already_present(self, tmp_path: Path): + src = tmp_path / "Game [0100ABCD12340000][v0].nsp" + src.write_bytes(b"rom") + + assert _embed_switch_title_id_in_name(src, "0100ABCD12340000", 0) is None + assert src.exists() + + def test_invalid_title_id_skips(self, tmp_path: Path): + src = tmp_path / "Game.nsp" + src.write_bytes(b"rom") + + assert _embed_switch_title_id_in_name(src, "NOT-A-TITLE-ID", 0) is None + assert src.exists() + + def test_collision_skips_and_leaves_original(self, tmp_path: Path): + src = tmp_path / "Game.nsp" + src.write_bytes(b"original") + target = tmp_path / "Game [0100ABCD12340000][v0].nsp" + target.write_bytes(b"existing") + + assert _embed_switch_title_id_in_name(src, "0100ABCD12340000", 0) is None + assert src.read_bytes() == b"original" + assert target.read_bytes() == b"existing" + + +class TestSwitchTitleIdEmbedding: + """Config-gated on-disk renaming of Switch files during get_rom_files.""" + + @pytest.mark.asyncio + async def test_flag_off_leaves_file_unchanged( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.nsp") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=extraction)): + parsed = await handler.get_rom_files(rom, embed_title_ids=False) + + assert parsed.rom_files[0].file_name == "Game.nsp" + assert parsed.renamed_rom_fs_name is None + assert (tmp_path / "switch/roms/Game.nsp").exists() + + @pytest.mark.asyncio + async def test_single_file_renamed_and_reconciled( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Moonlighter.xci") + + extraction = SigilExtractionResult( + title_id="0100F4700B2E0000", + save_id="0100F4700B2E0000", + usage="folder-exact", + version=0, + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=extraction)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + new_name = "Moonlighter [0100F4700B2E0000][v0].xci" + assert parsed.rom_files[0].file_name == new_name + assert parsed.rom_files[0].file_path == "switch/roms" + assert parsed.renamed_rom_fs_name == new_name + assert (tmp_path / "switch/roms" / new_name).exists() + assert not (tmp_path / "switch/roms/Moonlighter.xci").exists() + + @pytest.mark.asyncio + async def test_already_embedded_file_skipped( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + fs_name = "Game [0100ABCD12340000][v0].nsp" + rom = make_single_file_rom(tmp_path, platform, fs_name) + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=extraction)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + assert parsed.rom_files[0].file_name == fs_name + assert parsed.renamed_rom_fs_name is None + assert (tmp_path / "switch/roms" / fs_name).exists() + + @pytest.mark.asyncio + async def test_non_switch_platform_never_renamed( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="PlayStation Portable", slug="psp", fs_slug="psp") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.iso") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=extraction)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + assert parsed.rom_files[0].file_name == "Game.iso" + assert parsed.renamed_rom_fs_name is None + assert (tmp_path / "psp/roms/Game.iso").exists() + + @pytest.mark.asyncio + async def test_collision_target_exists_skips( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.nsp") + target = tmp_path / "switch/roms/Game [0100ABCD12340000][v0].nsp" + target.write_bytes(b"existing") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=extraction)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + assert parsed.rom_files[0].file_name == "Game.nsp" + assert parsed.renamed_rom_fs_name is None + assert (tmp_path / "switch/roms/Game.nsp").read_bytes() == b"rom-bytes" + assert target.read_bytes() == b"existing" + + @pytest.mark.asyncio + async def test_no_title_id_not_renamed(self, tmp_path: Path, sigil_config: Config): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_single_file_rom(tmp_path, platform, "Game.nsp") + + with patch(SIGIL_PATCH_TARGET, AsyncMock(return_value=None)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + assert parsed.rom_files[0].file_name == "Game.nsp" + assert parsed.renamed_rom_fs_name is None + assert (tmp_path / "switch/roms/Game.nsp").exists() + + @pytest.mark.asyncio + async def test_multi_part_nested_files_renamed( + self, tmp_path: Path, sigil_config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = make_sigil_handler(tmp_path) + rom = make_multi_part_rom( + tmp_path, + platform, + "Zelda", + ["Zelda base.nsp", "updates/Zelda update.nsp", "dlc/Zelda dlc.nsp"], + ) + + async def fake_extract( + platform_slug: str, file_path: str, prod_keys_path: str | None = None + ) -> SigilExtractionResult: + if "update" in file_path: + return SigilExtractionResult( + title_id="0100ABCD12340800", + save_id="0100ABCD12340800", + usage="folder-exact", + content_type="patch", + version=196608, + ) + if "dlc" in file_path: + return SigilExtractionResult( + title_id="0100ABCD12341001", + save_id="0100ABCD12341001", + usage="folder-exact", + content_type="addon", + version=0, + ) + return SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + content_type="application", + version=0, + ) + + with patch(SIGIL_PATCH_TARGET, AsyncMock(side_effect=fake_extract)): + parsed = await handler.get_rom_files(rom, embed_title_ids=True) + + names = {rf.file_name for rf in parsed.rom_files} + assert "Zelda base [0100ABCD12340000][v0].nsp" in names + assert "Zelda update [0100ABCD12340800][v196608].nsp" in names + assert "Zelda dlc [0100ABCD12341001][v0].nsp" in names + # A multi-part rom's folder name is not changed by inner-file renames. + assert parsed.renamed_rom_fs_name is None + rom_dir = tmp_path / "switch/roms/Zelda" + assert (rom_dir / "Zelda base [0100ABCD12340000][v0].nsp").exists() + assert ( + rom_dir / "updates/Zelda update [0100ABCD12340800][v196608].nsp" + ).exists() + + class TestExtractCHDHash: """Test suite for extract_chd_hash function""" diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index c7b71e01a..6a867fb2a 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -1101,7 +1101,7 @@ def test_get_matching_missing_rom_ambiguous_match_returns_none(platform: Platfor def test_get_matching_missing_rom_requires_all_three_hashes(platform: Platform): - """A match needs all three hashes; omitting one yields no match.""" + """A partial hash set yields no match when there is no title id either.""" _add_missing_rom( platform, "renamed", crc_hash="aabbccdd", md5_hash="md5val", sha1_hash="sha1val" ) @@ -1112,6 +1112,55 @@ def test_get_matching_missing_rom_requires_all_three_hashes(platform: Platform): assert match is None +def test_get_matching_missing_rom_matches_title_id_without_hashes(platform: Platform): + """A non-hashable platform reassociates on the binary title id. + + Switch ROMs are never hashed, so a renamed file would otherwise be + unrecoverable and land as a duplicate entry. + """ + missing = _add_missing_rom(platform, "renamed", title_id="0100ABCD12340000") + + match = db_rom_handler.get_matching_missing_rom( + platform_id=platform.id, title_id="0100ABCD12340000" + ) + assert match is not None + assert match.id == missing.id + + +def test_get_matching_missing_rom_hashes_take_precedence_over_title_id( + platform: Platform, +): + """A hashed file is matched on its hashes, not on a shared title id.""" + _add_missing_rom( + platform, + "other", + crc_hash="aabbccdd", + md5_hash="md5val", + sha1_hash="sha1val", + title_id="0100ABCD12340000", + ) + + match = db_rom_handler.get_matching_missing_rom( + platform_id=platform.id, + crc_hash="different", + md5_hash="different", + sha1_hash="different", + title_id="0100ABCD12340000", + ) + assert match is None + + +def test_get_matching_missing_rom_ambiguous_title_id_returns_none(platform: Platform): + """Two missing entries sharing a title id leave the choice to a new entry.""" + _add_missing_rom(platform, "dup_a", title_id="0100ABCD12340000") + _add_missing_rom(platform, "dup_b", title_id="0100ABCD12340000") + + match = db_rom_handler.get_matching_missing_rom( + platform_id=platform.id, title_id="0100ABCD12340000" + ) + assert match is None + + def test_get_matching_missing_rom_ignores_present_roms(platform: Platform): """Only ROMs marked missing are eligible for reassociation.""" db_rom_handler.add_rom( diff --git a/backend/tests/handler/test_fastapi.py b/backend/tests/handler/test_fastapi.py index 9953805a7..fc22278a6 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -28,7 +28,7 @@ scan_rom, ) from models.platform import Platform -from models.rom import Rom, RomFile +from models.rom import Rom, RomFile, SaveUsage from utils.context import initialize_context @@ -197,6 +197,115 @@ async def test_scan_rom_complete_clears_unselected_metadata( assert result.hasheous_id == 999 +@patch.object(meta_playmatch_handler, "is_enabled", return_value=False) +async def test_scan_rom_folds_extracted_title_id_values(mock_playmatch_enabled): + """Extracted title id values on the parsed files must land on the Rom.""" + platform = Platform(id=1, slug="switch", fs_slug="switch", name="Nintendo Switch") + platform = db_platform_handler.add_platform(platform) + + rom = Rom( + platform_id=platform.id, + fs_name="Game.nsp", + fs_path="switch/roms", + name="Game", + fs_size_bytes=1024, + tags=[], + ) + rom = db_rom_handler.add_rom(rom) + + async with initialize_context(): + result = await scan_rom( + platform=platform, + scan_type=ScanType.QUICK, + rom=rom, + fs_rom={ + "fs_name": "Game.nsp", + "flat": True, + "nested": False, + "files": [ + RomFile( + rom=rom, + file_name="Game.nsp", + file_path="switch/roms", + file_size_bytes=1024, + last_modified=1620000000, + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + ) + ], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + "title_id": "0100ABCD12340000", + "save_id": "0100ABCD12340000", + "save_usage": SaveUsage.FOLDER_EXACT, + }, + metadata_sources=[], + newly_added=False, + ) + + assert result.title_id == "0100ABCD12340000" + assert result.save_id == "0100ABCD12340000" + assert result.save_usage == SaveUsage.FOLDER_EXACT + + +@patch.object(meta_playmatch_handler, "is_enabled", return_value=False) +async def test_scan_rom_preserves_title_id_when_extraction_yields_nothing( + mock_playmatch_enabled, +): + """A rescan without extracted values must not wipe existing title ids.""" + platform = Platform(id=1, slug="switch", fs_slug="switch", name="Nintendo Switch") + platform = db_platform_handler.add_platform(platform) + + rom = Rom( + platform_id=platform.id, + fs_name="Game.nsp", + fs_path="switch/roms", + name="Game", + fs_size_bytes=1024, + tags=[], + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + save_usage=SaveUsage.FOLDER_EXACT, + ) + rom = db_rom_handler.add_rom(rom) + + async with initialize_context(): + result = await scan_rom( + platform=platform, + scan_type=ScanType.QUICK, + rom=rom, + fs_rom={ + "fs_name": "Game.nsp", + "flat": True, + "nested": False, + "files": [ + RomFile( + rom=rom, + file_name="Game.nsp", + file_path="switch/roms", + file_size_bytes=1024, + last_modified=1620000000, + ) + ], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + "title_id": None, + "save_id": None, + "save_usage": None, + }, + metadata_sources=[], + newly_added=False, + ) + + assert result.title_id == "0100ABCD12340000" + assert result.save_id == "0100ABCD12340000" + assert result.save_usage == SaveUsage.FOLDER_EXACT + + @patch.object(meta_playmatch_handler, "is_enabled", return_value=False) @patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock) @patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock) diff --git a/docker/Dockerfile b/docker/Dockerfile index 07dc03e46..215243db4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,6 +3,7 @@ # - backend-build: Build backend environment # - backend-dev-build: Similar to `backend-build`, but also compiles and installs development dependencies # - rahasher-build: Build RAHasher +# - sigil-build: Build sigil Python bindings (title ID extraction) # - emulator-stage: Fetch and extract emulators # - nginx-build: Build nginx modules # - production-stage: Setup frontend and backend @@ -104,6 +105,32 @@ RUN git clone --recursive --branch "${RALIBRETRO_VERSION}" --depth 1 https://git make HAVE_CHD=1 -f ./Makefile.RAHasher +# SIGIL TITLE ID EXTRACTION LIBRARY +# Built from python-alias (not plain alpine) so the cffi extension matches the +# CPython ABI of the venv produced in backend-build. +FROM python-alias AS sigil-build +RUN apk add --no-cache \ + cmake \ + g++ \ + git \ + make \ + musl-dev + +ARG SIGIL_VERSION=9665f03c04d0f547ed38dd5e5e31916c1da5f2e9 + +RUN git clone --recurse-submodules https://github.com/rommforge/argosy-sigil.git && \ + cd ./argosy-sigil && \ + git checkout "${SIGIL_VERSION}" && \ + git submodule update --init --recursive && \ + cmake -B ./build-python -S . -DSIGIL_BUILD_CLI=OFF -DSIGIL_BUILD_TESTS=OFF && \ + cmake --build ./build-python --target sigil && \ + pip install --no-cache-dir cffi setuptools && \ + cd ./bindings/python && \ + python build_sigil.py && \ + mkdir /sigil-pkg && \ + cp ./sigil/*.py ./sigil/_sigil.*.so /sigil-pkg/ + + # FETCH EMULATORJS AND RUFFLE FROM alpine:${ALPINE_VERSION}@sha256:${ALPINE_SHA256} AS emulator-stage @@ -224,6 +251,11 @@ COPY --from=production-stage / / COPY --from=backend-build /src/.venv /src/.venv +# Optional sigil bindings for title ID extraction; the backend feature +# no-ops when the package is absent. +ARG PYTHON_VERSION +COPY --from=sigil-build /sigil-pkg /src/.venv/lib/python${PYTHON_VERSION}/site-packages/sigil + ENV PATH="/src/.venv/bin:${PATH}" # ScreenScraper developer (application) credentials, injected at build time from diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index 854b907ef..e2353940c 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1475,6 +1475,11 @@ Nginx receives an internal redirect header and efficiently serves the file from Hashing can be disabled per-installation via `skip_hash_calculation` in config.yml. +Alongside hashing, scans extract platform-native title IDs from ROM binaries via +the optional `sigil` binding (Switch, PlayStation, Nintendo, Xbox and Dreamcast +families). The feature no-ops when the binding is absent, and can be turned off +with `skip_title_id_extraction`. + --- ## 13. Caching (Redis) @@ -1644,6 +1649,8 @@ filesystem: roms_folder: "roms" # Subfolder name for ROMs firmware_folder: "bios" # Subfolder name for BIOS skip_hash_calculation: false + skip_title_id_extraction: false # Skip sigil title ID extraction + embed_switch_title_ids: false # Rename Switch ROMs to embed their title ID system: platforms: diff --git a/examples/config.example.yml b/examples/config.example.yml index d6baa570a..bc9a61f90 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -76,6 +76,10 @@ # roms_folder: 'roms' # # Skip file hash calculations on low power devices (eg. Raspberry PI) # skip_hash_calculation: false +# # Skip reading platform-native title IDs out of ROM binaries during scans +# skip_title_id_extraction: false +# # Rename Switch ROMs to embed ` [TITLEID][vVERSION]` in their filename +# embed_switch_title_ids: false # scan: # # Metadata priority during scans diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 3cdf99433..83d3b5743 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -162,6 +162,7 @@ export type { SaveAndExitRequest } from './models/SaveAndExitRequest'; export type { SaveSchema } from './models/SaveSchema'; export type { SaveStateRequest } from './models/SaveStateRequest'; export type { SaveSummarySchema } from './models/SaveSummarySchema'; +export type { SaveUsage } from './models/SaveUsage'; export type { ScanSettingsPayload } from './models/ScanSettingsPayload'; export type { ScanStats } from './models/ScanStats'; export type { ScanTaskMeta } from './models/ScanTaskMeta'; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index f9dbd7b55..b2c5b3811 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -16,6 +16,7 @@ import type { RomRAMetadata } from './RomRAMetadata'; import type { RomSSMetadata } from './RomSSMetadata'; import type { RomUserSchema } from './RomUserSchema'; import type { SaveSchema } from './SaveSchema'; +import type { SaveUsage } from './SaveUsage'; import type { ScreenshotSchema } from './ScreenshotSchema'; import type { SiblingRomSchema } from './SiblingRomSchema'; import type { StateSchema } from './StateSchema'; @@ -84,6 +85,9 @@ export type DetailedRomSchema = { md5_hash: (string | null); sha1_hash: (string | null); ra_hash: (string | null); + title_id: (string | null); + save_id: (string | null); + save_usage: (SaveUsage | null); has_simple_single_file: boolean; has_nested_single_file: boolean; has_multiple_files: boolean; diff --git a/frontend/src/__generated__/models/RomFileSchema.ts b/frontend/src/__generated__/models/RomFileSchema.ts index 449120e09..b4ae16296 100644 --- a/frontend/src/__generated__/models/RomFileSchema.ts +++ b/frontend/src/__generated__/models/RomFileSchema.ts @@ -22,6 +22,9 @@ export type RomFileSchema = { sha1_hash: (string | null); ra_hash: (string | null); chd_sha1_hash: (string | null); + title_id: (string | null); + save_id: (string | null); + title_version: (number | null); archive_members: (Array | null); category: (RomFileCategory | null); track_meta?: (TrackMetaSchema | null); diff --git a/frontend/src/__generated__/models/SaveUsage.ts b/frontend/src/__generated__/models/SaveUsage.ts new file mode 100644 index 000000000..3286796e1 --- /dev/null +++ b/frontend/src/__generated__/models/SaveUsage.ts @@ -0,0 +1,5 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type SaveUsage = 'folder-exact' | 'folder-prefix' | 'file-exact' | 'file-prefix' | 'folder-split'; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 227ea4326..2aab62326 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -15,6 +15,7 @@ import type { RomMobyMetadata } from './RomMobyMetadata'; import type { RomRAMetadata } from './RomRAMetadata'; import type { RomSSMetadata } from './RomSSMetadata'; import type { RomUserSchema } from './RomUserSchema'; +import type { SaveUsage } from './SaveUsage'; import type { SiblingRomSchema } from './SiblingRomSchema'; export type SimpleRomSchema = { id: number; @@ -76,6 +77,9 @@ export type SimpleRomSchema = { md5_hash: (string | null); sha1_hash: (string | null); ra_hash: (string | null); + title_id: (string | null); + save_id: (string | null); + save_usage: (SaveUsage | null); has_simple_single_file: boolean; has_nested_single_file: boolean; has_multiple_files: boolean; diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index 32bf5d723..4ff3b1401 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM-ът е обновен успешно!", "save-data": "Данни от записи", "save-deleted": "Записът е изтрит.", + "save-id": "ID на записа", "saves-empty": "Все още няма записи", "saves-empty-hint": "Записите, качени за този ROM, ще се появят тук.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Бележки", "tab-overview": "Преглед", "tags": "Тагове", + "title-id": "ID на заглавието", "tracks-n": "{n} песен | {n} песни", "tracks-summary": "{count} песни · {duration}", "tracks-uploaded-n": "Качена е {n} песен. | Качени са {n} песни.", @@ -476,6 +478,7 @@ "verification": "Потвърждение", "verified-rom": "Потвърден ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Версия", "versions-count": "{n} версии", "volume-mute": "Заглуши", "volume-unmute": "Включи звука", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index c1fdd7a7e..09e679503 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM úspěšně aktualizována!", "save-data": "Uložená data", "save-deleted": "Uložená pozice smazána.", + "save-id": "ID uložení", "saves-empty": "Zatím žádné uložené pozice", "saves-empty-hint": "Uložené pozice nahrané pro tuto ROM se objeví zde.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Poznámky", "tab-overview": "Přehled", "tags": "Štítky", + "title-id": "ID titulu", "tracks-n": "{n} skladeb | {n} skladba | {n} skladby | {n} skladeb", "tracks-summary": "{count} skladeb · {duration}", "tracks-uploaded-n": "Nahráno {n} skladeb. | Nahrána {n} skladba. | Nahrány {n} skladby. | Nahráno {n} skladeb.", @@ -476,6 +478,7 @@ "verification": "Ověření", "verified-rom": "Ověřená ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Verze", "versions-count": "{n} verzí", "volume-mute": "Ztlumit", "volume-unmute": "Zrušit ztlumení", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 46932d390..5c63ad44e 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM erfolgreich aktualisiert!", "save-data": "Speicherdaten", "save-deleted": "Spielstand gelöscht.", + "save-id": "Speicher-ID", "saves-empty": "Noch keine Spielstände", "saves-empty-hint": "Für diese ROM hochgeladene Spielstände erscheinen hier.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notizen", "tab-overview": "Übersicht", "tags": "Tags", + "title-id": "Titel-ID", "tracks-n": "{n} Track | {n} Tracks", "tracks-summary": "{count} Tracks · {duration}", "tracks-uploaded-n": "{n} Track hochgeladen. | {n} Tracks hochgeladen.", @@ -476,6 +478,7 @@ "verification": "Verifizierung", "verified-rom": "Verifizierte ROM", "verified-rom-hint": "Hash mit ROM-Datenbank abgeglichen", + "version": "Version", "versions-count": "{n} Versionen", "volume-mute": "Stummschalten", "volume-unmute": "Stummschaltung aufheben", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 97748029a..f08280ac1 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "Rom updated successfully!", "save-data": "Save data", "save-deleted": "Save deleted.", + "save-id": "Save ID", "saves-empty": "No saves yet", "saves-empty-hint": "Saves uploaded for this ROM will appear here.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notes", "tab-overview": "Overview", "tags": "Tags", + "title-id": "Title ID", "tracks-n": "{n} track | {n} tracks", "tracks-summary": "{count} tracks · {duration}", "tracks-uploaded-n": "Uploaded {n} track. | Uploaded {n} tracks.", @@ -476,6 +478,7 @@ "verification": "Verification", "verified-rom": "Verified ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "versions-count": "{n} versions", "volume-mute": "Mute", "volume-unmute": "Unmute", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index 7cf588307..5331fef32 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "Rom updated successfully!", "save-data": "Save data", "save-deleted": "Save deleted.", + "save-id": "Save ID", "saves-empty": "No saves yet", "saves-empty-hint": "Saves uploaded for this ROM will appear here.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notes", "tab-overview": "Overview", "tags": "Tags", + "title-id": "Title ID", "tracks-n": "{n} track | {n} tracks", "tracks-summary": "{count} tracks · {duration}", "tracks-uploaded-n": "Uploaded {n} track. | Uploaded {n} tracks.", @@ -476,6 +478,7 @@ "verification": "Verification", "verified-rom": "Verified ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "versions-count": "{n} versions", "volume-mute": "Mute", "volume-unmute": "Unmute", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 3c81eb24e..30931a1b8 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM actualizada correctamente", "save-data": "Datos guardados", "save-deleted": "Partida eliminada", + "save-id": "ID de guardado", "saves-empty": "Aún no hay partidas", "saves-empty-hint": "Sube una partida para empezar.", "saves-section-community": "Comunidad", @@ -449,6 +450,7 @@ "tab-notes": "Notas", "tab-overview": "Resumen", "tags": "Etiquetas", + "title-id": "ID del título", "tracks-n": "{n} pista | {n} pistas", "tracks-summary": "{tracks}, {duration}", "tracks-uploaded-n": "{n} pista subida | {n} pistas subidas", @@ -476,6 +478,7 @@ "verification": "Verificación", "verified-rom": "ROM verificada", "verified-rom-hint": "Hash verificado contra una base de datos de ROM", + "version": "Versión", "versions-count": "{n} versiones", "volume-mute": "Silenciar", "volume-unmute": "Quitar silencio", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index dc2b9445b..01ac96f82 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM mise à jour avec succès !", "save-data": "Sauvegardes", "save-deleted": "Sauvegarde supprimée.", + "save-id": "ID de sauvegarde", "saves-empty": "Aucune sauvegarde pour le moment", "saves-empty-hint": "Les sauvegardes téléversées pour cette ROM apparaîtront ici.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notes", "tab-overview": "Aperçu", "tags": "Tags", + "title-id": "ID du titre", "tracks-n": "{n} piste | {n} pistes", "tracks-summary": "{count} pistes · {duration}", "tracks-uploaded-n": "{n} piste téléversée. | {n} pistes téléversées.", @@ -476,6 +478,7 @@ "verification": "Vérification", "verified-rom": "ROM vérifiée", "verified-rom-hint": "Empreinte vérifiée dans une base de données de ROM", + "version": "Version", "versions-count": "{n} versions", "volume-mute": "Couper le son", "volume-unmute": "Activer le son", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 4790d8c60..402ee4d68 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "A ROM frissítése sikeresen megtörtént!", "save-data": "Mentések", "save-deleted": "Mentés törölve.", + "save-id": "Mentés ID", "saves-empty": "Még nincsenek mentések", "saves-empty-hint": "A ROM-hoz feltöltött mentések itt jelennek meg.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Jegyzetek", "tab-overview": "Áttekintés", "tags": "Címkék", + "title-id": "Cím ID", "tracks-n": "{n} szám | {n} szám", "tracks-summary": "{count} szám · {duration}", "tracks-uploaded-n": "{n} szám feltöltve. | {n} szám feltöltve.", @@ -476,6 +478,7 @@ "verification": "Ellenőrzés", "verified-rom": "Ellenőrzött ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Verzió", "versions-count": "{n} verzió", "volume-mute": "Némítás", "volume-unmute": "Némítás feloldása", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index ef0be0ec4..edbe23614 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM aggiornata con successo!", "save-data": "Dati di salvataggio", "save-deleted": "Salvataggio eliminato.", + "save-id": "ID salvataggio", "saves-empty": "Nessun salvataggio ancora", "saves-empty-hint": "I salvataggi caricati per questa ROM appariranno qui.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Note", "tab-overview": "Panoramica", "tags": "Tag", + "title-id": "ID titolo", "tracks-n": "{n} traccia | {n} tracce", "tracks-summary": "{count} tracce · {duration}", "tracks-uploaded-n": "Caricata {n} traccia. | Caricate {n} tracce.", @@ -476,6 +478,7 @@ "verification": "Verifica", "verified-rom": "ROM verificata", "verified-rom-hint": "Hash verificato su un database di ROM", + "version": "Versione", "versions-count": "{n} versioni", "volume-mute": "Disattiva audio", "volume-unmute": "Riattiva audio", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 2745d060c..563d2c74b 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROMを正常に更新しました!", "save-data": "セーブデータ", "save-deleted": "セーブを削除しました。", + "save-id": "セーブID", "saves-empty": "セーブはまだありません", "saves-empty-hint": "このROMにアップロードされたセーブはここに表示されます。", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "ノート", "tab-overview": "概要", "tags": "タグ", + "title-id": "タイトルID", "tracks-n": "{n}件のトラック | {n}件のトラック", "tracks-summary": "{count}件のトラック · {duration}", "tracks-uploaded-n": "{n}件のトラックをアップロードしました。 | {n}件のトラックをアップロードしました。", @@ -476,6 +478,7 @@ "verification": "検証", "verified-rom": "確認済みROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "バージョン", "versions-count": "{n}件のバージョン", "volume-mute": "ミュート", "volume-unmute": "ミュート解除", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 9c2e95656..9338fbaef 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM이 성공적으로 업데이트되었습니다!", "save-data": "저장 데이터", "save-deleted": "세이브가 삭제되었습니다.", + "save-id": "저장 ID", "saves-empty": "아직 세이브가 없습니다", "saves-empty-hint": "이 ROM에 대해 업로드된 세이브가 여기에 표시됩니다.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "노트", "tab-overview": "개요", "tags": "태그", + "title-id": "타이틀 ID", "tracks-n": "{n}개 트랙 | {n}개 트랙", "tracks-summary": "{count}개 트랙 · {duration}", "tracks-uploaded-n": "{n}개 트랙을 업로드했습니다. | {n}개 트랙을 업로드했습니다.", @@ -476,6 +478,7 @@ "verification": "검증", "verified-rom": "검증된 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "버전", "versions-count": "{n}개 버전", "volume-mute": "음소거", "volume-unmute": "음소거 해제", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index d5af89b81..614c1ef64 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM zaktualizowany pomyślnie!", "save-data": "Dane zapisu", "save-deleted": "Zapis usunięty.", + "save-id": "ID zapisu", "saves-empty": "Brak zapisów", "saves-empty-hint": "Zapisy przesłane dla tego ROM-u pojawią się tutaj.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notatki", "tab-overview": "Przegląd", "tags": "Tagi", + "title-id": "ID tytułu", "tracks-n": "{n} utwór | {n} utwory | {n} utworów", "tracks-summary": "{count} utworów · {duration}", "tracks-uploaded-n": "Przesłano {n} utwór. | Przesłano {n} utwory. | Przesłano {n} utworów.", @@ -476,6 +478,7 @@ "verification": "Weryfikacja", "verified-rom": "Zweryfikowany ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Wersja", "versions-count": "{n} wersji", "volume-mute": "Wycisz", "volume-unmute": "Wyłącz wyciszenie", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index 69c79c8fc..f103c1939 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM atualizada com sucesso!", "save-data": "Dados salvos", "save-deleted": "Save excluído.", + "save-id": "ID do save", "saves-empty": "Nenhum save ainda", "saves-empty-hint": "Saves enviados para esta ROM aparecerão aqui.", "saves-section-community": "Comunidade", @@ -449,6 +450,7 @@ "tab-notes": "Notas", "tab-overview": "Visão geral", "tags": "Tags", + "title-id": "ID do título", "tracks-n": "{n} faixa | {n} faixas", "tracks-summary": "{count} faixas · {duration}", "tracks-uploaded-n": "{n} faixa enviada. | {n} faixas enviadas.", @@ -476,6 +478,7 @@ "verification": "Verificação", "verified-rom": "ROM verificada", "verified-rom-hint": "Hash verificado em banco de dados de ROM", + "version": "Versão", "versions-count": "{n} versões", "volume-mute": "Silenciar", "volume-unmute": "Reativar som", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index dee909585..31feda3c4 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM actualizat cu succes!", "save-data": "Date salvate", "save-deleted": "Salvare ștearsă.", + "save-id": "ID salvare", "saves-empty": "Nicio salvare încă", "saves-empty-hint": "Salvările încărcate pentru acest ROM vor apărea aici.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Notițe", "tab-overview": "Prezentare", "tags": "Etichete", + "title-id": "ID titlu", "tracks-n": "{n} piesă | {n} piese", "tracks-summary": "{count} piese · {duration}", "tracks-uploaded-n": "S-a încărcat {n} piesă. | S-au încărcat {n} piese.", @@ -476,6 +478,7 @@ "verification": "Verificare", "verified-rom": "ROM verificat", "verified-rom-hint": "Hash verified against ROM database", + "version": "Versiune", "versions-count": "{n} versiuni", "volume-mute": "Mut", "volume-unmute": "Activează sunetul", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index 70bd9d08b..eb2162257 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM успешно обновлён!", "save-data": "Сохранения", "save-deleted": "Сохранение удалено.", + "save-id": "ID сохранения", "saves-empty": "Сохранений пока нет", "saves-empty-hint": "Сохранения, загруженные для этого ROM, появятся здесь.", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "Заметки", "tab-overview": "Обзор", "tags": "Теги", + "title-id": "ID тайтла", "tracks-n": "{n} трек | {n} треков", "tracks-summary": "{count} треков · {duration}", "tracks-uploaded-n": "Загружен {n} трек. | Загружено {n} треков.", @@ -476,6 +478,7 @@ "verification": "Проверка", "verified-rom": "Проверенный ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Версия", "versions-count": "{n} версий", "volume-mute": "Отключить звук", "volume-unmute": "Включить звук", diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index 182dd950c..85de5e3c2 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM başarıyla güncellendi!", "save-data": "Kayıt verisi", "save-deleted": "Kayıt silindi.", + "save-id": "Kayıt ID", "saves-empty": "Henüz kayıt yok", "saves-empty-hint": "Bu ROM için yüklenen kayıtlar burada görünecek.", "saves-section-community": "Topluluk", @@ -449,6 +450,7 @@ "tab-notes": "Notlar", "tab-overview": "Genel Bakış", "tags": "Etiketler", + "title-id": "Başlık ID", "tracks-n": "{n} parça | {n} parça", "tracks-summary": "{count} parça · {duration}", "tracks-uploaded-n": "{n} parça yüklendi. | {n} parça yüklendi.", @@ -476,6 +478,7 @@ "verification": "Doğrulama", "verified-rom": "Doğrulanmış ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Sürüm", "versions-count": "{n} sürüm", "volume-mute": "Sesi kapat", "volume-unmute": "Sesi aç", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 43355a886..d8c0ae9db 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM 更新成功!", "save-data": "存档数据", "save-deleted": "存档已删除。", + "save-id": "存档ID", "saves-empty": "暂无存档", "saves-empty-hint": "此 ROM 上传的存档将显示在此处。", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "笔记", "tab-overview": "概览", "tags": "标签", + "title-id": "标题ID", "tracks-n": "{n} 个曲目 | {n} 个曲目", "tracks-summary": "{count} 个曲目 · {duration}", "tracks-uploaded-n": "已上传 {n} 个曲目。 | 已上传 {n} 个曲目。", @@ -476,6 +478,7 @@ "verification": "验证", "verified-rom": "已验证 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "版本", "versions-count": "{n} 个版本", "volume-mute": "静音", "volume-unmute": "取消静音", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 7699838d8..6d37a8108 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -346,6 +346,7 @@ "rom-updated-successfully": "ROM 更新成功!", "save-data": "存檔資料", "save-deleted": "存檔已刪除。", + "save-id": "存檔ID", "saves-empty": "尚未有存檔", "saves-empty-hint": "為此 ROM 上傳的存檔將會顯示在此處。", "saves-section-community": "Community", @@ -449,6 +450,7 @@ "tab-notes": "筆記", "tab-overview": "概覽", "tags": "標籤", + "title-id": "標題ID", "tracks-n": "{n} 個音軌 | {n} 個音軌", "tracks-summary": "{count} 個音軌 · {duration}", "tracks-uploaded-n": "已上傳 {n} 個音軌。 | 已上傳 {n} 個音軌。", @@ -476,6 +478,7 @@ "verification": "驗證", "verified-rom": "已驗證 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "版本", "versions-count": "{n} 個版本", "volume-mute": "靜音", "volume-unmute": "取消靜音", diff --git a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue index a7e70a380..9ed8a9439 100644 --- a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue +++ b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue @@ -97,14 +97,23 @@ const audioDuration = computed(() => formatDuration(props.file.track_meta?.duration_seconds), ); -const hasAnyHash = computed( - () => - Boolean(props.file.sha1_hash) || - Boolean(props.file.chd_sha1_hash) || - Boolean(props.file.md5_hash) || - Boolean(props.file.crc_hash) || - Boolean(props.file.ra_hash), -); +// Title ids get the same click-to-copy treatment as the digests. +const identifierChips = computed(() => { + const f = props.file; + const chips: { label: string; value: string | null }[] = [ + { label: t("rom.title-id"), value: f.title_id }, + { + label: t("rom.version"), + value: f.title_version != null ? String(f.title_version) : null, + }, + { label: "SHA-1", value: f.sha1_hash }, + { label: "CHD SHA-1", value: f.chd_sha1_hash }, + { label: "MD5", value: f.md5_hash }, + { label: "CRC", value: f.crc_hash }, + { label: "RA", value: f.ra_hash }, + ]; + return chips.filter((chip) => Boolean(chip.value)); +}); -
- - - - +
@@ -322,7 +308,7 @@ const hasAnyHash = computed( opacity: 0.5; } -.r-v2-file-row__hashes { +.r-v2-file-row__identifiers { display: flex; flex-wrap: wrap; gap: 4px; diff --git a/frontend/src/v2/components/GameDetails/MetadataTab.vue b/frontend/src/v2/components/GameDetails/MetadataTab.vue index b59bd2b7a..865626621 100644 --- a/frontend/src/v2/components/GameDetails/MetadataTab.vue +++ b/frontend/src/v2/components/GameDetails/MetadataTab.vue @@ -1,6 +1,6 @@