diff --git a/DEVELOPER_SETUP.md b/DEVELOPER_SETUP.md index f834506d1e..32744aa935 100644 --- a/DEVELOPER_SETUP.md +++ b/DEVELOPER_SETUP.md @@ -100,6 +100,20 @@ source .venv/bin/activate uv sync --all-extras --dev ``` +#### - Build and install sigil (optional) + +```sh +# This is only required for title ID extraction; the feature no-ops without it +# Users on macOS can skip this step, like RAHasher +# Requires cmake, and RomM's Python 3.13 venv (created above) must be active +git clone --recurse-submodules https://github.com/rommforge/argosy-sigil.git ../argosy-sigil +cd ../argosy-sigil/bindings/python +uv pip install cffi setuptools +make build +uv pip install -e . +cd - +``` + #### - Spin up the database and other services ```sh diff --git a/Dockerfile b/Dockerfile index 69ff807b8b..e6bb1c09e4 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=98f3c626eecac48aa99209414d88a15ebe9b70c4 +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 0000000000..27bba8a496 --- /dev/null +++ b/backend/adapters/services/sigil.py @@ -0,0 +1,99 @@ +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] + +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", +} + +# 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"} +) + +_missing_binding_logged = False + + +class SigilServiceError(Exception): ... + + +@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: + global _missing_binding_logged + + if sigil is None: + if not _missing_binding_logged: + log.debug("sigil binding not installed, skipping title id extraction") + _missing_binding_logged = True + 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) + is_sigil_error = isinstance(exc, getattr(sigil, "SigilError", ())) + if is_sigil_error and 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/0104_sigil_title_ids.py b/backend/alembic/versions/0104_sigil_title_ids.py new file mode 100644 index 0000000000..ebd776a008 --- /dev/null +++ b/backend/alembic/versions/0104_sigil_title_ids.py @@ -0,0 +1,93 @@ +"""Add sigil-extracted title id columns: per-file title_id/save_id on +rom_files, plus rom-level title_id/save_id/save_usage on roms. + +Revision ID: 0104_sigil_title_ids +Revises: 0103_roms_facets_provider_ids +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 = "0104_sigil_title_ids" +down_revision = "0103_roms_facets_provider_ids" +branch_labels = None +depends_on = None + +SAVE_USAGE_VALUES = ("FOLDER_EXACT", "FOLDER_PREFIX", "FILE_EXACT", "FILE_PREFIX") + + +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) + + op.add_column( + "rom_files", + sa.Column("title_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + op.add_column( + "rom_files", + sa.Column("save_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + op.add_column( + "roms", + sa.Column("title_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + op.add_column( + "roms", + sa.Column("save_id", sa.String(length=100), nullable=True), + if_not_exists=True, + ) + op.add_column( + "roms", + sa.Column("save_usage", save_usage_enum, nullable=True), + if_not_exists=True, + ) + + with op.batch_alter_table("rom_files", schema=None) as batch_op: + 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.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) + with op.batch_alter_table("rom_files", schema=None) as batch_op: + batch_op.drop_index("idx_rom_files_title_id", if_exists=True) + + op.drop_column("roms", "save_usage", if_exists=True) + op.drop_column("roms", "save_id", if_exists=True) + op.drop_column("roms", "title_id", if_exists=True) + op.drop_column("rom_files", "save_id", if_exists=True) + op.drop_column("rom_files", "title_id", if_exists=True) + + connection = op.get_bind() + if is_postgresql(connection): + ENUM(name="saveusage").drop(connection, checkfirst=True) diff --git a/backend/alembic/versions/0105_sigil_title_version.py b/backend/alembic/versions/0105_sigil_title_version.py new file mode 100644 index 0000000000..4b427e5d39 --- /dev/null +++ b/backend/alembic/versions/0105_sigil_title_version.py @@ -0,0 +1,29 @@ +"""Add sigil-extracted title_version column to rom_files (Switch title +versions are u32, stored as BigInteger). + +Revision ID: 0105_sigil_title_version +Revises: 0104_sigil_title_ids +Create Date: 2026-07-24 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0105_sigil_title_version" +down_revision = "0104_sigil_title_ids" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "rom_files", + sa.Column("title_version", sa.BigInteger(), nullable=True), + if_not_exists=True, + ) + + +def downgrade() -> None: + op.drop_column("rom_files", "title_version", if_exists=True) diff --git a/backend/alembic/versions/0106_sigil_folder_split.py b/backend/alembic/versions/0106_sigil_folder_split.py new file mode 100644 index 0000000000..7de15fbf29 --- /dev/null +++ b/backend/alembic/versions/0106_sigil_folder_split.py @@ -0,0 +1,59 @@ +"""Add FOLDER_SPLIT to the saveusage enum (3DS saves nest the id across +two directory levels). + +Revision ID: 0106_sigil_folder_split +Revises: 0105_sigil_title_version +Create Date: 2026-07-29 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0106_sigil_folder_split" +down_revision = "0105_sigil_title_version" +branch_labels = None +depends_on = None + +SAVE_USAGE_VALUES = ( + "FOLDER_EXACT", + "FOLDER_PREFIX", + "FILE_EXACT", + "FILE_PREFIX", + "FOLDER_SPLIT", +) + + +def upgrade() -> None: + connection = op.get_bind() + + if is_postgresql(connection): + # ALTER TYPE ... ADD VALUE must run outside alembic's transaction. + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE saveusage ADD VALUE IF NOT EXISTS 'FOLDER_SPLIT'") + else: + save_usage_enum = sa.Enum(*SAVE_USAGE_VALUES, name="saveusage") + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.alter_column("save_usage", type_=save_usage_enum, nullable=True) + + +def downgrade() -> None: + # PostgreSQL cannot drop enum values; leave FOLDER_SPLIT in place. On + # MariaDB, narrow the enum back after clearing any rows that used it. + connection = op.get_bind() + if is_postgresql(connection): + return + + op.execute("UPDATE roms SET save_usage = NULL WHERE save_usage = 'FOLDER_SPLIT'") + reverted_enum = sa.Enum( + "FOLDER_EXACT", + "FOLDER_PREFIX", + "FILE_EXACT", + "FILE_PREFIX", + name="saveusage", + ) + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.alter_column("save_usage", type_=reverted_enum, nullable=True) diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index 21a409bbed..39b73ee67e 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -185,6 +185,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 @@ -437,6 +439,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 @@ -873,6 +881,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 aa8d7084d1..d05ce30ac3 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -26,7 +26,14 @@ from handler.metadata.ra_handler import RAMetadata from handler.metadata.ss_handler import SSMetadata from models.collection import Collection, SmartCollection -from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus +from models.rom import ( + Rom, + RomArchiveMember, + RomFile, + RomFileCategory, + RomUserStatus, + SaveUsage, +) from .base import BaseModel, UTCDatetime @@ -191,6 +198,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 @@ -331,6 +341,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 6229c4ef9d..cb6d87cef1 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -12,7 +12,14 @@ from sqlalchemy.exc import IntegrityError from adapters.services.screenscraper import reset_daily_quota as reset_ss_daily_quota -from config import DEV_MODE, REDIS_URL, SCAN_TIMEOUT, SCAN_WORKERS, TASK_RESULT_TTL +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 @@ -36,6 +43,7 @@ ) from handler.filesystem.roms_handler import FSRom from handler.metadata import meta_gamelist_handler, meta_hltb_handler +from handler.metadata.base_handler import UniversalPlatformSlug as UPS from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types from handler.redis_handler import ( get_job_func_name, @@ -337,6 +345,18 @@ async def _identify_rom( } calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION + extract_title_ids = not cm.get_config().SKIP_TITLE_ID_EXTRACTION + embed_title_ids = cm.get_config().EMBED_SWITCH_TITLE_IDS + + # Switch title id extraction needs the console's prod.keys to decrypt + # XCI/NSP headers; resolve it from the scanned firmware when present. + prod_keys_path: str | None = None + if extract_title_ids and platform.slug in (UPS.SWITCH, UPS.SWITCH_2): + prod_keys = db_firmware_handler.get_firmware_by_filename( + platform.id, "prod.keys" + ) + if prod_keys: + prod_keys_path = f"{LIBRARY_BASE_PATH}/{prod_keys.full_path}" newly_added: bool = rom is None reassociated: bool = False @@ -353,6 +373,9 @@ 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( { @@ -361,8 +384,16 @@ async def _identify_rom( "md5_hash": parsed_rom_files.md5_hash, "sha1_hash": parsed_rom_files.sha1_hash, "ra_hash": parsed_rom_files.ra_hash, + "title_id": parsed_rom_files.title_id, + "save_id": parsed_rom_files.save_id, + "save_usage": parsed_rom_files.save_usage, } ) + # A single-file rom renamed on disk to embed its title id must carry the + # new name into both the new-entry insert and any hash reassociation. + if parsed_rom_files.renamed_rom_fs_name: + rom_attrs["fs_name"] = parsed_rom_files.renamed_rom_fs_name + fs_rom["fs_name"] = parsed_rom_files.renamed_rom_fs_name files_built = True missing_match = db_rom_handler.get_matching_missing_rom( @@ -416,7 +447,11 @@ 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 + rom, + 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( { @@ -425,8 +460,16 @@ async def _identify_rom( "md5_hash": parsed_rom_files.md5_hash, "sha1_hash": parsed_rom_files.sha1_hash, "ra_hash": parsed_rom_files.ra_hash, + "title_id": parsed_rom_files.title_id, + "save_id": parsed_rom_files.save_id, + "save_usage": parsed_rom_files.save_usage, } ) + # scan_rom persists Rom.fs_name from fs_rom["fs_name"]; keep the rom + # object in sync so its in-memory identity matches the renamed file. + if parsed_rom_files.renamed_rom_fs_name: + fs_rom["fs_name"] = 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 @@ -498,7 +541,8 @@ async def _identify_rom( if should_update_files: # Reconcile against the existing rows instead of replacing them, so file # ids survive a rescan and anything keyed on them (track metadata, - # persisted soundtrack covers) stays valid. + # persisted soundtrack covers) stays valid. title_id/save_id/title_version + # ride along via ROM_FILE_SCANNED_COLUMNS in sync_rom_files. synced = db_rom_handler.sync_rom_files(_added_rom.id, fs_rom["files"]) for cover_path in synced.orphaned_cover_paths: remove_persisted_cover(cover_path) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 0a64984a58..282cd77a85 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -167,6 +167,9 @@ "chd_sha1_hash", "archive_members", "category", + "title_id", + "save_id", + "title_version", ) TRACK_META_SCANNED_COLUMNS = ( diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 24df437806..d6776a4f9e 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -7,10 +7,15 @@ 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, + SigilExtractionResult, + SigilService, +) from config import LIBRARY_BASE_PATH from config.config_manager import ( DEFAULT_EXCLUDED_EXTENSIONS, @@ -24,7 +29,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 +102,9 @@ 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 FileHash(TypedDict): @@ -164,6 +172,127 @@ 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. None for multi-part roms + # (whose folder name is unchanged) or when no rename happened. + 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, +} + +# Platforms whose files may have their title id embedded in the filename. +EMBED_TITLE_ID_PLATFORM_SLUGS = frozenset((UPS.SWITCH, UPS.SWITCH_2)) + +# A 16-hex-digit title id in square brackets, e.g. [0100F4700B2E0000]. Used both +# to detect files that already carry an embedded id (idempotency) and to +# validate the id before embedding it. +SWITCH_TITLE_ID_BRACKET_REGEX = re.compile(r"\[[0-9A-Fa-f]{16}\]") +SWITCH_TITLE_ID_REGEX = re.compile(r"^[0-9A-Fa-f]{16}$") + + +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 its title id and version as + ` [TITLEID][vVERSION]` before the extension, preserving existing tags. + + Returns the new absolute path, or None when the file was left untouched: + it already carries a 16-hex title-id bracket, the id is not a valid 16-hex + value, or the target name already exists on disk. The os.rename is the only + side effect. + """ + 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.match(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 _is_switch_base_title_id(title_id: str) -> bool: + """Base-game Switch title ids have their low 12 bits cleared and an even + program-index nibble; update/DLC ids differ only in those positions.""" + if len(title_id) < 4 or not title_id.endswith("000"): + return False + try: + return int(title_id[-4], 16) % 2 == 0 + except ValueError: + return False + + +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 _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 (UPS.SWITCH, UPS.SWITCH_2): + 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): @@ -319,7 +448,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 @@ -335,6 +469,52 @@ 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] = [] + + # 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 EMBED_TITLE_ID_PLATFORM_SLUGS + ) + renamed_rom_fs_name: str | None = None + + async def _extract_title_id( + rom_file: RomFile, abs_file_path: Path, is_rom_level: bool = False + ) -> None: + nonlocal renamed_rom_fs_name + extraction = await SigilService().extract_title_id( + rom.platform_slug, str(abs_file_path), prod_keys_path + ) + if extraction is None: + return + rom_file.title_id = extraction.title_id + rom_file.save_id = extraction.save_id + rom_file.title_version = extraction.version + # For Switch, the binary CNMT content type is authoritative over the + # folder-derived category. Leave the folder category when absent. + 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 embed_switch and extraction.title_id: + new_path = _embed_switch_title_id_in_name( + abs_file_path, extraction.title_id, extraction.version + ) + if new_path is not None: + # The file stays in the same directory, so only file_name + # changes; file_path is unaffected. + rom_file.file_name = new_path.name + # A single-file rom's file name IS the Rom.fs_name, so the + # caller must reconcile it. Multi-part inner files don't + # change the rom folder name. + if is_rom_level: + renamed_rom_fs_name = new_path.name + cnfg = cm.get_config() excluded_file_names = cnfg.EXCLUDED_MULTI_PARTS_FILES excluded_file_exts = cnfg.EXCLUDED_MULTI_PARTS_EXT @@ -441,14 +621,17 @@ 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. + if sigil_platform: + await _extract_title_id(rom_file, Path(f_path, file_name)) + 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 @@ -576,14 +759,15 @@ def _hash_raw_archive(crc: int) -> int: extract_chd_hash(rom_dir) if is_chd_file(rom_dir) else "" ), ) - rom_files.append( - self._build_rom_file( - rom=rom, - rom_path=Path(rel_roms_path), - file_name=rom.fs_name, - file_hash=file_hash, - ) + rom_file = self._build_rom_file( + rom=rom, + rom_path=Path(rel_roms_path), + file_name=rom.fs_name, + file_hash=file_hash, ) + if sigil_platform: + await _extract_title_id(rom_file, rom_dir, is_rom_level=True) + rom_files.append(rom_file) else: file_hash = FileHash( crc_hash="", @@ -591,14 +775,21 @@ def _hash_raw_archive(crc: int) -> int: 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, - ) + rom_file = self._build_rom_file( + rom=rom, + rom_path=Path(rel_roms_path), + file_name=rom.fs_name, + file_hash=file_hash, ) + # Archives keep hashes only; sigil reads title ids from the ROM + # binary itself. + if sigil_platform and rom_ext not in ARCHIVE_READERS: + await _extract_title_id(rom_file, rom_dir, is_rom_level=True) + rom_files.append(rom_file) + + rom_title_id, rom_save_id, rom_save_usage = _rom_level_title_values( + rom.platform_slug, sigil_extractions + ) return ParsedRomFiles( rom_files=rom_files, @@ -614,6 +805,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 cce4e17515..6cdf2645c4 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -350,6 +350,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, } @@ -366,6 +369,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 68d6f8ebcf..0b94b281e1 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -84,6 +84,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" + + class SiblingRom(BaseModel): __tablename__ = "sibling_roms" @@ -106,7 +114,10 @@ class RomArchiveMember(TypedDict): class RomFile(BaseModel): __tablename__ = "rom_files" - __table_args__ = (Index("idx_rom_files_rom_id", "rom_id"),) + __table_args__ = ( + Index("idx_rom_files_rom_id", "rom_id"), + Index("idx_rom_files_title_id", "title_id"), + ) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE")) @@ -119,6 +130,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 ) @@ -335,6 +350,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"), ) fs_name: Mapped[str] = mapped_column(String(length=FILE_NAME_MAX_LENGTH)) @@ -407,6 +423,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 0000000000..9826039d97 --- /dev/null +++ b/backend/tests/adapters/services/test_sigil.py @@ -0,0 +1,202 @@ +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" diff --git a/backend/tests/endpoints/sockets/test_scan.py b/backend/tests/endpoints/sockets/test_scan.py index 021a6648db..016ec03870 100644 --- a/backend/tests/endpoints/sockets/test_scan.py +++ b/backend/tests/endpoints/sockets/test_scan.py @@ -17,7 +17,7 @@ ) from exceptions.fs_exceptions import FolderStructureNotMatchException 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, @@ -27,7 +27,7 @@ from handler.metadata.base_handler import UniversalPlatformSlug as UPS from handler.scan_handler import ScanType from models.platform import Platform -from models.rom import Rom +from models.rom import Rom, RomFile, RomFileCategory def test_scan_stats(): @@ -581,6 +581,225 @@ 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): + 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="switch/roms") + mocker.patch.object(fs, "get_file_name_with_no_tags", return_value="Game") + mocker.patch.object( + fs, + "get_rom_files", + AsyncMock( + return_value=ParsedRomFiles( + rom_files=[], + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + ra_hash="", + title_id="0100ABCD12340000", + renamed_rom_fs_name=self.NEW_NAME, + ) + ), + ) + + config = MagicMock() + config.SKIP_HASH_CALCULATION = False + config.SKIP_TITLE_ID_EXTRACTION = False + config.EMBED_SWITCH_TITLE_IDS = True + 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) + db.get_matching_missing_rom.return_value = None + return db + + def _platform(self): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + platform.id = 1 + return platform + + async def test_new_entry_carries_embedded_name(self, patched): + db = patched + fs_rom: FSRom = { + "fs_name": "Game.nsp", + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + } + await _identify_rom( + platform=self._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(), + ) + + # 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): + 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="switch/roms") + mocker.patch.object(fs, "get_file_name_with_no_tags", return_value="Game") + + 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, + ) + mocker.patch.object( + fs, + "get_rom_files", + AsyncMock( + return_value=ParsedRomFiles( + rom_files=[update_file], + crc_hash="crc", + md5_hash="md5", + sha1_hash="sha1", + ra_hash="", + title_id=self.UPDATE_TITLE_ID, + ) + ), + ) + + config = MagicMock() + config.SKIP_HASH_CALCULATION = False + config.SKIP_TITLE_ID_EXTRACTION = False + config.EMBED_SWITCH_TITLE_IDS = False + 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)), + ) + # The persist loop calls this per saved file; keep it inert. + mocker.patch.object(scan_module, "persist_soundtrack_cover") + + db = mocker.patch.object(scan_module, "db_rom_handler") + db.add_rom.return_value = MagicMock(is_identified=False, id=99) + 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 + + def _platform(self): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + platform.id = 1 + return platform + + async def test_rebuilt_file_keeps_title_version(self, patched): + db = patched + fs_rom: FSRom = { + "fs_name": "Game.nsp", + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + } + await _identify_rom( + platform=self._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(), + ) + + 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 b58bd46b39..a190968d14 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -2,13 +2,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, @@ -17,9 +18,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 @@ -1212,6 +1214,754 @@ async def test_get_rom_files_archive_skips_ra_hash_for_disc_platform( assert parsed.ra_hash == "" +class TestSigilTitleIdExtraction: + """Title id extraction via the sigil adapter during get_rom_files.""" + + SIGIL_PATCH_TARGET = "adapters.services.sigil.SigilService.extract_title_id" + + @pytest.fixture + def config(self): + 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 + return cnfg + + def _make_handler(self, tmp_path: Path) -> FSRomsHandler: + handler = FSRomsHandler() + handler.base_path = tmp_path + return handler + + def _make_single_file_rom( + self, 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( + self, 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, + ) + + @pytest.mark.asyncio + async def test_single_file_extraction_attaches_values( + self, tmp_path: Path, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + # Folder name "dlc" would otherwise derive category DLC, but the binary + # content type says this is the base application. + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="PlayStation Portable", slug="psp", fs_slug="psp") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with ( + patch(self.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, config: Config + ): + platform = Platform(name="Nintendo 3DS", slug="3ds", fs_slug="3ds") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.3ds") + + mock_extract = AsyncMock( + return_value=SigilExtractionResult( + title_id="0004000000033500", + # save_id carries the on-disk split, not the flat id. + save_id="00040000/00033500", + usage="folder-split", + ) + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with ( + patch(self.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 == "00040000/00033500" + assert parsed.save_id == "00040000/00033500" + assert parsed.save_usage == SaveUsage.FOLDER_SPLIT + + @pytest.mark.asyncio + async def test_non_sigil_platform_skips_extraction( + self, tmp_path: Path, config: Config + ): + platform = Platform(name="Nintendo 64", slug="n64", fs_slug="n64") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.z64") + + mock_extract = AsyncMock() + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.nsp") + + mock_extract = AsyncMock() + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, 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 = self._make_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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with ( + patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "game.zip") + + mock_extract = AsyncMock() + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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.""" + + SIGIL_PATCH_TARGET = "adapters.services.sigil.SigilService.extract_title_id" + + @pytest.fixture + def config(self): + 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 + return cnfg + + def _make_handler(self, tmp_path: Path) -> FSRomsHandler: + handler = FSRomsHandler() + handler.base_path = tmp_path + return handler + + def _make_single_file_rom( + self, 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( + self, 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, + ) + + @pytest.mark.asyncio + async def test_flag_off_leaves_file_unchanged(self, tmp_path: Path, config: Config): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.nsp") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Moonlighter.xci") + + extraction = SigilExtractionResult( + title_id="0100F4700B2E0000", + save_id="0100F4700B2E0000", + usage="folder-exact", + version=0, + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + fs_name = "Game [0100ABCD12340000][v0].nsp" + rom = self._make_single_file_rom(tmp_path, platform, fs_name) + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="PlayStation Portable", slug="psp", fs_slug="psp") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.iso") + + extraction = SigilExtractionResult( + title_id="0100ABCD12340000", + save_id="0100ABCD12340000", + usage="folder-exact", + ) + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._make_single_file_rom(tmp_path, platform, "Game.nsp") + + with pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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, config: Config + ): + platform = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + handler = self._make_handler(tmp_path) + rom = self._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 pytest.MonkeyPatch.context() as m: + m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + with patch(self.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_fastapi.py b/backend/tests/handler/test_fastapi.py index e17896009a..301dddc01b 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -23,7 +23,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 @@ -192,6 +192,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/backend/tests/tasks/test_cleanup_orphaned_resources.py b/backend/tests/tasks/test_cleanup_orphaned_resources.py index 9f630e7960..0297cf7abf 100644 --- a/backend/tests/tasks/test_cleanup_orphaned_resources.py +++ b/backend/tests/tasks/test_cleanup_orphaned_resources.py @@ -25,8 +25,8 @@ def test_stays_manually_runnable(self, task): assert task.enabled is True assert task.manual_run is True - def test_no_cron_string_by_default(self, task): - assert task.cron_string is None + def test_cron_string_defaults_to_schedule(self, task): + assert task.cron_string == mod.SCHEDULED_CLEANUP_ORPHANED_RESOURCES_CRON def test_cron_string_set_when_schedule_enabled(self): with patch.object(mod, "ENABLE_SCHEDULED_CLEANUP_ORPHANED_RESOURCES", True): @@ -36,6 +36,7 @@ def test_cron_string_set_when_schedule_enabled(self): assert CleanupOrphanedResourcesTask().cron_string == "0 5 * * *" def test_init_unschedules_when_no_cron(self, task): + task.cron_string = None with patch.object(task, "unschedule") as mock_unschedule: assert task.init() is None mock_unschedule.assert_called_once() diff --git a/docker/Dockerfile b/docker/Dockerfile index 0c4901c865..349c783d03 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=98f3c626eecac48aa99209414d88a15ebe9b70c4 + +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 @@ -223,6 +250,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/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 12762d7482..ddd6c834a2 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -158,6 +158,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 b2c99c0bd4..9929a7a3ab 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 5d672771c7..68d9066a76 100644 --- a/frontend/src/__generated__/models/RomFileSchema.ts +++ b/frontend/src/__generated__/models/RomFileSchema.ts @@ -21,6 +21,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 0000000000..3286796e10 --- /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 ed03254bac..94c473a051 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 b8d8775f0a..1dc7cf049f 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM-ът е обновен успешно!", "save-data": "Данни от записи", "save-deleted": "Записът е изтрит.", + "save-id": "Save ID", "saves-empty": "Все още няма записи", "saves-empty-hint": "Записите, качени за този ROM, ще се появят тук.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Бележки", "tab-overview": "Преглед", "tags": "Тагове", + "title-id": "Title ID", "tracks-n": "{n} песен | {n} песни", "tracks-summary": "{count} песни · {duration}", "tracks-uploaded-n": "Качена е {n} песен. | Качени са {n} песни.", @@ -449,6 +451,7 @@ "verification": "Потвърждение", "verified-rom": "Потвърден ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 9c96f8615f..5e547bca48 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM úspěšně aktualizována!", "save-data": "Uložená data", "save-deleted": "Uložená pozice smazána.", + "save-id": "Save ID", "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", @@ -423,6 +424,7 @@ "tab-notes": "Poznámky", "tab-overview": "Přehled", "tags": "Štítky", + "title-id": "Title ID", "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.", @@ -449,6 +451,7 @@ "verification": "Ověření", "verified-rom": "Ověřená ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "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 48c6d6efa8..e0a1b586a8 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM erfolgreich aktualisiert!", "save-data": "Speicherdaten", "save-deleted": "Spielstand gelöscht.", + "save-id": "Save ID", "saves-empty": "Noch keine Spielstände", "saves-empty-hint": "Für diese ROM hochgeladene Spielstände erscheinen hier.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Notizen", "tab-overview": "Übersicht", "tags": "Tags", + "title-id": "Title ID", "tracks-n": "{n} Track | {n} Tracks", "tracks-summary": "{count} Tracks · {duration}", "tracks-uploaded-n": "{n} Track hochgeladen. | {n} Tracks hochgeladen.", @@ -449,6 +451,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 947156ea95..f3fb1a12e6 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -320,6 +320,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", @@ -423,6 +424,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.", @@ -449,6 +451,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 1f86d9e45d..db89680a5d 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -320,6 +320,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", @@ -423,6 +424,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.", @@ -449,6 +451,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 720a8f04af..7895f8eb07 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM actualizada correctamente", "save-data": "Datos guardados", "save-deleted": "Partida eliminada", + "save-id": "Save ID", "saves-empty": "Aún no hay partidas", "saves-empty-hint": "Sube una partida para empezar.", "saves-section-community": "Comunidad", @@ -423,6 +424,7 @@ "tab-notes": "Notas", "tab-overview": "Resumen", "tags": "Etiquetas", + "title-id": "Title ID", "tracks-n": "{n} pista | {n} pistas", "tracks-summary": "{tracks}, {duration}", "tracks-uploaded-n": "{n} pista subida | {n} pistas subidas", @@ -449,6 +451,7 @@ "verification": "Verificación", "verified-rom": "ROM verificada", "verified-rom-hint": "Hash verificado contra una base de datos de ROM", + "version": "Version", "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 bb61b287de..ae4f5cef3a 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM mise à jour avec succès !", "save-data": "Sauvegardes", "save-deleted": "Sauvegarde supprimée.", + "save-id": "Save ID", "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", @@ -423,6 +424,7 @@ "tab-notes": "Notes", "tab-overview": "Aperçu", "tags": "Tags", + "title-id": "Title ID", "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.", @@ -449,6 +451,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 90e2ed4cea..9d97d900a0 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -320,6 +320,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": "Save 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", @@ -423,6 +424,7 @@ "tab-notes": "Jegyzetek", "tab-overview": "Áttekintés", "tags": "Címkék", + "title-id": "Title 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.", @@ -449,6 +451,7 @@ "verification": "Ellenőrzés", "verified-rom": "Ellenőrzött ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "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 77bb00c545..74fceb9e63 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM aggiornata con successo!", "save-data": "Dati di salvataggio", "save-deleted": "Salvataggio eliminato.", + "save-id": "Save ID", "saves-empty": "Nessun salvataggio ancora", "saves-empty-hint": "I salvataggi caricati per questa ROM appariranno qui.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Note", "tab-overview": "Panoramica", "tags": "Tag", + "title-id": "Title ID", "tracks-n": "{n} traccia | {n} tracce", "tracks-summary": "{count} tracce · {duration}", "tracks-uploaded-n": "Caricata {n} traccia. | Caricate {n} tracce.", @@ -449,6 +451,7 @@ "verification": "Verifica", "verified-rom": "ROM verificata", "verified-rom-hint": "Hash verificato su un database di ROM", + "version": "Version", "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 45794e646e..cffaca864a 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROMを正常に更新しました!", "save-data": "セーブデータ", "save-deleted": "セーブを削除しました。", + "save-id": "Save ID", "saves-empty": "セーブはまだありません", "saves-empty-hint": "このROMにアップロードされたセーブはここに表示されます。", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "ノート", "tab-overview": "概要", "tags": "タグ", + "title-id": "Title ID", "tracks-n": "{n}件のトラック | {n}件のトラック", "tracks-summary": "{count}件のトラック · {duration}", "tracks-uploaded-n": "{n}件のトラックをアップロードしました。 | {n}件のトラックをアップロードしました。", @@ -449,6 +451,7 @@ "verification": "検証", "verified-rom": "確認済みROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 ba7bf8ff87..42cc26ceed 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM이 성공적으로 업데이트되었습니다!", "save-data": "저장 데이터", "save-deleted": "세이브가 삭제되었습니다.", + "save-id": "Save ID", "saves-empty": "아직 세이브가 없습니다", "saves-empty-hint": "이 ROM에 대해 업로드된 세이브가 여기에 표시됩니다.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "노트", "tab-overview": "개요", "tags": "태그", + "title-id": "Title ID", "tracks-n": "{n}개 트랙 | {n}개 트랙", "tracks-summary": "{count}개 트랙 · {duration}", "tracks-uploaded-n": "{n}개 트랙을 업로드했습니다. | {n}개 트랙을 업로드했습니다.", @@ -449,6 +451,7 @@ "verification": "검증", "verified-rom": "검증된 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 343d3731b8..705014c3e1 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM zaktualizowany pomyślnie!", "save-data": "Dane zapisu", "save-deleted": "Zapis usunięty.", + "save-id": "Save ID", "saves-empty": "Brak zapisów", "saves-empty-hint": "Zapisy przesłane dla tego ROM-u pojawią się tutaj.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Notatki", "tab-overview": "Przegląd", "tags": "Tagi", + "title-id": "Title ID", "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.", @@ -449,6 +451,7 @@ "verification": "Weryfikacja", "verified-rom": "Zweryfikowany ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "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 2636479449..052482df8c 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM atualizada com sucesso!", "save-data": "Dados salvos", "save-deleted": "Save excluído.", + "save-id": "Save ID", "saves-empty": "Nenhum save ainda", "saves-empty-hint": "Saves enviados para esta ROM aparecerão aqui.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Notas", "tab-overview": "Visão geral", "tags": "Tags", + "title-id": "Title ID", "tracks-n": "{n} faixa | {n} faixas", "tracks-summary": "{count} faixas · {duration}", "tracks-uploaded-n": "{n} faixa enviada. | {n} faixas enviadas.", @@ -449,6 +451,7 @@ "verification": "Verificação", "verified-rom": "ROM verificada", "verified-rom-hint": "Hash verificado em banco de dados de ROM", + "version": "Version", "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 fdc4c864a0..6bc8aafda9 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM actualizat cu succes!", "save-data": "Date salvate", "save-deleted": "Salvare ștearsă.", + "save-id": "Save ID", "saves-empty": "Nicio salvare încă", "saves-empty-hint": "Salvările încărcate pentru acest ROM vor apărea aici.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Notițe", "tab-overview": "Prezentare", "tags": "Etichete", + "title-id": "Title ID", "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.", @@ -449,6 +451,7 @@ "verification": "Verificare", "verified-rom": "ROM verificat", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "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 f2740cb7b9..06e8bd818c 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM успешно обновлён!", "save-data": "Сохранения", "save-deleted": "Сохранение удалено.", + "save-id": "Save ID", "saves-empty": "Сохранений пока нет", "saves-empty-hint": "Сохранения, загруженные для этого ROM, появятся здесь.", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "Заметки", "tab-overview": "Обзор", "tags": "Теги", + "title-id": "Title ID", "tracks-n": "{n} трек | {n} треков", "tracks-summary": "{count} треков · {duration}", "tracks-uploaded-n": "Загружен {n} трек. | Загружено {n} треков.", @@ -449,6 +451,7 @@ "verification": "Проверка", "verified-rom": "Проверенный ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 e8c0b64b06..16de212563 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM başarıyla güncellendi!", "save-data": "Kayıt verisi", "save-deleted": "Kayıt silindi.", + "save-id": "Save 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", @@ -423,6 +424,7 @@ "tab-notes": "Notlar", "tab-overview": "Genel Bakış", "tags": "Etiketler", + "title-id": "Title 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.", @@ -449,6 +451,7 @@ "verification": "Doğrulama", "verified-rom": "Doğrulanmış ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "Version", "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 299338d37c..de98ec5501 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM 更新成功!", "save-data": "存档数据", "save-deleted": "存档已删除。", + "save-id": "Save ID", "saves-empty": "暂无存档", "saves-empty-hint": "此 ROM 上传的存档将显示在此处。", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "笔记", "tab-overview": "概览", "tags": "标签", + "title-id": "Title ID", "tracks-n": "{n} 个曲目 | {n} 个曲目", "tracks-summary": "{count} 个曲目 · {duration}", "tracks-uploaded-n": "已上传 {n} 个曲目。 | 已上传 {n} 个曲目。", @@ -449,6 +451,7 @@ "verification": "验证", "verified-rom": "已验证 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 33f6e1a37a..983b9b0d03 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -320,6 +320,7 @@ "rom-updated-successfully": "ROM 更新成功!", "save-data": "存檔資料", "save-deleted": "存檔已刪除。", + "save-id": "Save ID", "saves-empty": "尚未有存檔", "saves-empty-hint": "為此 ROM 上傳的存檔將會顯示在此處。", "saves-section-community": "Community", @@ -423,6 +424,7 @@ "tab-notes": "筆記", "tab-overview": "概覽", "tags": "標籤", + "title-id": "Title ID", "tracks-n": "{n} 個音軌 | {n} 個音軌", "tracks-summary": "{count} 個音軌 · {duration}", "tracks-uploaded-n": "已上傳 {n} 個音軌。 | 已上傳 {n} 個音軌。", @@ -449,6 +451,7 @@ "verification": "驗證", "verified-rom": "已驗證 ROM", "verified-rom-hint": "Hash verified against ROM database", + "version": "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 a857631564..f067a5cdf7 100644 --- a/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue +++ b/frontend/src/v2/components/GameDetails/FilesTab/FileRow.vue @@ -95,6 +95,8 @@ const audioDuration = computed(() => const hasAnyHash = computed( () => + Boolean(props.file.title_id) || + Boolean(props.file.title_version) || Boolean(props.file.sha1_hash) || Boolean(props.file.chd_sha1_hash) || Boolean(props.file.md5_hash) || @@ -166,6 +168,18 @@ const hasAnyHash = computed(
+ + (() => { return [ { label: t("rom.filename"), value: r.fs_name }, { label: t("common.size"), value: size }, + { label: t("rom.title-id"), value: r.title_id ?? "—" }, + { label: t("rom.save-id"), value: r.save_id ?? "—" }, ]; }); diff --git a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue index ef4b99fdd6..6f725d73f0 100644 --- a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue +++ b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue @@ -142,6 +142,9 @@ const syntheticRom = computed(() => ({ md5_hash: null, sha1_hash: null, ra_hash: null, + title_id: null, + save_id: null, + save_usage: null, has_simple_single_file: false, has_nested_single_file: false, has_multiple_files: false, diff --git a/frontend/src/v2/utils/romArtwork.test.ts b/frontend/src/v2/utils/romArtwork.test.ts index f849d52b51..be6a48bdeb 100644 --- a/frontend/src/v2/utils/romArtwork.test.ts +++ b/frontend/src/v2/utils/romArtwork.test.ts @@ -20,6 +20,9 @@ function makeFile(overrides: Partial): RomFileSchema { sha1_hash: null, ra_hash: null, chd_sha1_hash: null, + title_id: null, + save_id: null, + title_version: null, archive_members: null, category: "game", ...overrides,