From 83457574ce466f0cf814789c8f38908cdefe0761 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 5 Jul 2026 08:35:07 -0400 Subject: [PATCH] add support for physical (DB only) games --- .../alembic/versions/0095_physical_roms.py | 35 +++ backend/config/__init__.py | 12 + backend/endpoints/responses/rom.py | 2 + backend/endpoints/roms/__init__.py | 133 ++++++++++- backend/endpoints/sockets/scan.py | 91 +------ backend/handler/database/roms_handler.py | 12 +- backend/handler/metadata/__init__.py | 2 + backend/handler/metadata/upc_handler.py | 74 ++++++ backend/handler/scan_handler.py | 142 ++++++++++- backend/models/rom.py | 5 + backend/tests/endpoints/roms/test_physical.py | 192 +++++++++++++++ backend/tests/handler/test_db_handler.py | 38 +++ backend/tests/models/test_rom.py | 26 ++ frontend/package-lock.json | 46 ++++ frontend/package.json | 1 + frontend/src/__generated__/index.ts | 1 + .../__generated__/models/DetailedRomSchema.ts | 2 + .../models/PhysicalRomCreateForm.ts | 23 ++ .../__generated__/models/SimpleRomSchema.ts | 2 + frontend/src/locales/bg_BG/rom.json | 14 +- frontend/src/locales/cs_CZ/rom.json | 14 +- frontend/src/locales/de_DE/rom.json | 14 +- frontend/src/locales/en_GB/rom.json | 14 +- frontend/src/locales/en_US/rom.json | 14 +- frontend/src/locales/es_ES/rom.json | 14 +- frontend/src/locales/fr_FR/rom.json | 14 +- frontend/src/locales/hu_HU/rom.json | 14 +- frontend/src/locales/it_IT/rom.json | 14 +- frontend/src/locales/ja_JP/rom.json | 14 +- frontend/src/locales/ko_KR/rom.json | 14 +- frontend/src/locales/pl_PL/rom.json | 14 +- frontend/src/locales/pt_BR/rom.json | 14 +- frontend/src/locales/ro_RO/rom.json | 14 +- frontend/src/locales/ru_RU/rom.json | 14 +- frontend/src/locales/tr_TR/rom.json | 14 +- frontend/src/locales/zh_CN/rom.json | 14 +- frontend/src/locales/zh_TW/rom.json | 14 +- frontend/src/services/api/rom.ts | 22 ++ frontend/src/types/emitter.d.ts | 3 + .../Dialogs/AddPhysicalGameDialog.vue | 224 ++++++++++++++++++ .../v2/components/Dialogs/GlobalDialogs.vue | 2 + .../v2/components/Gallery/PlatformHead.vue | 12 + .../GameDetails/RelatedGameCard.vue | 2 + .../shared/BarcodeScannerDialog.vue | 198 ++++++++++++++++ frontend/src/v2/views/Gallery/Platform.vue | 13 +- 45 files changed, 1459 insertions(+), 108 deletions(-) create mode 100644 backend/alembic/versions/0095_physical_roms.py create mode 100644 backend/handler/metadata/upc_handler.py create mode 100644 backend/tests/endpoints/roms/test_physical.py create mode 100644 frontend/src/__generated__/models/PhysicalRomCreateForm.ts create mode 100644 frontend/src/v2/components/Dialogs/AddPhysicalGameDialog.vue create mode 100644 frontend/src/v2/components/shared/BarcodeScannerDialog.vue diff --git a/backend/alembic/versions/0095_physical_roms.py b/backend/alembic/versions/0095_physical_roms.py new file mode 100644 index 0000000000..d8b86216d2 --- /dev/null +++ b/backend/alembic/versions/0095_physical_roms.py @@ -0,0 +1,35 @@ +"""Add physical-game columns to roms. + +Revision ID: 0095_physical_roms +Revises: 0094_track_meta_table +Create Date: 2026-07-04 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0095_physical_roms" +down_revision = "0094_track_meta_table" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "is_physical", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) + ) + batch_op.add_column(sa.Column("upc", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_column("upc") + batch_op.drop_column("is_physical") diff --git a/backend/config/__init__.py b/backend/config/__init__.py index f3485fd7ef..8b972c8d1b 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -120,6 +120,18 @@ def _get_env(var: str, fallback: str | None = None) -> str | None: # HOWLONGTOBEAT HLTB_API_ENABLED: Final[bool] = safe_str_to_bool(_get_env("HLTB_API_ENABLED")) +# UPC LOOKUP (barcode -> title, used when adding physical games by UPC). +# Defaults to the free UPCitemdb trial endpoint, which works without a key but +# is heavily rate-limited. Set UPC_LOOKUP_API_KEY (and optionally a base URL) to +# use the paid tier. +UPC_LOOKUP_ENABLED: Final[bool] = safe_str_to_bool( + _get_env("UPC_LOOKUP_ENABLED", "true") +) +UPC_LOOKUP_API_KEY: Final[str | None] = _get_env("UPC_LOOKUP_API_KEY") +UPC_LOOKUP_BASE_URL: Final[str] = _get_env( + "UPC_LOOKUP_BASE_URL", "https://api.upcitemdb.com/prod/trial" +) + # AUTH ROMM_AUTH_SECRET_KEY: Final[str] = _get_env("ROMM_AUTH_SECRET_KEY", "") if not ROMM_AUTH_SECRET_KEY: diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 8ad6b0d1bb..3c7906c516 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -340,6 +340,8 @@ class RomSchema(BaseModel): created_at: UTCDatetime updated_at: UTCDatetime missing_from_fs: bool + is_physical: bool + upc: str | None has_notes: bool rom_user: RomUserSchema diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index dcd3d16981..2562900200 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -52,10 +52,11 @@ assert_rom_visible, get_permissions, ) -from handler.database import db_rom_handler, db_save_handler +from handler.database import db_platform_handler, db_rom_handler, db_save_handler from handler.database.base_handler import sync_session from handler.filesystem import fs_resource_handler, fs_rom_handler from handler.filesystem.assets_handler import validate_image_upload +from handler.filesystem.roms_handler import FSRom from handler.metadata import ( meta_flashpoint_handler, meta_igdb_handler, @@ -64,9 +65,18 @@ meta_playmatch_handler, meta_ra_handler, meta_ss_handler, + meta_upc_handler, ) from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types from handler.rom_conversion import promote_single_file_to_folder +from handler.scan_handler import ( + MetadataSource, + ScanType, + build_physical_fs_name, + build_physical_fs_path, + download_rom_resources, + scan_rom, +) from logger.formatter import BLUE from logger.formatter import highlight as hl from logger.logger import log @@ -1355,6 +1365,127 @@ async def build_zip_in_memory() -> bytes: ) +class PhysicalRomCreateForm(BaseModel): + platform_id: int = Field(..., ge=1, description="Platform the game belongs to.") + name: str | None = Field( + default=None, description="Game name to match metadata against." + ) + upc: str | None = Field( + default=None, description="UPC/EAN/barcode of the physical copy." + ) + metadata_sources: list[str] | None = Field( + default=None, + description="Metadata providers to match against; defaults to all enabled.", + ) + + +@protected_route( + router.post, + "/physical", + [Scope.ROMS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def create_physical_rom( + request: Request, + form_data: Annotated[PhysicalRomCreateForm, Body()], +) -> DetailedRomSchema: + """Manually add a physical game and auto-link its metadata (a single quick scan). + + The game has no file on disk; it is stored as a `Rom` with `is_physical=True` + and a synthetic filesystem name, then matched by name against the metadata + providers exactly like a one-title scan. + """ + platform = db_platform_handler.get_platform(form_data.platform_id) + if not platform: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Platform with id {form_data.platform_id} not found", + ) + + match_name = (form_data.name or "").strip() + if not match_name and form_data.upc: + match_name = (await meta_upc_handler.resolve_upc_to_title(form_data.upc)) or "" + if not match_name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Could not resolve the provided UPC to a game title", + ) + + if not match_name: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="A name or a resolvable UPC is required", + ) + + fs_name = build_physical_fs_name(platform.id, match_name) + fs_path = build_physical_fs_path(platform) + + try: + rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + fs_name=fs_name, + fs_path=fs_path, + fs_size_bytes=0, + name=match_name, + is_physical=True, + upc=form_data.upc, + url_cover="", + url_manual="", + url_screenshots=[], + ) + ) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A game named {match_name!r} already exists on this platform", + ) from exc + + metadata_sources = form_data.metadata_sources or [s.value for s in MetadataSource] + + fs_rom: FSRom = { + "fs_name": fs_name, + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + } + + scanned_rom = await scan_rom( + scan_type=ScanType.QUICK, + platform=platform, + rom=rom, + fs_rom=fs_rom, + metadata_sources=metadata_sources, + newly_added=True, + ) + + # scan_rom returns a fresh Rom; re-assert the physical identity before persisting + # so a matched row can never be treated as a file-less-and-missing digital rom. + scanned_rom.is_physical = True + scanned_rom.upc = form_data.upc + scanned_rom.fs_path = fs_path + + added_rom = db_rom_handler.add_rom(scanned_rom) + + await download_rom_resources( + added_rom=added_rom, + previous_url_cover=rom.url_cover, + previous_url_manual=rom.url_manual, + previous_url_screenshots=rom.url_screenshots, + metadata_sources=metadata_sources, + ) + + db_rom_handler.invalidate_filter_values_cache() + + return DetailedRomSchema.from_orm_with_request( + db_rom_handler.get_rom(added_rom.id), request + ) + + @protected_route( router.put, "/{id}", diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 2773fe9026..b630d6c600 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -5,7 +5,6 @@ from itertools import batched from typing import Any, Final -import pydash import socketio # type: ignore from rq import Worker from rq.job import Job @@ -33,11 +32,11 @@ ) from handler.filesystem.roms_handler import FSRom from handler.metadata import meta_gamelist_handler, meta_hltb_handler -from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_types from handler.redis_handler import get_job_func_name, high_prio_queue, redis_client from handler.scan_handler import ( MetadataSource, ScanType, + download_rom_resources, persist_soundtrack_cover, scan_firmware, scan_platform, @@ -419,90 +418,14 @@ async def _identify_rom( if scan_type == ScanType.HASHES: return - path_cover_s, path_cover_l = await fs_resource_handler.get_cover( - entity=_added_rom, - overwrite=_added_rom.url_cover != rom.url_cover, - url_cover=add_ss_auth_to_url(_added_rom.url_cover), - ) - - path_manual = await fs_resource_handler.get_manual( - rom=_added_rom, - overwrite=_added_rom.url_manual != rom.url_manual, - url_manual=add_ss_auth_to_url(_added_rom.url_manual), - ) - - screenshots_changed = pydash.xor( - _added_rom.url_screenshots or [], rom.url_screenshots or [] - ) - url_screenshots = _added_rom.url_screenshots or [] - path_screenshots = await fs_resource_handler.get_rom_screenshots( - rom=_added_rom, - overwrite=bool(screenshots_changed), - url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots], - ) - - _added_rom.path_cover_s = path_cover_s - _added_rom.path_cover_l = path_cover_l - _added_rom.path_screenshots = path_screenshots - _added_rom.path_manual = path_manual - - # Update the scanned rom with the cover and screenshots paths and update database - db_rom_handler.update_rom( - _added_rom.id, - { - "path_cover_s": path_cover_s, - "path_cover_l": path_cover_l, - "path_screenshots": path_screenshots, - "path_manual": path_manual, - }, + await download_rom_resources( + added_rom=_added_rom, + previous_url_cover=rom.url_cover, + previous_url_manual=rom.url_manual, + previous_url_screenshots=rom.url_screenshots, + metadata_sources=metadata_sources, ) - # Handle special media files from Screenscraper - if _added_rom.ss_metadata and MetadataSource.SS in metadata_sources: - preferred_media_types = get_preferred_media_types() - for media_type in preferred_media_types: - media_path = _added_rom.ss_metadata.get(f"{media_type.value}_path") - media_url = _added_rom.ss_metadata.get(f"{media_type.value}_url") - if media_path and media_url: - await fs_resource_handler.store_media_file( - add_ss_auth_to_url(media_url), - media_path, - ) - - # Handle special media files from ES-DE gamelist.xml - if _added_rom.gamelist_metadata and MetadataSource.GAMELIST in metadata_sources: - preferred_media_types = get_preferred_media_types() - for media_type in preferred_media_types: - if _added_rom.gamelist_metadata.get(f"{media_type.value}_path"): - await fs_resource_handler.store_media_file( - _added_rom.gamelist_metadata[f"{media_type.value}_url"], - _added_rom.gamelist_metadata[f"{media_type.value}_path"], - ) - - # Handle special media files from LaunchBox - if _added_rom.launchbox_metadata and MetadataSource.LAUNCHBOX in metadata_sources: - preferred_media_types = get_preferred_media_types() - for media_type in preferred_media_types: - if _added_rom.launchbox_metadata.get(f"{media_type.value}_path"): - await fs_resource_handler.store_media_file( - _added_rom.launchbox_metadata[f"{media_type.value}_url"], - _added_rom.launchbox_metadata[f"{media_type.value}_path"], - ) - - # Store normal and locked badges - if _added_rom.ra_metadata and MetadataSource.RA in metadata_sources: - for ach in _added_rom.ra_metadata.get("achievements", []): - badge_url_lock = ach.get("badge_url_lock", None) - badge_path_lock = ach.get("badge_path_lock", None) - if badge_url_lock and badge_path_lock: - await fs_resource_handler.store_ra_badge( - badge_url_lock, badge_path_lock - ) - badge_url = ach.get("badge_url", None) - badge_path = ach.get("badge_path", None) - if badge_url and badge_path: - await fs_resource_handler.store_ra_badge(badge_url, badge_path) - await socket_manager.emit( "scan:scanning_rom", SimpleRomSchema.from_orm_with_factory(_added_rom).model_dump( diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 6ed97a3e38..9dcfa3eb83 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -573,7 +573,10 @@ def _filter_by_missing_from_fs(self, query: Query, value: bool) -> Query: predicate = Rom.missing_from_fs.isnot(False) if not value: predicate = not_(predicate) - return query.filter(predicate) + return query.filter(predicate) + # Physical games are never "missing"; exclude them so a stray flag can + # never make one eligible for the missing-roms cleanup that hard-deletes. + return query.filter(and_(predicate, Rom.is_physical.is_(False))) def _filter_by_verified(self, query: Query, value: bool) -> Query: keys_to_check = [ @@ -1490,9 +1493,13 @@ def mark_missing_roms( changes, so a re-scan of an unchanged platform issues no updates. """ keep_set = set(fs_roms_to_keep) + # Physical games have no file on disk, so they must never be flagged missing. rows = session.execute( select(Rom.id, Rom.fs_name, Rom.missing_from_fs).where( - Rom.platform_id == platform_id + and_( + Rom.platform_id == platform_id, + Rom.is_physical.is_(False), + ) ) ).all() @@ -1519,6 +1526,7 @@ def mark_missing_roms( and_( Rom.platform_id == platform_id, Rom.missing_from_fs.is_(True), + Rom.is_physical.is_(False), ) ) .order_by(Rom.fs_name.asc()) diff --git a/backend/handler/metadata/__init__.py b/backend/handler/metadata/__init__.py index 7a64e6e0ce..59522ab162 100644 --- a/backend/handler/metadata/__init__.py +++ b/backend/handler/metadata/__init__.py @@ -11,6 +11,7 @@ from .sgdb_handler import SGDBBaseHandler from .ss_handler import SSHandler from .tgdb_handler import TGDBHandler +from .upc_handler import UPCHandler meta_igdb_handler = IGDBHandler() meta_moby_handler = MobyGamesHandler() @@ -25,3 +26,4 @@ meta_flashpoint_handler = FlashpointHandler() meta_gamelist_handler = GamelistHandler() meta_hltb_handler = HLTBHandler() +meta_upc_handler = UPCHandler() diff --git a/backend/handler/metadata/upc_handler.py b/backend/handler/metadata/upc_handler.py new file mode 100644 index 0000000000..3130755e7c --- /dev/null +++ b/backend/handler/metadata/upc_handler.py @@ -0,0 +1,74 @@ +import re +from typing import Final + +from config import UPC_LOOKUP_API_KEY, UPC_LOOKUP_BASE_URL, UPC_LOOKUP_ENABLED +from logger.logger import log +from utils import get_version +from utils.context import ctx_httpx_client + +from .base_handler import MetadataHandler + +# Retail suffixes/noise commonly present in UPC-database product titles that hurt +# name matching against game-metadata providers (e.g. "Sonic - Nintendo Switch"). +_TITLE_NOISE_RE = re.compile( + r"\s*[-–(]?\s*(video ?game|nintendo switch|playstation \d?|" + r"xbox(?: one| series [sx])?|pc|ntsc|pal|region free|brand new|sealed)\b.*$", + re.IGNORECASE, +) + + +class UPCHandler(MetadataHandler): + """Resolve a UPC/EAN/barcode to a product title via an external lookup service. + + This does not attach provider IDs; the resolved title is fed into the normal + name-based scan so the existing providers do the actual game matching. + """ + + def __init__(self) -> None: + self.base_url = UPC_LOOKUP_BASE_URL.rstrip("/") + self.lookup_url = f"{self.base_url}/lookup" + self.min_title_length: Final = 2 + + @classmethod + def is_enabled(cls) -> bool: + return UPC_LOOKUP_ENABLED + + def _clean_title(self, title: str) -> str: + cleaned = _TITLE_NOISE_RE.sub("", title).strip(" -–:") + return cleaned or title.strip() + + async def resolve_upc_to_title(self, upc: str) -> str | None: + """Return the best product title for a UPC, or None if unresolved.""" + if not self.is_enabled(): + log.warning("UPC lookup is disabled; cannot resolve barcode %s", upc) + return None + + upc = upc.strip() + if not upc: + return None + + headers = {"User-Agent": f"RomM/{get_version()}"} + if UPC_LOOKUP_API_KEY: + headers["user_key"] = UPC_LOOKUP_API_KEY + + httpx_client = ctx_httpx_client.get() + try: + response = await httpx_client.get( + self.lookup_url, + params={"upc": upc}, + headers=headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + except Exception as e: + log.warning("Failed to resolve UPC %s: %s", upc, e) + return None + + items = data.get("items") or [] + for item in items: + title = (item.get("title") or "").strip() + if len(title) >= self.min_title_length: + return self._clean_title(title) + + return None diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 503004b04e..1e67289f00 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -3,12 +3,18 @@ import functools from typing import Any +import pydash import socketio # type: ignore from config.config_manager import config_manager as cm from endpoints.responses.rom import SimpleRomSchema from handler.database import db_platform_handler, db_rom_handler -from handler.filesystem import fs_asset_handler, fs_firmware_handler, fs_rom_handler +from handler.filesystem import ( + fs_asset_handler, + fs_firmware_handler, + fs_resource_handler, + fs_rom_handler, +) from handler.filesystem.roms_handler import FSRom from handler.metadata import ( meta_flashpoint_handler, @@ -41,7 +47,12 @@ ) from handler.metadata.ra_handler import RA_PLATFORM_LIST, RAGameRom from handler.metadata.sgdb_handler import SGDBRom -from handler.metadata.ss_handler import SCREENSAVER_PLATFORM_LIST, SSRom +from handler.metadata.ss_handler import ( + SCREENSAVER_PLATFORM_LIST, + SSRom, + add_ss_auth_to_url, + get_preferred_media_types, +) from logger.formatter import BLUE, LIGHTYELLOW from logger.formatter import highlight as hl from logger.logger import log @@ -52,6 +63,7 @@ from models.user import User from utils import emoji from utils.audio_tags import persist_embedded_cover +from utils.filesystem import sanitize_filename LOGGER_MODULE_NAME = {"module_name": "scan"} @@ -83,6 +95,34 @@ class MetadataSource(enum.StrEnum): PLAYMATCH = "playmatch" # Playmatch +# Sentinel folder for manually-added physical games; it never exists on disk. +PHYSICAL_FS_SUBDIR = ".physical" + + +def build_physical_fs_path(platform: Platform) -> str: + """Sentinel `fs_path` for a file-less physical game on the given platform.""" + return ( + f"{fs_rom_handler.get_roms_fs_structure(platform.fs_slug)}/{PHYSICAL_FS_SUBDIR}" + ) + + +def build_physical_fs_name(platform_id: int, name: str) -> str: + """Build a unique-per-platform `fs_name` for a physical game from its name. + + Physical games have no file, so the sanitized name is used directly (no fake + extension). A numeric suffix is appended on collision with an existing row. + """ + base = sanitize_filename(name) + candidate = base + counter = 2 + while db_rom_handler.get_roms_by_fs_name( + platform_id=platform_id, fs_names=[candidate] + ): + candidate = f"{base} ({counter})" + counter += 1 + return candidate + + def get_main_platform_igdb_id(platform: Platform): cnfg = cm.get_config() @@ -337,6 +377,8 @@ async def scan_rom( "sha1_hash": rom.sha1_hash, "ra_hash": rom.ra_hash, "fs_size_bytes": rom.fs_size_bytes, + "is_physical": rom.is_physical, + "upc": rom.upc, } # Check if files have been parsed and hashed @@ -1088,6 +1130,102 @@ async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom: return Rom(**rom_attrs) +async def download_rom_resources( + added_rom: Rom, + previous_url_cover: str | None, + previous_url_manual: str | None, + previous_url_screenshots: list[str] | None, + metadata_sources: list[str], +) -> None: + """Download and persist cover, manual, screenshots and provider media for a rom. + + Shared by the scan socket flow and the manual physical-game endpoint. Only + re-downloads when the source URL changed, then stores the resulting paths. + """ + path_cover_s, path_cover_l = await fs_resource_handler.get_cover( + entity=added_rom, + overwrite=added_rom.url_cover != previous_url_cover, + url_cover=add_ss_auth_to_url(added_rom.url_cover), + ) + + path_manual = await fs_resource_handler.get_manual( + rom=added_rom, + overwrite=added_rom.url_manual != previous_url_manual, + url_manual=add_ss_auth_to_url(added_rom.url_manual), + ) + + screenshots_changed = pydash.xor( + added_rom.url_screenshots or [], previous_url_screenshots or [] + ) + url_screenshots = added_rom.url_screenshots or [] + path_screenshots = await fs_resource_handler.get_rom_screenshots( + rom=added_rom, + overwrite=bool(screenshots_changed), + url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots], + ) + + added_rom.path_cover_s = path_cover_s + added_rom.path_cover_l = path_cover_l + added_rom.path_screenshots = path_screenshots + added_rom.path_manual = path_manual + + db_rom_handler.update_rom( + added_rom.id, + { + "path_cover_s": path_cover_s, + "path_cover_l": path_cover_l, + "path_screenshots": path_screenshots, + "path_manual": path_manual, + }, + ) + + # Handle special media files from Screenscraper + if added_rom.ss_metadata and MetadataSource.SS in metadata_sources: + preferred_media_types = get_preferred_media_types() + for media_type in preferred_media_types: + media_path = added_rom.ss_metadata.get(f"{media_type.value}_path") + media_url = added_rom.ss_metadata.get(f"{media_type.value}_url") + if media_path and media_url: + await fs_resource_handler.store_media_file( + add_ss_auth_to_url(media_url), + media_path, + ) + + # Handle special media files from ES-DE gamelist.xml + if added_rom.gamelist_metadata and MetadataSource.GAMELIST in metadata_sources: + preferred_media_types = get_preferred_media_types() + for media_type in preferred_media_types: + if added_rom.gamelist_metadata.get(f"{media_type.value}_path"): + await fs_resource_handler.store_media_file( + added_rom.gamelist_metadata[f"{media_type.value}_url"], + added_rom.gamelist_metadata[f"{media_type.value}_path"], + ) + + # Handle special media files from LaunchBox + if added_rom.launchbox_metadata and MetadataSource.LAUNCHBOX in metadata_sources: + preferred_media_types = get_preferred_media_types() + for media_type in preferred_media_types: + if added_rom.launchbox_metadata.get(f"{media_type.value}_path"): + await fs_resource_handler.store_media_file( + added_rom.launchbox_metadata[f"{media_type.value}_url"], + added_rom.launchbox_metadata[f"{media_type.value}_path"], + ) + + # Store normal and locked achievement badges from RetroAchievements + if added_rom.ra_metadata and MetadataSource.RA in metadata_sources: + for ach in added_rom.ra_metadata.get("achievements", []): + badge_url_lock = ach.get("badge_url_lock", None) + badge_path_lock = ach.get("badge_path_lock", None) + if badge_url_lock and badge_path_lock: + await fs_resource_handler.store_ra_badge( + badge_url_lock, badge_path_lock + ) + badge_url = ach.get("badge_url", None) + badge_path = ach.get("badge_path", None) + if badge_url and badge_path: + await fs_resource_handler.store_ra_badge(badge_url, badge_path) + + async def _scan_asset(file_name: str, asset_path: str, should_hash: bool = False): file_path = f"{asset_path}/{file_name}" file_size = await fs_asset_handler.get_file_size(file_path) diff --git a/backend/models/rom.py b/backend/models/rom.py index ec1dfb8dd5..3c8902f536 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -357,6 +357,11 @@ class Rom(BaseModel): missing_from_fs: Mapped[bool] = mapped_column(default=False, nullable=False) + # Physical games are manually-added rows with no file on disk; they carry the + # same metadata as digital ROMs but must never be flagged missing or cleaned up. + is_physical: Mapped[bool] = mapped_column(default=False, nullable=False) + upc: Mapped[str | None] = mapped_column(String(length=64), default=None) + platform_id: Mapped[int] = mapped_column( ForeignKey("platforms.id", ondelete="CASCADE") ) diff --git a/backend/tests/endpoints/roms/test_physical.py b/backend/tests/endpoints/roms/test_physical.py new file mode 100644 index 0000000000..5ec85b13dd --- /dev/null +++ b/backend/tests/endpoints/roms/test_physical.py @@ -0,0 +1,192 @@ +from unittest.mock import AsyncMock, patch + +from fastapi import status +from fastapi.testclient import TestClient + +from handler.database import db_rom_handler +from models.platform import Platform +from models.rom import Rom + +MOCK_IGDB_ID = 424242 + + +async def _fake_scan_rom(*, rom: Rom, platform: Platform, fs_rom, **kwargs) -> Rom: + """Return a fresh Rom (as the real scan_rom does) that drops the physical + fields, so the endpoint's re-assert path is exercised.""" + return Rom( + id=rom.id, + platform_id=platform.id, + fs_name=fs_rom["fs_name"], + fs_path=rom.fs_path, + fs_size_bytes=0, + name=rom.name, + igdb_id=MOCK_IGDB_ID, + url_cover="", + url_manual="", + url_screenshots=[], + ) + + +def _auth(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +@patch("endpoints.roms.download_rom_resources", new_callable=AsyncMock) +@patch("endpoints.roms.scan_rom", side_effect=_fake_scan_rom) +def test_create_physical_rom_by_name( + scan_rom_mock: AsyncMock, + download_mock: AsyncMock, + client: TestClient, + access_token: str, + platform: Platform, +): + response = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": platform.id, "name": "Sonic the Hedgehog"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["is_physical"] is True + assert body["upc"] is None + assert body["name"] == "Sonic the Hedgehog" + assert body["fs_path"].endswith("/.physical") + assert body["igdb_id"] == MOCK_IGDB_ID + assert scan_rom_mock.called + assert download_mock.called + + stored = db_rom_handler.get_rom(body["id"]) + assert stored is not None + assert stored.is_physical is True + + +@patch("endpoints.roms.download_rom_resources", new_callable=AsyncMock) +@patch("endpoints.roms.scan_rom", side_effect=_fake_scan_rom) +@patch( + "endpoints.roms.meta_upc_handler.resolve_upc_to_title", + new_callable=AsyncMock, + return_value="Sonic the Hedgehog", +) +def test_create_physical_rom_by_upc_resolves_title( + resolve_mock: AsyncMock, + scan_rom_mock: AsyncMock, + download_mock: AsyncMock, + client: TestClient, + access_token: str, + platform: Platform, +): + response = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": platform.id, "upc": "012345678905"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + assert body["name"] == "Sonic the Hedgehog" + assert body["upc"] == "012345678905" + assert resolve_mock.called + + +@patch( + "endpoints.roms.meta_upc_handler.resolve_upc_to_title", + new_callable=AsyncMock, + return_value=None, +) +def test_create_physical_rom_unresolved_upc_returns_400( + resolve_mock: AsyncMock, + client: TestClient, + access_token: str, + platform: Platform, +): + response = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": platform.id, "upc": "000000000000"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_create_physical_rom_requires_name_or_upc( + client: TestClient, access_token: str, platform: Platform +): + response = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": platform.id}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_create_physical_rom_unknown_platform_returns_404( + client: TestClient, access_token: str +): + response = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": 999999, "name": "Sonic"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@patch("endpoints.roms.download_rom_resources", new_callable=AsyncMock) +@patch("endpoints.roms.scan_rom", side_effect=_fake_scan_rom) +def test_create_physical_rom_name_collision_suffixes( + scan_rom_mock: AsyncMock, + download_mock: AsyncMock, + client: TestClient, + access_token: str, + platform: Platform, +): + payload = {"platform_id": platform.id, "name": "Sonic"} + first = client.post("/api/roms/physical", headers=_auth(access_token), json=payload) + second = client.post( + "/api/roms/physical", headers=_auth(access_token), json=payload + ) + + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_200_OK + + first_rom = db_rom_handler.get_rom(first.json()["id"]) + second_rom = db_rom_handler.get_rom(second.json()["id"]) + assert first_rom is not None and second_rom is not None + assert first_rom.fs_name != second_rom.fs_name + assert second_rom.fs_name == "Sonic (2)" + + +def test_create_physical_rom_requires_write_scope( + client: TestClient, viewer_access_token: str, platform: Platform +): + response = client.post( + "/api/roms/physical", + headers=_auth(viewer_access_token), + json={"platform_id": platform.id, "name": "Sonic"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@patch("endpoints.roms.download_rom_resources", new_callable=AsyncMock) +@patch("endpoints.roms.scan_rom", side_effect=_fake_scan_rom) +def test_physical_rom_intermingles_in_listing( + scan_rom_mock: AsyncMock, + download_mock: AsyncMock, + client: TestClient, + access_token: str, + rom: Rom, + platform: Platform, +): + create = client.post( + "/api/roms/physical", + headers=_auth(access_token), + json={"platform_id": platform.id, "name": "Sonic"}, + ) + physical_id = create.json()["id"] + + listing = client.get( + f"/api/roms?platform_id={platform.id}", + headers=_auth(access_token), + ) + assert listing.status_code == status.HTTP_200_OK + ids = {item["id"] for item in listing.json()["items"]} + assert {rom.id, physical_id}.issubset(ids) diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 29f4247a93..d3fc3c7764 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -37,6 +37,44 @@ def test_platforms(): assert len(platforms) == 1 +def _add_physical_rom(platform: Platform, name: str = "Physical Game") -> Rom: + return db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name=name, + fs_name=name, + fs_path=f"{platform.slug}/roms/.physical", + fs_size_bytes=0, + is_physical=True, + ) + ) + + +def test_mark_missing_roms_skips_physical(rom: Rom, platform: Platform): + physical = _add_physical_rom(platform) + + # An empty keep-list would normally flag every rom on the platform as missing. + still_missing = db_rom_handler.mark_missing_roms(platform.id, []) + + missing_ids = {r.id for r in still_missing} + assert rom.id in missing_ids + assert physical.id not in missing_ids + + refreshed = db_rom_handler.get_rom(physical.id) + assert refreshed is not None + assert refreshed.missing_from_fs is False + + +def test_get_roms_scalar_missing_excludes_physical(platform: Platform): + physical = _add_physical_rom(platform) + # Even if a physical rom is erroneously flagged, it must not be returned as + # missing (the cleanup task hard-deletes whatever this query returns). + db_rom_handler.update_rom(physical.id, {"missing_from_fs": True}) + + missing = db_rom_handler.get_roms_scalar(platform_ids=[platform.id], missing=True) + assert physical.id not in {r.id for r in missing} + + def test_roms(rom: Rom, platform: Platform): db_rom_handler.add_rom( Rom( diff --git a/backend/tests/models/test_rom.py b/backend/tests/models/test_rom.py index 07808e85f8..957b60aa58 100644 --- a/backend/tests/models/test_rom.py +++ b/backend/tests/models/test_rom.py @@ -1,3 +1,5 @@ +from handler.database import db_rom_handler +from models.platform import Platform from models.rom import Rom @@ -6,6 +8,30 @@ def test_rom(rom: Rom): assert rom.full_path == "test_platform_slug/roms/test_rom.zip" +def test_rom_defaults_to_non_physical(rom: Rom): + assert rom.is_physical is False + assert rom.upc is None + + +def test_physical_rom_round_trips(platform: Platform): + rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name="Sonic the Hedgehog", + fs_name="Sonic the Hedgehog", + fs_path=f"{platform.slug}/roms/.physical", + fs_size_bytes=0, + is_physical=True, + upc="012345678905", + ) + ) + + stored = db_rom_handler.get_rom(rom.id) + assert stored is not None + assert stored.is_physical is True + assert stored.upc == "012345678905" + + def test_rom_with_libretro_match_is_identified(rom: Rom): rom.libretro_id = "abc123" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3916b11574..581c2f4ad7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@floating-ui/vue": "^1.1.11", "@mdi/font": "7.4.47", "@vueuse/core": "^13.7.0", + "@zxing/browser": "^0.1.5", "axios": "^1.18.1", "bowser": "^2.14.1", "cronstrue": "^2.57.0", @@ -5895,6 +5896,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -11643,6 +11679,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/ts-dedent": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 002d3be3d5..e96212d389 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "@floating-ui/vue": "^1.1.11", "@mdi/font": "7.4.47", "@vueuse/core": "^13.7.0", + "@zxing/browser": "^0.1.5", "axios": "^1.18.1", "bowser": "^2.14.1", "cronstrue": "^2.57.0", diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 3b0cac5b6e..25358af124 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -117,6 +117,7 @@ export type { PermissionGroupSchema } from './models/PermissionGroupSchema'; export type { PermissionGroupUpdate } from './models/PermissionGroupUpdate'; export type { PermissionScopeSchema } from './models/PermissionScopeSchema'; export type { PermissionsResponse } from './models/PermissionsResponse'; +export type { PhysicalRomCreateForm } from './models/PhysicalRomCreateForm'; export type { PlatformBindingPayload } from './models/PlatformBindingPayload'; export type { PlatformSchema } from './models/PlatformSchema'; export type { PlaySessionEntry } from './models/PlaySessionEntry'; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index e1ba25d657..900aa37145 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -92,6 +92,8 @@ export type DetailedRomSchema = { created_at: string; updated_at: string; missing_from_fs: boolean; + is_physical: boolean; + upc: (string | null); has_notes: boolean; rom_user: RomUserSchema; merged_screenshots: Array; diff --git a/frontend/src/__generated__/models/PhysicalRomCreateForm.ts b/frontend/src/__generated__/models/PhysicalRomCreateForm.ts new file mode 100644 index 0000000000..289cf3b059 --- /dev/null +++ b/frontend/src/__generated__/models/PhysicalRomCreateForm.ts @@ -0,0 +1,23 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type PhysicalRomCreateForm = { + /** + * Platform the game belongs to. + */ + platform_id: number; + /** + * Game name to match metadata against. + */ + name?: (string | null); + /** + * UPC/EAN/barcode of the physical copy. + */ + upc?: (string | null); + /** + * Metadata providers to match against; defaults to all enabled. + */ + metadata_sources?: (Array | null); +}; + diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 3f83b99180..d7773ee0d3 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -84,6 +84,8 @@ export type SimpleRomSchema = { created_at: string; updated_at: string; missing_from_fs: boolean; + is_physical: boolean; + upc: (string | null); has_notes: boolean; rom_user: RomUserSchema; merged_screenshots: Array; diff --git a/frontend/src/locales/bg_BG/rom.json b/frontend/src/locales/bg_BG/rom.json index e8f12394fd..fad968be56 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Включи звука", "youtube-video-id": "YouTube видео ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index a801a9db44..f7122a0713 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Zrušit ztlumení", "youtube-video-id": "ID YouTube videa", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index 5a19744039..37ce2e69f0 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Stummschaltung aufheben", "youtube-video-id": "YouTube-Video-ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Physisches Spiel hinzufügen", + "add-physical-game-desc": "Füge ein Spiel, das du physisch besitzt, manuell hinzu. Die Metadaten werden automatisch zugeordnet.", + "physical-game-name": "Spielname", + "physical-upc": "UPC / Barcode", + "physical-name-or-upc-required": "Gib einen Spielnamen oder eine UPC ein", + "physical-game-added": "Physisches Spiel hinzugefügt", + "physical-game-add-failed": "Physisches Spiel konnte nicht hinzugefügt werden", + "barcode-scan-title": "Barcode scannen", + "barcode-scan-hint": "Richte die Kamera auf den Barcode des Spiels.", + "barcode-scan-unsupported": "Das Scannen per Kamera ist auf diesem Gerät oder Browser nicht verfügbar.", + "barcode-scan-denied": "Kamerazugriff wurde verweigert.", + "barcode-scan-failed": "Die Kamera konnte nicht gestartet werden." } diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 1bb1683aa8..60f865af61 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -429,5 +429,17 @@ "media-video": "Video", "media-video-normalized": "Video (standardized)", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index 7d451c8f31..07695d1924 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -429,5 +429,17 @@ "media-video": "Video", "media-video-normalized": "Video (standardized)", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index 853aedc19f..35dae19397 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Quitar silencio", "youtube-video-id": "ID de vídeo de YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Añadir juego físico", + "add-physical-game-desc": "Añade manualmente un juego que posees físicamente. Sus metadatos se asociarán automáticamente.", + "physical-game-name": "Nombre del juego", + "physical-upc": "UPC / código de barras", + "physical-name-or-upc-required": "Introduce un nombre de juego o un UPC", + "physical-game-added": "Juego físico añadido", + "physical-game-add-failed": "No se pudo añadir el juego físico", + "barcode-scan-title": "Escanear código de barras", + "barcode-scan-hint": "Apunta la cámara al código de barras del juego.", + "barcode-scan-unsupported": "El escaneo con cámara no está disponible en este dispositivo o navegador.", + "barcode-scan-denied": "Se denegó el permiso de la cámara.", + "barcode-scan-failed": "No se pudo iniciar la cámara." } diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 352a5bff3e..c4a3d0247c 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Activer le son", "youtube-video-id": "ID vidéo YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Ajouter un jeu physique", + "add-physical-game-desc": "Ajoutez manuellement un jeu que vous possédez physiquement. Ses métadonnées seront associées automatiquement.", + "physical-game-name": "Nom du jeu", + "physical-upc": "UPC / code-barres", + "physical-name-or-upc-required": "Saisissez un nom de jeu ou un UPC", + "physical-game-added": "Jeu physique ajouté", + "physical-game-add-failed": "Échec de l'ajout du jeu physique", + "barcode-scan-title": "Scanner le code-barres", + "barcode-scan-hint": "Pointez la caméra vers le code-barres du jeu.", + "barcode-scan-unsupported": "Le scan par caméra n'est pas disponible sur cet appareil ou ce navigateur.", + "barcode-scan-denied": "L'accès à la caméra a été refusé.", + "barcode-scan-failed": "Impossible de démarrer la caméra." } diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index fced84fa3e..89b88e559f 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Némítás feloldása", "youtube-video-id": "YouTube videó azonosító", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 2a5741729c..b1c5bc70aa 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Riattiva audio", "youtube-video-id": "ID video YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Aggiungi gioco fisico", + "add-physical-game-desc": "Aggiungi manualmente un gioco che possiedi fisicamente. I metadati verranno associati automaticamente.", + "physical-game-name": "Nome del gioco", + "physical-upc": "UPC / codice a barre", + "physical-name-or-upc-required": "Inserisci un nome di gioco o un UPC", + "physical-game-added": "Gioco fisico aggiunto", + "physical-game-add-failed": "Impossibile aggiungere il gioco fisico", + "barcode-scan-title": "Scansiona codice a barre", + "barcode-scan-hint": "Punta la fotocamera sul codice a barre del gioco.", + "barcode-scan-unsupported": "La scansione con fotocamera non è disponibile su questo dispositivo o browser.", + "barcode-scan-denied": "Permesso della fotocamera negato.", + "barcode-scan-failed": "Impossibile avviare la fotocamera." } diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 04f9b4dc6a..f888efdbf2 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "ミュート解除", "youtube-video-id": "YouTube動画ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index 6dbab90ce5..fc6ee193c7 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "음소거 해제", "youtube-video-id": "YouTube 동영상 ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 0d8378def2..f54eee42db 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Wyłącz wyciszenie", "youtube-video-id": "ID filmu YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index b22758ea0b..ba72cb43ec 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Reativar som", "youtube-video-id": "ID do vídeo do YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Adicionar jogo físico", + "add-physical-game-desc": "Adicione manualmente um jogo que você possui fisicamente. Os metadados serão associados automaticamente.", + "physical-game-name": "Nome do jogo", + "physical-upc": "UPC / código de barras", + "physical-name-or-upc-required": "Informe um nome de jogo ou um UPC", + "physical-game-added": "Jogo físico adicionado", + "physical-game-add-failed": "Falha ao adicionar jogo físico", + "barcode-scan-title": "Escanear código de barras", + "barcode-scan-hint": "Aponte a câmera para o código de barras do jogo.", + "barcode-scan-unsupported": "A leitura por câmera não está disponível neste dispositivo ou navegador.", + "barcode-scan-denied": "A permissão da câmera foi negada.", + "barcode-scan-failed": "Não foi possível iniciar a câmera." } diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index 98b08235c1..2e7536b686 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Activează sunetul", "youtube-video-id": "ID video YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index f6c2448b69..f0db569000 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Включить звук", "youtube-video-id": "ID видео YouTube", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index ffbe913297..c011689424 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "Sesi aç", "youtube-video-id": "YouTube video ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 160ca0c6d2..471b3ac113 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "取消静音", "youtube-video-id": "YouTube 视频 ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index dff1237869..7f005c7db2 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -429,5 +429,17 @@ "volume-unmute": "取消靜音", "youtube-video-id": "YouTube 影片 ID", "convert-to-folder-title": "Convert to folder ROM?", - "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible." + "convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", + "physical-game-name": "Game name", + "physical-upc": "UPC / barcode", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-game-added": "Physical game added", + "physical-game-add-failed": "Failed to add physical game", + "barcode-scan-title": "Scan barcode", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera." } diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index b11a1e70c8..0e6c80f5b9 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -426,6 +426,27 @@ async function searchRom({ }); } +async function createPhysicalRom({ + platformId, + name, + upc, + metadataSources, +}: { + platformId: number; + name?: string; + upc?: string; + metadataSources?: string[]; +}) { + // POST /roms/physical — manually add a file-less physical game and + // auto-link its metadata (a single quick scan). Returns the created rom. + return api.post("/roms/physical", { + platform_id: platformId, + name: name || null, + upc: upc || null, + metadata_sources: metadataSources ?? null, + }); +} + function triggerFileDownload(href: string) { return new Promise((resolve) => { const a = document.createElement("a"); @@ -899,6 +920,7 @@ export default { downloadRom, bulkDownloadRoms, searchRom, + createPhysicalRom, updateRom, uploadManuals, removeManual, diff --git a/frontend/src/types/emitter.d.ts b/frontend/src/types/emitter.d.ts index d2f3c9606c..435fa4e0bd 100644 --- a/frontend/src/types/emitter.d.ts +++ b/frontend/src/types/emitter.d.ts @@ -54,6 +54,9 @@ export type Events = { showCopyDownloadLinkDialog: string; showDeleteRomDialog: SimpleRom[]; showUploadRomDialog: Platform | null; + /** v2 — opens the add-physical-game dialog. When a Platform is + * provided the platform field is prefilled; null lets the user pick. */ + showAddPhysicalGameDialog: Platform | null; showDeleteFirmwareDialog: FirmwareSchema[]; addFirmwareDialog: null; showAddPlatformDialog: null; diff --git a/frontend/src/v2/components/Dialogs/AddPhysicalGameDialog.vue b/frontend/src/v2/components/Dialogs/AddPhysicalGameDialog.vue new file mode 100644 index 0000000000..6767475ede --- /dev/null +++ b/frontend/src/v2/components/Dialogs/AddPhysicalGameDialog.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/frontend/src/v2/components/Dialogs/GlobalDialogs.vue b/frontend/src/v2/components/Dialogs/GlobalDialogs.vue index 95423ce7ea..f39a5e69a6 100644 --- a/frontend/src/v2/components/Dialogs/GlobalDialogs.vue +++ b/frontend/src/v2/components/Dialogs/GlobalDialogs.vue @@ -3,6 +3,7 @@ // mount-points that need to live at the layout level so they overlay // every route. All surfaces here are v2-native. import AboutDialog from "@/v2/components/Dialogs/AboutDialog.vue"; +import AddPhysicalGameDialog from "@/v2/components/Dialogs/AddPhysicalGameDialog.vue"; import ChangelogDialog from "@/v2/components/Dialogs/ChangelogDialog.vue"; import CopyDownloadLinkDialog from "@/v2/components/Dialogs/CopyDownloadLinkDialog.vue"; import CreateSmartCollectionDialog from "@/v2/components/Dialogs/CreateSmartCollectionDialog.vue"; @@ -42,6 +43,7 @@ defineOptions({ inheritAttrs: false }); + diff --git a/frontend/src/v2/components/Gallery/PlatformHead.vue b/frontend/src/v2/components/Gallery/PlatformHead.vue index 7c9d4d6433..0b3ef9d126 100644 --- a/frontend/src/v2/components/Gallery/PlatformHead.vue +++ b/frontend/src/v2/components/Gallery/PlatformHead.vue @@ -61,6 +61,7 @@ defineProps<{ scan: string; random: string; download: string; + addPhysical: string; }; }>(); @@ -70,6 +71,7 @@ defineEmits<{ (e: "scan"): void; (e: "random"): void; (e: "download"): void; + (e: "add-physical"): void; }>(); // A square icon sized per breakpoint (smaller on phones). Driving the size @@ -181,6 +183,16 @@ const iconSize = computed(() => (xs.value ? 116 : 148)); :tooltip="labels.upload" @click="$emit('upload')" /> + (() => ({ created_at: "", updated_at: "", missing_from_fs: false, + is_physical: false, + upc: null, has_notes: false, files: [], sibling_roms: [], diff --git a/frontend/src/v2/components/shared/BarcodeScannerDialog.vue b/frontend/src/v2/components/shared/BarcodeScannerDialog.vue new file mode 100644 index 0000000000..e9d82b98ab --- /dev/null +++ b/frontend/src/v2/components/shared/BarcodeScannerDialog.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/frontend/src/v2/views/Gallery/Platform.vue b/frontend/src/v2/views/Gallery/Platform.vue index a265ce4d63..98de0fea25 100644 --- a/frontend/src/v2/views/Gallery/Platform.vue +++ b/frontend/src/v2/views/Gallery/Platform.vue @@ -17,14 +17,16 @@ // Action ribbon (Upload / Scan) lives inside the head component; // Edit (custom_name) and Delete moved inline into the Settings tab. import { RDivider, type RTabNavItem } from "@v2/lib"; +import type { Emitter } from "mitt"; import { storeToRefs } from "pinia"; -import { computed, nextTick, onMounted, ref, watch } from "vue"; +import { computed, inject, nextTick, onMounted, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router"; import { ROUTES } from "@/plugins/router"; import platformApi from "@/services/api/platform"; import romApi from "@/services/api/rom"; import storePlatforms, { type Platform } from "@/stores/platforms"; +import type { Events } from "@/types/emitter"; import { formatBytes } from "@/utils"; import FirmwareTab from "@/v2/components/Gallery/FirmwareTab.vue"; import GalleryShell from "@/v2/components/Gallery/GalleryShell.vue"; @@ -43,6 +45,7 @@ const platformsStore = storePlatforms(); const galleryRoms = storeGalleryRoms(); const snackbar = useSnackbar(); const confirm = useConfirm(); +const emitter = inject>("emitter"); const { currentPlatform, total } = storeToRefs(galleryRoms); const notFound = ref(false); @@ -97,6 +100,7 @@ const headLabels = computed(() => ({ scan: t("platform.scan-platform"), random: t("platform.random-rom"), download: t("platform.download-platform"), + addPhysical: t("rom.add-physical-game"), })); function onTabChange(next: string) { @@ -317,6 +321,11 @@ function onScan() { scanOpen.value = true; } +function onAddPhysical() { + if (!currentPlatform.value) return; + emitter?.emit("showAddPhysicalGameDialog", currentPlatform.value); +} + // Random ROM — pick one game from this platform and jump to its // details. Mirrors the Home RandomPickWidget approach: a cheap // count-only fetch gives the `total`, then a single-item fetch at a @@ -437,6 +446,7 @@ async function onDelete() { @update:tab="onTabChange" @upload="onUploadRoms" @scan="onScan" + @add-physical="onAddPhysical" @random="onRandomGame" @download="onDownload" /> @@ -465,6 +475,7 @@ async function onDelete() { @update:tab="onTabChange" @upload="onUploadRoms" @scan="onScan" + @add-physical="onAddPhysical" @random="onRandomGame" @download="onDownload" />