diff --git a/backend/alembic/versions/0099_physical_roms.py b/backend/alembic/versions/0099_physical_roms.py new file mode 100644 index 0000000000..d8b86216d2 --- /dev/null +++ b/backend/alembic/versions/0099_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 4a89812e84..078ccddfd6 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -128,6 +128,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 75094a90bb..5f80e22eda 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -339,6 +339,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 b6364fc1c0..47ca5dea6b 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,10 +65,19 @@ meta_playmatch_handler, meta_ra_handler, meta_ss_handler, + meta_upc_handler, ) from handler.metadata.launchbox_handler.media import populate_rom_specific_paths 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 @@ -1440,6 +1450,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 2a90678ab0..31fce2202b 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 @@ -36,11 +35,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, @@ -422,90 +421,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 cb0243d791..4983e51798 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -628,7 +628,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 = [ @@ -1570,9 +1573,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() @@ -1599,6 +1606,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 cc7c41cd12..d4950fbb94 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() @@ -347,6 +387,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 @@ -1135,6 +1177,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 4059bd2d01..d5215ae851 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -358,6 +358,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 e18d062e9e..5991c8e454 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 ea214ee79c..17a932247b 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", @@ -5896,6 +5897,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", @@ -11644,6 +11680,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 19105574bc..4422beb42e 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 79b398b263..fbcda5427c 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 b2c99c0bd4..8426ea60eb 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -91,6 +91,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 ed03254bac..88e7294c19 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -83,6 +83,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 0fddb49ba2..95a7fbd79c 100644 --- a/frontend/src/locales/bg_BG/rom.json +++ b/frontend/src/locales/bg_BG/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Условие за победа", "add-new-note": "Добави нова бележка", "add-note": "Добави бележка", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Добави в колекция", "add-to-favorites": "Добави в любими", "adding-to-collection": "Добавяне на {n} ROM-а в колекция", @@ -29,6 +31,11 @@ "artwork-empty": "Няма налична графика за тази игра.", "artwork-open": "Отваряне на {name}", "backlogged": "В списъка", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Въртяща се 3D кутия за {title}", "by": "от", "cant-copy-link": "Линкът не може да бъде копиран, копирай го ръчно", @@ -255,6 +262,11 @@ "paused": "На пауза", "pdf-toggle-sidebar": "Превключи страничната лента", "personal": "Лично", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Избери корица", "pick-track-prompt": "Избери песен, за да започне възпроизвеждането", "play": "Играй", diff --git a/frontend/src/locales/cs_CZ/rom.json b/frontend/src/locales/cs_CZ/rom.json index e128020159..e637ea434c 100644 --- a/frontend/src/locales/cs_CZ/rom.json +++ b/frontend/src/locales/cs_CZ/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Podmínka vítězství", "add-new-note": "Přidat novou poznámku", "add-note": "Přidat poznámku", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Přidat do kolekce", "add-to-favorites": "Přidat mezi oblíbené", "adding-to-collection": "Přidávání {n} ROM do kolekce", @@ -29,6 +31,11 @@ "artwork-empty": "Pro tuto hru není k dispozici žádná grafika.", "artwork-open": "Otevřít {name}", "backlogged": "Odloženo", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Otočná 3D krabice pro {title}", "by": "od", "cant-copy-link": "Nelze zkopírovat odkaz do schránky, zkopírujte ručně", @@ -255,6 +262,11 @@ "paused": "Pozastaveno", "pdf-toggle-sidebar": "Přepnout postranní panel", "personal": "Osobní", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Vybrat obal", "pick-track-prompt": "Vyberte skladbu pro spuštění přehrávání", "play": "Hrát", diff --git a/frontend/src/locales/de_DE/rom.json b/frontend/src/locales/de_DE/rom.json index c7ee00c181..b4858bcb98 100644 --- a/frontend/src/locales/de_DE/rom.json +++ b/frontend/src/locales/de_DE/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Siegbedingung", "add-new-note": "Neue Notiz hinzufügen", "add-note": "Notiz hinzufügen", + "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.", "add-to-collection": "Zu Sammlung hinzufügen", "add-to-favorites": "Zu Favoriten hinzufügen", "adding-to-collection": "Füge {n} ROMs zu Sammlung hinzu", @@ -29,6 +31,11 @@ "artwork-empty": "Kein Artwork für dieses Spiel verfügbar.", "artwork-open": "{name} öffnen", "backlogged": "Vorgemerkt", + "barcode-scan-denied": "Kamerazugriff wurde verweigert.", + "barcode-scan-failed": "Die Kamera konnte nicht gestartet werden.", + "barcode-scan-hint": "Richte die Kamera auf den Barcode des Spiels.", + "barcode-scan-title": "Barcode scannen", + "barcode-scan-unsupported": "Das Scannen per Kamera ist auf diesem Gerät oder Browser nicht verfügbar.", "box3d-alt": "Drehbares 3D-Cover von {title}", "by": "nach", "cant-copy-link": "Link kann nicht in Zwischenablage kopiert werden. Bitte manuell kopieren.", @@ -255,6 +262,11 @@ "paused": "Pausiert", "pdf-toggle-sidebar": "Seitenleiste umschalten", "personal": "Persönlich", + "physical-game-add-failed": "Physisches Spiel konnte nicht hinzugefügt werden", + "physical-game-added": "Physisches Spiel hinzugefügt", + "physical-game-name": "Spielname", + "physical-name-or-upc-required": "Gib einen Spielnamen oder eine UPC ein", + "physical-upc": "UPC / Barcode", "pick-cover": "Cover auswählen", "pick-track-prompt": "Wähle einen Track zum Abspielen", "play": "Spielen", diff --git a/frontend/src/locales/en_GB/rom.json b/frontend/src/locales/en_GB/rom.json index 03946e4d07..14b2b47081 100644 --- a/frontend/src/locales/en_GB/rom.json +++ b/frontend/src/locales/en_GB/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Win condition", "add-new-note": "Add New Note", "add-note": "Add Note", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Add to collection", "add-to-favorites": "Add to favourites", "adding-to-collection": "Adding {n} ROMs to collection", @@ -29,6 +31,11 @@ "artwork-empty": "No artwork available for this game.", "artwork-open": "Open {name}", "backlogged": "Backlogged", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", @@ -255,6 +262,11 @@ "paused": "Paused", "pdf-toggle-sidebar": "Toggle sidebar", "personal": "Personal", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Pick a cover", "pick-track-prompt": "Pick a track to start playing", "play": "Play", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index 0cef8f1474..14750d0bce 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Win condition", "add-new-note": "Add New Note", "add-note": "Add Note", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Add to collection", "add-to-favorites": "Add to favourites", "adding-to-collection": "Adding {n} ROMs to collection", @@ -29,6 +31,11 @@ "artwork-empty": "No artwork available for this game.", "artwork-open": "Open {name}", "backlogged": "Backlogged", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Rotatable 3D box art for {title}", "by": "by", "cant-copy-link": "Can't copy link to clipboard, copy it manually", @@ -255,6 +262,11 @@ "paused": "Paused", "pdf-toggle-sidebar": "Toggle sidebar", "personal": "Personal", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Pick a cover", "pick-track-prompt": "Pick a track to start playing", "play": "Play", diff --git a/frontend/src/locales/es_ES/rom.json b/frontend/src/locales/es_ES/rom.json index d2135f1ac2..46e32a748e 100644 --- a/frontend/src/locales/es_ES/rom.json +++ b/frontend/src/locales/es_ES/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Condición de victoria", "add-new-note": "Agregar Nueva Nota", "add-note": "Agregar Nota", + "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.", "add-to-collection": "Añadir a la colección", "add-to-favorites": "Añadir a favoritos", "adding-to-collection": "Añadiendo {n} ROMs a la colección", @@ -29,6 +31,11 @@ "artwork-empty": "No hay material gráfico disponible para este juego.", "artwork-open": "Abrir {name}", "backlogged": "Pendiente", + "barcode-scan-denied": "Se denegó el permiso de la cámara.", + "barcode-scan-failed": "No se pudo iniciar la cámara.", + "barcode-scan-hint": "Apunta la cámara al código de barras del juego.", + "barcode-scan-title": "Escanear código de barras", + "barcode-scan-unsupported": "El escaneo con cámara no está disponible en este dispositivo o navegador.", "box3d-alt": "Carátula 3D giratoria de {title}", "by": "por", "cant-copy-link": "No se pudo copiar el link al portapapeles, copialo manualmente", @@ -255,6 +262,11 @@ "paused": "Pausado", "pdf-toggle-sidebar": "Mostrar/ocultar barra lateral", "personal": "Personal", + "physical-game-add-failed": "No se pudo añadir el juego físico", + "physical-game-added": "Juego físico añadido", + "physical-game-name": "Nombre del juego", + "physical-name-or-upc-required": "Introduce un nombre de juego o un UPC", + "physical-upc": "UPC / código de barras", "pick-cover": "Elige una portada", "pick-track-prompt": "Selecciona una pista para reproducir", "play": "Jugar", diff --git a/frontend/src/locales/fr_FR/rom.json b/frontend/src/locales/fr_FR/rom.json index 0042dbddf0..ec2a8e8bd7 100644 --- a/frontend/src/locales/fr_FR/rom.json +++ b/frontend/src/locales/fr_FR/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Condition de victoire", "add-new-note": "Ajouter une Nouvelle Note", "add-note": "Ajouter une Note", + "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.", "add-to-collection": "Ajouter à la collection", "add-to-favorites": "Ajouter aux favoris", "adding-to-collection": "Ajout de {n} ROMs à la collection", @@ -29,6 +31,11 @@ "artwork-empty": "Aucune illustration disponible pour ce jeu.", "artwork-open": "Ouvrir {name}", "backlogged": "En attente", + "barcode-scan-denied": "L'accès à la caméra a été refusé.", + "barcode-scan-failed": "Impossible de démarrer la caméra.", + "barcode-scan-hint": "Pointez la caméra vers le code-barres du jeu.", + "barcode-scan-title": "Scanner le code-barres", + "barcode-scan-unsupported": "Le scan par caméra n'est pas disponible sur cet appareil ou ce navigateur.", "box3d-alt": "Boîtier 3D pivotant de {title}", "by": "par", "cant-copy-link": "Impossible de copier le lien dans le presse-papiers, copiez-le manuellement", @@ -255,6 +262,11 @@ "paused": "En pause", "pdf-toggle-sidebar": "Basculer la barre latérale", "personal": "Personnel", + "physical-game-add-failed": "Échec de l'ajout du jeu physique", + "physical-game-added": "Jeu physique ajouté", + "physical-game-name": "Nom du jeu", + "physical-name-or-upc-required": "Saisissez un nom de jeu ou un UPC", + "physical-upc": "UPC / code-barres", "pick-cover": "Choisir une jaquette", "pick-track-prompt": "Choisissez une piste pour commencer la lecture", "play": "Jouer", diff --git a/frontend/src/locales/hu_HU/rom.json b/frontend/src/locales/hu_HU/rom.json index 5c174aa245..897e60ebe3 100644 --- a/frontend/src/locales/hu_HU/rom.json +++ b/frontend/src/locales/hu_HU/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Győzelmi feltétel", "add-new-note": "Új megjegyzés hozzáadása", "add-note": "Megjegyzés hozzáadása", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Hozzáadás a Gyűjteményhez", "add-to-favorites": "Hozzáadás a Kedvencekhez", "adding-to-collection": "{n} ROM-ok hozzáadva a gyűjteményhez", @@ -29,6 +31,11 @@ "artwork-empty": "Nincs elérhető grafika ehhez a játékhoz.", "artwork-open": "{name} megnyitása", "backlogged": "Függőben", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Forgatható 3D doboz: {title}", "by": "általa", "cant-copy-link": "A linket nem lehet a vágólapra másolni, kézzel kell.", @@ -255,6 +262,11 @@ "paused": "Szüneteltetve", "pdf-toggle-sidebar": "Oldalsáv ki/be", "personal": "Személyes", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Borító kiválasztása", "pick-track-prompt": "Válassz számot a lejátszás megkezdéséhez", "play": "Indítás", diff --git a/frontend/src/locales/it_IT/rom.json b/frontend/src/locales/it_IT/rom.json index 789f1840f3..a765b0592a 100644 --- a/frontend/src/locales/it_IT/rom.json +++ b/frontend/src/locales/it_IT/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Condizione di vittoria", "add-new-note": "Aggiungi Nuova Nota", "add-note": "Aggiungi Nota", + "add-physical-game": "Aggiungi gioco fisico", + "add-physical-game-desc": "Aggiungi manualmente un gioco che possiedi fisicamente. I metadati verranno associati automaticamente.", "add-to-collection": "Aggiungi alla collezione", "add-to-favorites": "Aggiungi ai preferiti", "adding-to-collection": "Aggiungendo {n} ROM alla collezione", @@ -29,6 +31,11 @@ "artwork-empty": "Nessun artwork disponibile per questo gioco.", "artwork-open": "Apri {name}", "backlogged": "In attesa", + "barcode-scan-denied": "Permesso della fotocamera negato.", + "barcode-scan-failed": "Impossibile avviare la fotocamera.", + "barcode-scan-hint": "Punta la fotocamera sul codice a barre del gioco.", + "barcode-scan-title": "Scansiona codice a barre", + "barcode-scan-unsupported": "La scansione con fotocamera non è disponibile su questo dispositivo o browser.", "box3d-alt": "Confezione 3D ruotabile di {title}", "by": "di", "cant-copy-link": "Impossibile copiare il link negli appunti, copialo manualmente", @@ -255,6 +262,11 @@ "paused": "In pausa", "pdf-toggle-sidebar": "Mostra/nascondi barra laterale", "personal": "Personale", + "physical-game-add-failed": "Impossibile aggiungere il gioco fisico", + "physical-game-added": "Gioco fisico aggiunto", + "physical-game-name": "Nome del gioco", + "physical-name-or-upc-required": "Inserisci un nome di gioco o un UPC", + "physical-upc": "UPC / codice a barre", "pick-cover": "Scegli una copertina", "pick-track-prompt": "Scegli una traccia per iniziare la riproduzione", "play": "Gioca", diff --git a/frontend/src/locales/ja_JP/rom.json b/frontend/src/locales/ja_JP/rom.json index 23f475d663..4d5b704a97 100644 --- a/frontend/src/locales/ja_JP/rom.json +++ b/frontend/src/locales/ja_JP/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "勝利条件", "add-new-note": "新しいノートを追加", "add-note": "ノートを追加", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "コレクションに追加", "add-to-favorites": "お気に入りに追加", "adding-to-collection": "コレクションにROM {n} を追加しています", @@ -29,6 +31,11 @@ "artwork-empty": "このゲームに利用できるアートワークはありません。", "artwork-open": "{name} を開く", "backlogged": "未処理", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "{title} の回転式3Dボックスアート", "by": "by", "cant-copy-link": "クリップボードへのコピー失敗 手動でコピーしてください", @@ -255,6 +262,11 @@ "paused": "一時停止中", "pdf-toggle-sidebar": "サイドバーを切り替え", "personal": "マイステータス", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "カバーを選択", "pick-track-prompt": "再生するトラックを選んでください", "play": "プレイ", diff --git a/frontend/src/locales/ko_KR/rom.json b/frontend/src/locales/ko_KR/rom.json index dea2f0da7a..d48d8c8520 100644 --- a/frontend/src/locales/ko_KR/rom.json +++ b/frontend/src/locales/ko_KR/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "승리 조건", "add-new-note": "새 노트 추가", "add-note": "노트 추가", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "모음집에 추가", "add-to-favorites": "즐겨찾기에 추가", "adding-to-collection": "{n} 개의 롬을 모음집에 추가합니다", @@ -29,6 +31,11 @@ "artwork-empty": "이 게임에 사용할 수 있는 아트워크가 없습니다.", "artwork-open": "{name} 열기", "backlogged": "나중에 플레이", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "{title}의 회전 가능한 3D 박스 아트", "by": "분류", "cant-copy-link": "링크를 클립보드에 복사할 수 없습니다. 수동으로 복사합니다", @@ -255,6 +262,11 @@ "paused": "일시정지됨", "pdf-toggle-sidebar": "사이드바 전환", "personal": "개인", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "커버 선택", "pick-track-prompt": "재생할 트랙을 선택하세요", "play": "실행", diff --git a/frontend/src/locales/pl_PL/rom.json b/frontend/src/locales/pl_PL/rom.json index 5e49308e02..5fceca2bfb 100644 --- a/frontend/src/locales/pl_PL/rom.json +++ b/frontend/src/locales/pl_PL/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Warunek zwycięstwa", "add-new-note": "Dodaj Nową Notatkę", "add-note": "Dodaj Notatkę", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Dodaj do kolekcji", "add-to-favorites": "Dodaj do ulubionych", "adding-to-collection": "Dodawanie {n} ROM-ów do kolekcji", @@ -29,6 +31,11 @@ "artwork-empty": "Brak dostępnej grafiki dla tej gry.", "artwork-open": "Otwórz {name}", "backlogged": "Zaległe", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Obracane pudełko 3D dla {title}", "by": "przez", "cant-copy-link": "Nie można skopiować linku do schowka, skopiuj go ręcznie", @@ -255,6 +262,11 @@ "paused": "Wstrzymane", "pdf-toggle-sidebar": "Przełącz pasek boczny", "personal": "Osobiste", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Wybierz okładkę", "pick-track-prompt": "Wybierz utwór, aby rozpocząć odtwarzanie", "play": "Graj", diff --git a/frontend/src/locales/pt_BR/rom.json b/frontend/src/locales/pt_BR/rom.json index e45c842f52..2509f3920f 100644 --- a/frontend/src/locales/pt_BR/rom.json +++ b/frontend/src/locales/pt_BR/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Condição de vitória", "add-new-note": "Adicionar Nova Nota", "add-note": "Adicionar Nota", + "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.", "add-to-collection": "Adicionar à coleção", "add-to-favorites": "Adicionar aos favoritos", "adding-to-collection": "Adicionando {n} ROMs à coleção", @@ -29,6 +31,11 @@ "artwork-empty": "Nenhuma arte disponível para este jogo.", "artwork-open": "Abrir {name}", "backlogged": "Em espera", + "barcode-scan-denied": "A permissão da câmera foi negada.", + "barcode-scan-failed": "Não foi possível iniciar a câmera.", + "barcode-scan-hint": "Aponte a câmera para o código de barras do jogo.", + "barcode-scan-title": "Escanear código de barras", + "barcode-scan-unsupported": "A leitura por câmera não está disponível neste dispositivo ou navegador.", "box3d-alt": "Caixa 3D giratória de {title}", "by": "por", "cant-copy-link": "Não é possível copiar o link para a área de transferência, copie manualmente", @@ -255,6 +262,11 @@ "paused": "Pausado", "pdf-toggle-sidebar": "Alternar barra lateral", "personal": "Pessoal", + "physical-game-add-failed": "Falha ao adicionar jogo físico", + "physical-game-added": "Jogo físico adicionado", + "physical-game-name": "Nome do jogo", + "physical-name-or-upc-required": "Informe um nome de jogo ou um UPC", + "physical-upc": "UPC / código de barras", "pick-cover": "Escolher uma capa", "pick-track-prompt": "Escolha uma faixa para começar a tocar", "play": "Jogar", diff --git a/frontend/src/locales/ro_RO/rom.json b/frontend/src/locales/ro_RO/rom.json index 9b651903ae..3328956bcf 100644 --- a/frontend/src/locales/ro_RO/rom.json +++ b/frontend/src/locales/ro_RO/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Condiție de câștig", "add-new-note": "Adaugă Notație Nouă", "add-note": "Adaugă Notație", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Adaugă la colecție", "add-to-favorites": "Adaugă la favorite", "adding-to-collection": "Adăugare a {n} ROM-urilor la colecție", @@ -29,6 +31,11 @@ "artwork-empty": "Nu există grafică disponibilă pentru acest joc.", "artwork-open": "Deschide {name}", "backlogged": "În așteptare", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Cutie 3D rotativă pentru {title}", "by": "de", "cant-copy-link": "Nu s-a putut copia linkul în clipboard, copiază-l manual", @@ -255,6 +262,11 @@ "paused": "În pauză", "pdf-toggle-sidebar": "Comută bara laterală", "personal": "Personal", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Alege o copertă", "pick-track-prompt": "Alege o piesă pentru a începe redarea", "play": "Joacă", diff --git a/frontend/src/locales/ru_RU/rom.json b/frontend/src/locales/ru_RU/rom.json index f82d9e5ab7..72adb833e5 100644 --- a/frontend/src/locales/ru_RU/rom.json +++ b/frontend/src/locales/ru_RU/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Условие победы", "add-new-note": "Добавить Новую Заметку", "add-note": "Добавить Заметку", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Добавить в коллекцию", "add-to-favorites": "Добавить в избранное", "adding-to-collection": "Добавление {n} ромов в коллекцию", @@ -29,6 +31,11 @@ "artwork-empty": "Для этой игры нет доступной графики.", "artwork-open": "Открыть {name}", "backlogged": "Отложено", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "Вращающаяся 3D-коробка для {title}", "by": "от", "cant-copy-link": "Не удается скопировать ссылку в буфер обмена, скопируйте ее вручную", @@ -255,6 +262,11 @@ "paused": "Пауза", "pdf-toggle-sidebar": "Переключить боковую панель", "personal": "Личное", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Выбрать обложку", "pick-track-prompt": "Выберите трек, чтобы начать воспроизведение", "play": "Играть", diff --git a/frontend/src/locales/tr_TR/rom.json b/frontend/src/locales/tr_TR/rom.json index 13551a636e..5957493ba5 100644 --- a/frontend/src/locales/tr_TR/rom.json +++ b/frontend/src/locales/tr_TR/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "Kazanma koşulu", "add-new-note": "Yeni not ekle", "add-note": "Not ekle", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "Koleksiyona ekle", "add-to-favorites": "Favorilere ekle", "adding-to-collection": "{n} ROM koleksiyona ekleniyor", @@ -29,6 +31,11 @@ "artwork-empty": "Bu oyun için kullanılabilir görsel yok.", "artwork-open": "{name} öğesini aç", "backlogged": "Beklemede", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "{title} için döndürülebilir 3D kutu görseli", "by": "tarafından", "cant-copy-link": "Bağlantı panoya kopyalanamıyor, manuel olarak kopyalayın", @@ -255,6 +262,11 @@ "paused": "Duraklatıldı", "pdf-toggle-sidebar": "Kenar çubuğunu aç/kapat", "personal": "Kişisel", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "Kapak seç", "pick-track-prompt": "Oynatmaya başlamak için bir parça seçin", "play": "Oyna", diff --git a/frontend/src/locales/zh_CN/rom.json b/frontend/src/locales/zh_CN/rom.json index 451acc2a33..a6b26b00e5 100644 --- a/frontend/src/locales/zh_CN/rom.json +++ b/frontend/src/locales/zh_CN/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "胜利条件", "add-new-note": "添加新笔记", "add-note": "添加笔记", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "添加至收藏", "add-to-favorites": "添加至喜好", "adding-to-collection": "添加 {n} ROMs 至收藏", @@ -29,6 +31,11 @@ "artwork-empty": "此游戏没有可用的美术图。", "artwork-open": "打开 {name}", "backlogged": "待办", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "{title} 的可旋转 3D 包装盒", "by": "根据", "cant-copy-link": "无法将链接复制到剪贴板,请手动复制", @@ -255,6 +262,11 @@ "paused": "已暂停", "pdf-toggle-sidebar": "切换侧边栏", "personal": "私人", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "选择封面", "pick-track-prompt": "选择一首曲目开始播放", "play": "开始游玩", diff --git a/frontend/src/locales/zh_TW/rom.json b/frontend/src/locales/zh_TW/rom.json index 59a6a5cefb..c9d12e5cd1 100644 --- a/frontend/src/locales/zh_TW/rom.json +++ b/frontend/src/locales/zh_TW/rom.json @@ -16,6 +16,8 @@ "achievements-type-win-condition": "勝利條件", "add-new-note": "新增新筆記", "add-note": "新增筆記", + "add-physical-game": "Add physical game", + "add-physical-game-desc": "Manually add a game you own physically. It will be matched to metadata automatically.", "add-to-collection": "加入收藏庫", "add-to-favorites": "加入喜好", "adding-to-collection": "加入 {n} 個 ROM 至收藏庫", @@ -29,6 +31,11 @@ "artwork-empty": "此遊戲沒有可用的美術圖。", "artwork-open": "開啟 {name}", "backlogged": "待遊玩", + "barcode-scan-denied": "Camera permission was denied.", + "barcode-scan-failed": "Could not start the camera.", + "barcode-scan-hint": "Point the camera at the game's barcode.", + "barcode-scan-title": "Scan barcode", + "barcode-scan-unsupported": "Camera scanning isn't available on this device or browser.", "box3d-alt": "{title} 的可旋轉 3D 包裝盒", "by": "依據", "cant-copy-link": "無法複製下載鏈接到剪貼簿,請手動複製", @@ -255,6 +262,11 @@ "paused": "已暫停", "pdf-toggle-sidebar": "切換側邊欄", "personal": "私人", + "physical-game-add-failed": "Failed to add physical game", + "physical-game-added": "Physical game added", + "physical-game-name": "Game name", + "physical-name-or-upc-required": "Enter a game name or a UPC", + "physical-upc": "UPC / barcode", "pick-cover": "選擇封面", "pick-track-prompt": "挑選一首音軌開始播放", "play": "遊玩", diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 33e1dba52f..2ee6940fb3 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -443,6 +443,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"); @@ -926,6 +947,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" />