diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..880feeeef
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,50 @@
+# Local secrets / env
+.env
+**/.env
+
+# Git / editor / OS
+.git
+.gitignore
+.gitattributes
+**/.DS_Store
+
+# IDE / dev containers
+.vscode/
+.devcontainer/
+
+# Python virtualenvs & caches
+.venv/
+**/.venv/
+**/venv/
+**/__pycache__/
+**/.mypy_cache/
+**/.pytest_cache/
+**/.ruff_cache/
+
+# Node caches
+**/node_modules/
+**/.npm/
+**/.vite/
+
+# CI / metadata not needed in image
+.github/
+
+# Mock & test data (requested)
+romm_mock/
+**/romm_mock/
+backend/tests/
+**/romm_test/
+
+# Local-only docker compose files/examples (not needed in image)
+docker-compose.yml
+examples/
+
+# Local tools / docs not required for runtime
+**/*.md
+CODE_OF_CONDUCT.md
+CONTRIBUTING.md
+DEVELOPER_SETUP.md
+SECURITY.md
+
+# Note: we intentionally do NOT ignore `docker/` because `docker/Dockerfile`
+# copies init scripts and nginx/gunicorn configs from that folder.
diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py
index 81bf69770..b299a500d 100644
--- a/backend/endpoints/sockets/scan.py
+++ b/backend/endpoints/sockets/scan.py
@@ -232,6 +232,7 @@ async def _identify_rom(
scan_type: ScanType,
roms_ids: list[int],
metadata_sources: list[str],
+ launchbox_remote_enabled: bool,
socket_manager: socketio.AsyncRedisManager,
scan_stats: ScanStats,
calculate_hashes: bool = True,
@@ -319,6 +320,7 @@ async def _identify_rom(
fs_rom=fs_rom,
metadata_sources=metadata_sources,
newly_added=newly_added,
+ launchbox_remote_enabled=launchbox_remote_enabled,
socket_manager=socket_manager,
)
@@ -457,6 +459,7 @@ async def _identify_platform(
fs_platforms: list[str],
roms_ids: list[int],
metadata_sources: list[str],
+ launchbox_remote_enabled: bool,
socket_manager: socketio.AsyncRedisManager,
scan_stats: ScanStats,
calculate_hashes: bool = True,
@@ -547,6 +550,7 @@ async def scan_rom_with_semaphore(fs_rom: FSRom, rom: Rom | None) -> None:
scan_type=scan_type,
roms_ids=roms_ids,
metadata_sources=metadata_sources,
+ launchbox_remote_enabled=launchbox_remote_enabled,
socket_manager=socket_manager,
scan_stats=scan_stats,
calculate_hashes=calculate_hashes,
@@ -597,6 +601,7 @@ async def scan_platforms(
metadata_sources: list[str],
scan_type: ScanType = ScanType.QUICK,
roms_ids: list[int] | None = None,
+ launchbox_remote_enabled: bool = True,
) -> ScanStats:
"""Scan all the listed platforms and fetch metadata from different sources
@@ -671,6 +676,7 @@ async def stop_scan():
fs_platforms=fs_platforms,
roms_ids=roms_ids,
metadata_sources=metadata_sources,
+ launchbox_remote_enabled=launchbox_remote_enabled,
socket_manager=socket_manager,
scan_stats=scan_stats,
calculate_hashes=calculate_hashes,
@@ -710,6 +716,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
scan_type = ScanType[options.get("type", "quick").upper()]
roms_ids = options.get("roms_ids", [])
metadata_sources = options.get("apis", [])
+ launchbox_remote_enabled = bool(options.get("launchbox_remote_enabled", True))
if DEV_MODE:
return await scan_platforms(
@@ -717,6 +724,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
metadata_sources=metadata_sources,
scan_type=scan_type,
roms_ids=roms_ids,
+ launchbox_remote_enabled=launchbox_remote_enabled,
)
return high_prio_queue.enqueue(
@@ -725,6 +733,7 @@ async def scan_handler(_sid: str, options: dict[str, Any]):
metadata_sources=metadata_sources,
scan_type=scan_type,
roms_ids=roms_ids,
+ launchbox_remote_enabled=launchbox_remote_enabled,
job_timeout=SCAN_TIMEOUT, # Timeout (default of 4 hours)
result_ttl=TASK_RESULT_TTL,
meta={
diff --git a/backend/handler/metadata/launchbox_handler.py b/backend/handler/metadata/launchbox_handler.py
index dc17b9517..494545045 100644
--- a/backend/handler/metadata/launchbox_handler.py
+++ b/backend/handler/metadata/launchbox_handler.py
@@ -1,14 +1,17 @@
import json
import re
+from dataclasses import dataclass
from datetime import datetime
+from pathlib import Path, PureWindowsPath
from typing import Final, NotRequired, TypedDict
import pydash
+from defusedxml import ElementTree as ET
-from config import LAUNCHBOX_API_ENABLED
+from config import LAUNCHBOX_API_ENABLED, ROMM_BASE_PATH
from handler.redis_handler import async_cache
from logger.logger import log
-from utils.database import safe_str_to_bool
+from utils.database import safe_int, safe_str_to_bool
from .base_handler import BaseRom, MetadataHandler
from .base_handler import UniversalPlatformSlug as UPS
@@ -22,24 +25,33 @@
LAUNCHBOX_METADATA_IMAGE_KEY: Final[str] = "romm:launchbox_metadata_image"
LAUNCHBOX_MAME_KEY: Final[str] = "romm:launchbox_mame"
LAUNCHBOX_FILES_KEY: Final[str] = "romm:launchbox_files"
+LAUNCHBOX_XML_INDEX_KEY: Final[str] = "romm:launchbox_xml_index"
+
+
+LAUNCHBOX_LOCAL_DIR: Final[Path] = Path(ROMM_BASE_PATH) / "launchbox"
+LAUNCHBOX_PLATFORMS_DIR: Final[Path] = LAUNCHBOX_LOCAL_DIR / "Data" / "Platforms"
+LAUNCHBOX_IMAGES_DIR: Final[Path] = LAUNCHBOX_LOCAL_DIR / "Images"
+LAUNCHBOX_MANUALS_DIR: Final[Path] = LAUNCHBOX_LOCAL_DIR / "Manuals"
+LAUNCHBOX_VIDEOS_DIR: Final[Path] = LAUNCHBOX_LOCAL_DIR / "Videos"
# Regex to detect LaunchBox ID tags in filenames like (launchbox-12345)
LAUNCHBOX_TAG_REGEX = re.compile(r"\(launchbox-(\d+)\)", re.IGNORECASE)
DASH_COLON_REGEX = re.compile(r"\s?-\s")
-class LaunchboxPlatform(TypedDict):
- slug: str
- launchbox_id: int | None
- name: NotRequired[str]
-
-
class LaunchboxImage(TypedDict):
url: str
type: NotRequired[str]
region: NotRequired[str]
+class LaunchboxPlatform(TypedDict):
+ slug: str
+ launchbox_id: int | None
+ name: NotRequired[str]
+ images: NotRequired[list[LaunchboxImage]]
+
+
class LaunchboxMetadata(TypedDict):
first_release_date: int | None
max_players: NotRequired[int]
@@ -60,11 +72,84 @@ class LaunchboxRom(BaseRom):
launchbox_metadata: NotRequired[LaunchboxMetadata]
-def extract_video_id_from_youtube_url(url: str | None) -> str:
- """
- Extracts the video ID from a YouTube URL.
- Returns None if the URL is not a valid YouTube URL.
- """
+def _sanitize_filename(stem: str) -> str:
+ s = (stem or "").strip()
+ s = s.replace("’", "'")
+ s = re.sub(r"[:']", "_", s)
+ s = re.sub(r"[\\/|<>\"?*]", "_", s)
+ s = re.sub(r"\s+", " ", s)
+ s = re.sub(r"_+", "_", s)
+ return s.strip(" .")
+
+
+def _file_uri_for_local_path(path: Path) -> str | None:
+ try:
+ _ = path.resolve().relative_to(LAUNCHBOX_LOCAL_DIR.resolve())
+ except ValueError:
+ return None
+ return f"file://{str(path)}"
+
+
+def _coalesce(*values: object | None) -> str | None:
+ for v in values:
+ if v is None:
+ continue
+ s = str(v).strip()
+ if s:
+ return s
+ return None
+
+
+def _parse_list(value: str | None) -> list[str]:
+ if not value:
+ return []
+ parts = re.split(r"[;,]", value)
+ return [p.strip() for p in parts if p and p.strip()]
+
+
+def _dedupe_words(values):
+ seen = {}
+ out: list[str] = []
+
+ for v in pydash.compact(pydash.map_(values, str.strip)):
+ key = v.lower()
+ if key not in seen:
+ seen[key] = len(out)
+ out.append(v)
+ else:
+ idx = seen[key]
+ if out[idx].islower() and not v.islower():
+ out[idx] = v
+ return out
+
+
+def _parse_release_date(value: str | None) -> int | None:
+ if not value:
+ return None
+
+ try:
+ iso = value.replace("Z", "+00:00")
+ return int(datetime.fromisoformat(iso).timestamp())
+ except ValueError:
+ pass
+
+ for fmt in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d"):
+ try:
+ return int(datetime.strptime(value, fmt).timestamp())
+ except ValueError:
+ continue
+
+ return None
+
+
+def _parse_playmode(play_mode: str | None) -> bool:
+ if not play_mode:
+ return False
+ pm = play_mode.lower()
+ return bool(re.search(r"\b(cooperative|coop|co-op)\b", pm))
+
+
+def _parse_videourl(url: str | None) -> str:
if not url:
return ""
@@ -76,163 +161,751 @@ def extract_video_id_from_youtube_url(url: str | None) -> str:
return ""
-def extract_metadata_from_launchbox_rom(
- index_entry: dict, game_images: list[dict] | None
+def build_launchbox_metadata(
+ *,
+ local: dict[str, str] | None = None,
+ remote: dict | None = None,
+ images: list[LaunchboxImage],
+ **kwargs: object,
) -> LaunchboxMetadata:
+ if local is None and isinstance(kwargs.get("local_entry"), dict):
+ local = kwargs["local_entry"] # type: ignore[assignment]
+
+ local_release_date = local.get("ReleaseDate") if local else None
+ remote_release_date = remote.get("ReleaseDate") if remote else None
+ release_date_raw = _coalesce(local_release_date, remote_release_date)
+ first_release_date = _parse_release_date(release_date_raw)
+
+ max_players_raw = _coalesce(
+ local.get("MaxPlayers") if local else None,
+ remote.get("MaxPlayers") if remote else None,
+ )
+ try:
+ max_players = int(max_players_raw or 0)
+ except (TypeError, ValueError):
+ max_players = 0
+
+ release_type = (
+ _coalesce(
+ local.get("ReleaseType") if local else None,
+ remote.get("ReleaseType") if remote else None,
+ )
+ or ""
+ )
+
+ if local and _coalesce(local.get("PlayMode")):
+ cooperative = _parse_playmode(local.get("PlayMode"))
+ else:
+ cooperative = safe_str_to_bool(
+ (remote.get("Cooperative") if remote else None) or "false"
+ )
+
+ video_url = _coalesce(
+ (local.get("VideoUrl") if local else None),
+ (remote.get("VideoURL") if remote else None),
+ )
+
+ community_rating_raw = _coalesce(
+ local.get("CommunityStarRating") if local else None,
+ remote.get("CommunityRating") if remote else None,
+ )
+ try:
+ community_rating = float(community_rating_raw or 0.0)
+ except (TypeError, ValueError):
+ community_rating = 0.0
+
+ community_rating_count_raw = _coalesce(
+ local.get("CommunityStarRatingTotalVotes") if local else None,
+ remote.get("CommunityRatingCount") if remote else None,
+ )
try:
- first_release_date = int(
- datetime.strptime(
- index_entry["ReleaseDate"], "%Y-%m-%dT%H:%M:%S%z"
- ).timestamp()
+ community_rating_count = int(community_rating_count_raw or 0)
+ except (TypeError, ValueError):
+ community_rating_count = 0
+
+ wikipedia_url = (
+ _coalesce(
+ local.get("WikipediaURL") if local else None,
+ remote.get("WikipediaURL") if remote else None,
)
- except (ValueError, KeyError, IndexError):
- first_release_date = None
+ or ""
+ )
+
+ esrb_raw = _coalesce(
+ (local.get("Rating") if local else None),
+ (remote.get("ESRB") if remote else None),
+ )
+ esrb = (esrb_raw or "").split(" - ")[0].strip()
+
+ genres_raw = _coalesce(
+ local.get("Genre") if local else None,
+ remote.get("Genres") if remote else None,
+ )
+ genres = _parse_list(genres_raw)
+
+ publisher = _coalesce(
+ local.get("Publisher") if local else None,
+ remote.get("Publisher") if remote else None,
+ )
+ developer = _coalesce(
+ local.get("Developer") if local else None,
+ remote.get("Developer") if remote else None,
+ )
+ companies = _dedupe_words([publisher, developer])
return LaunchboxMetadata(
{
"first_release_date": first_release_date,
- "max_players": int(index_entry.get("MaxPlayers") or 0),
- "release_type": index_entry.get("ReleaseType", ""),
- "cooperative": safe_str_to_bool(index_entry.get("Cooperative") or "false"),
- "youtube_video_id": extract_video_id_from_youtube_url(
- index_entry.get("VideoURL")
- ),
- "community_rating": float(index_entry.get("CommunityRating") or 0.0),
- "community_rating_count": int(index_entry.get("CommunityRatingCount") or 0),
- "wikipedia_url": index_entry.get("WikipediaURL", ""),
- "esrb": index_entry.get("ESRB", "").split(" - ")[0].strip(),
- "genres": (
- index_entry["Genres"].split() if index_entry.get("Genres", None) else []
- ),
- "companies": pydash.compact(
- [
- index_entry.get("Publisher", None),
- index_entry.get("Developer", None),
+ "max_players": max_players,
+ "release_type": release_type,
+ "cooperative": cooperative,
+ "youtube_video_id": _parse_videourl(video_url),
+ "community_rating": community_rating,
+ "community_rating_count": community_rating_count,
+ "wikipedia_url": wikipedia_url,
+ "esrb": esrb,
+ "genres": genres,
+ "companies": companies,
+ "images": images,
+ }
+ )
+
+
+class _LocalMediaContext(TypedDict):
+ base: Path
+ stems: list[str]
+ preferred_regions: list[str]
+
+
+@dataclass(frozen=True, slots=True)
+class _MediaRequest:
+ platform_name: str | None
+ fs_name: str
+ title: str
+ region_hint: str | None
+ remote_images: list[dict] | None
+ remote_enabled: bool
+
+
+def _local_media_req(
+ *,
+ platform_name: str | None,
+ fs_name: str,
+ local: dict[str, str] | None,
+ remote: dict | None,
+ remote_images: list[dict] | None,
+ remote_enabled: bool,
+) -> _MediaRequest:
+ title = ((local or {}).get("Title") or "").strip()
+ region_hint = ((local or {}).get("Region") or "").strip() or None
+ return _MediaRequest(
+ platform_name,
+ fs_name,
+ title,
+ region_hint,
+ remote_images,
+ remote_enabled,
+ )
+
+
+def _remote_media_req(
+ *,
+ remote: dict | None,
+ remote_images: list[dict] | None,
+ remote_enabled: bool,
+) -> _MediaRequest:
+ title = ((remote or {}).get("Name") or "").strip()
+ return _MediaRequest(
+ None,
+ "",
+ title,
+ None,
+ remote_images,
+ remote_enabled,
+ )
+
+
+def _build_local_media_context(
+ req: _MediaRequest,
+ base_dir: Path,
+ *,
+ include_region_hints: bool = True,
+) -> _LocalMediaContext | None:
+ if not req.platform_name:
+ return None
+
+ if not base_dir.exists():
+ return None
+ base = (base_dir / req.platform_name).resolve()
+ if not base.is_dir():
+ return None
+
+ stems: list[str] = []
+ if req.fs_name:
+ stems.append(Path(req.fs_name).stem)
+ if req.title:
+ stems.append(req.title)
+
+ out: list[str] = []
+ for s in stems:
+ clean = _sanitize_filename(s)
+ if clean and clean not in out:
+ out.append(clean)
+ stems = out
+ if not stems:
+ return None
+
+ preferred_regions: list[str] = []
+ if include_region_hints and req.region_hint:
+ region_hint = req.region_hint.strip()
+ if region_hint:
+ preferred_regions.append(region_hint)
+ if "," in region_hint:
+ preferred_regions.extend(
+ [r.strip() for r in region_hint.split(",") if r.strip()]
+ )
+
+ return {
+ "base": base,
+ "stems": stems,
+ "preferred_regions": preferred_regions,
+ }
+
+
+def _find_local_media_candidates(
+ ctx: _LocalMediaContext,
+ category_name: str,
+ *,
+ exts: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp"),
+ indexed_preference: tuple[int, ...] | None = None,
+ indexed_only_preferred: bool = False,
+) -> tuple[list[Path], str]:
+ category_dir = ctx["base"] / category_name
+ if not category_dir.is_dir():
+ return [], ""
+
+ search_dirs: list[Path] = []
+
+ for region in ctx["preferred_regions"]:
+ p = category_dir / region
+ if p.exists() and p.is_dir() and p not in search_dirs:
+ search_dirs.append(p)
+
+ for p in sorted(
+ [p for p in category_dir.iterdir() if p.is_dir()],
+ key=lambda p: p.name.lower(),
+ ):
+ if p not in search_dirs:
+ search_dirs.append(p)
+
+ if category_dir not in search_dirs:
+ search_dirs.append(category_dir)
+
+ if not search_dirs:
+ return [], ""
+ allowed_exts = {e.lower() for e in exts}
+
+ def _candidates(d: Path, stem: str) -> list[Path]:
+ if not stem:
+ return []
+
+ plain: Path | None = None
+ indexed: list[tuple[int, Path]] = []
+ prefix = f"{stem}-"
+
+ for p in d.iterdir():
+ if not (p.is_file() and p.suffix.lower() in allowed_exts):
+ continue
+
+ stem_name = p.stem
+ if stem_name == stem:
+ plain = p
+ continue
+
+ if stem_name.startswith(prefix):
+ suffix = stem_name[len(prefix) :]
+ if suffix.isdigit():
+ indexed.append((int(suffix), p))
+
+ if indexed:
+ indexed.sort(key=lambda t: (t[0], t[1].name.lower()))
+ if indexed_preference:
+ indexed_by_num: dict[int, Path] = {n: p for n, p in indexed}
+ preferred_hits = [
+ indexed_by_num[n] for n in indexed_preference if n in indexed_by_num
]
- ),
- "images": [
- LaunchboxImage(
- {
- "url": f"https://images.launchbox-app.com/{image['FileName']}",
- "type": image.get("Type", ""),
- "region": image.get("Region", ""),
- }
+ if preferred_hits:
+ return preferred_hits
+ if indexed_only_preferred:
+ return [plain] if plain else []
+
+ return [p for _, p in indexed]
+
+ return [plain] if plain else []
+
+ for d in search_dirs:
+ region = "" if d == category_dir else d.name
+ for stem in ctx["stems"]:
+ candidate_files = _candidates(d, stem)
+ if candidate_files:
+ return candidate_files, region
+
+ return [], ""
+
+
+def _get_cover(req: _MediaRequest) -> str | None:
+ cover: str | None = None
+
+ cover_priority_types = (
+ "Box - Front",
+ "Box - Front - Reconstructed",
+ "Fanart - Box - Front",
+ "Box - 3D",
+ "Amazon Poster",
+ "Epic Games Poster",
+ "GOG Poster",
+ "Steam Poster",
+ )
+
+ # Remote media fallback (only if allowed)
+ if req.remote_enabled and req.remote_images:
+ best_cover: dict | None = None
+ for image_type in cover_priority_types:
+ for image in req.remote_images:
+ if image.get("Type") == image_type and image.get("FileName"):
+ best_cover = image
+ break
+ if best_cover is not None:
+ break
+
+ if best_cover and best_cover.get("FileName"):
+ cover = f"https://images.launchbox-app.com/{best_cover.get('FileName')}"
+
+ ctx = _build_local_media_context(
+ req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
+ )
+ if ctx is not None:
+ for category in cover_priority_types:
+ candidate_files, _region = _find_local_media_candidates(
+ ctx,
+ category,
+ indexed_preference=(1,),
+ indexed_only_preferred=True,
+ )
+ if not candidate_files:
+ continue
+
+ cover_path = candidate_files[0]
+ url = _file_uri_for_local_path(cover_path)
+ if url:
+ cover = url
+ break
+
+ return cover
+
+
+def _get_screenshots(req: _MediaRequest) -> list[str]:
+ screenshots: list[str] = []
+
+ # Remote media fallback (only if allowed)
+ if req.remote_enabled and req.remote_images:
+ screenshots = [
+ f"https://images.launchbox-app.com/{image.get('FileName')}"
+ for image in req.remote_images
+ if image.get("FileName") and "Screenshot" in image.get("Type", "")
+ ]
+
+ ctx = _build_local_media_context(
+ req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
+ )
+ if ctx is not None:
+ local_screens: list[str] = []
+ seen: set[str] = set()
+ for dir_name in (
+ "Amazon Screenshot",
+ "Epic Games Screenshot",
+ "GOG Screenshot",
+ "Origin Screenshot",
+ "Screenshot - Game Title",
+ "Screenshot - Game Select",
+ "Screenshot - Gameplay",
+ "Screenshot - High Scores",
+ "Screenshot - Game Over",
+ "Steam Screenshot",
+ ):
+ candidate_files, _region = _find_local_media_candidates(ctx, dir_name)
+ for p in candidate_files:
+ url = _file_uri_for_local_path(p)
+ if url and url not in seen:
+ seen.add(url)
+ local_screens.append(url)
+
+ if local_screens:
+ screenshots = local_screens
+
+ return screenshots
+
+
+def _get_manuals(req: _MediaRequest) -> str | None:
+ manual: str | None = None
+
+ ctx = _build_local_media_context(
+ req, LAUNCHBOX_MANUALS_DIR, include_region_hints=False
+ )
+ if ctx is None:
+ return manual
+
+ pdfs: list[Path] = [
+ p for p in ctx["base"].iterdir() if p.is_file() and p.suffix.lower() == ".pdf"
+ ]
+ if not pdfs:
+ return manual
+
+ def _key(p: Path) -> str:
+ return _sanitize_filename(p.stem).lower()
+
+ pdfs_sorted = sorted(pdfs, key=lambda p: (len(p.name), p.name.lower()))
+
+ stems_lower = [s.lower() for s in ctx["stems"]]
+
+ for stem in stems_lower:
+ for p in pdfs_sorted:
+ if _key(p) == stem:
+ url = _file_uri_for_local_path(p)
+ if url:
+ return url
+
+ for stem in stems_lower:
+ for p in pdfs_sorted:
+ if _key(p).startswith(stem):
+ url = _file_uri_for_local_path(p)
+ if url:
+ return url
+
+ return manual
+
+
+def _get_images(req: _MediaRequest) -> list[LaunchboxImage]:
+ images: list[LaunchboxImage] = []
+
+ # Remote media fallback (only if allowed)
+ if req.remote_enabled and req.remote_images:
+ images = [
+ LaunchboxImage(
+ {
+ "url": f"https://images.launchbox-app.com/{image['FileName']}",
+ "type": image.get("Type", ""),
+ "region": image.get("Region", ""),
+ }
+ )
+ for image in req.remote_images
+ if image.get("FileName")
+ ]
+
+ ctx = _build_local_media_context(
+ req, LAUNCHBOX_IMAGES_DIR, include_region_hints=True
+ )
+ if ctx is not None:
+ local_images: list[LaunchboxImage] = []
+ for dir_name in (
+ "Advertisement Flyer - Back",
+ "Advertisement Flyer - Front",
+ "Box - Back",
+ "Box - Back - Reconstructed",
+ "Box - Full",
+ "Box - Spine",
+ "Cart - Front",
+ "Cart - 3D",
+ "Clear Logo",
+ "Fanart - Box - Back",
+ "Fanart - Background", # Later separate in new category for rom header
+ "Amazon Background", # Later separate in new category for rom header
+ "Epic Games Background", # Later separate in new category for rom header
+ "Origin Background", # Later separate in new category for rom header
+ "Uplay Background", # Later separate in new category for rom header
+ ):
+ candidate_files, region = _find_local_media_candidates(ctx, dir_name)
+ for p in candidate_files:
+ url = _file_uri_for_local_path(p)
+ if not url:
+ continue
+ local_images.append(
+ LaunchboxImage(
+ {
+ "url": url,
+ "type": dir_name,
+ "region": region,
+ }
+ )
)
- for image in game_images or []
- ],
- }
+
+ if local_images:
+ images = local_images
+
+ seen_images: dict[str, LaunchboxImage] = {}
+ for img in images:
+ if img["url"] not in seen_images:
+ seen_images[img["url"]] = img
+
+ return list(seen_images.values())
+
+
+def build_rom(
+ *,
+ local: dict[str, str] | None,
+ remote: dict | None,
+ launchbox_id: int | None,
+ media_req: _MediaRequest | None = None,
+) -> LaunchboxRom:
+ images: list[LaunchboxImage] = (
+ _get_images(media_req) if media_req is not None else []
+ )
+
+ url_cover: str | None = None
+ url_screenshots: list[str] = []
+ url_manual: str | None = None
+ if media_req is not None:
+ url_cover = _get_cover(media_req)
+ url_screenshots = _get_screenshots(media_req)
+ url_manual = _get_manuals(media_req)
+ url_screenshots = url_screenshots or []
+
+ name = (
+ _coalesce(
+ (local.get("Title") if local else None),
+ (remote.get("Name") if remote else None),
+ )
+ or ""
+ ).strip()
+
+ summary = (
+ _coalesce(
+ (local.get("Notes") if local else None),
+ (remote.get("Overview") if remote else None),
+ )
+ or ""
+ ).strip()
+
+ return LaunchboxRom(
+ launchbox_id=launchbox_id,
+ name=name,
+ summary=summary,
+ url_cover=url_cover or "",
+ url_screenshots=url_screenshots,
+ url_manual=url_manual or "",
+ launchbox_metadata=build_launchbox_metadata(
+ local=local,
+ remote=remote,
+ images=images,
+ ),
)
class LaunchboxHandler(MetadataHandler):
@classmethod
def is_enabled(cls) -> bool:
- return LAUNCHBOX_API_ENABLED
+ return LAUNCHBOX_API_ENABLED or LAUNCHBOX_PLATFORMS_DIR.exists()
async def heartbeat(self) -> bool:
return self.is_enabled()
- @staticmethod
- def extract_launchbox_id_from_filename(fs_name: str) -> int | None:
- """Extract LaunchBox ID from filename tag like (launchbox-12345)."""
- match = LAUNCHBOX_TAG_REGEX.search(fs_name)
- if match:
- return int(match.group(1))
- return None
-
- async def _get_rom_from_metadata(
- self, file_name: str, platform_slug: str
- ) -> dict | None:
- if not (await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)):
- log.error("Could not find the Launchbox Metadata.xml file in cache")
+ async def _fetch_remote_images(
+ self,
+ *,
+ remote: dict | None = None,
+ database_id: str | int | None = None,
+ remote_enabled: bool = True,
+ ) -> list[dict] | None:
+ if not remote_enabled:
return None
- lb_platform = self.get_platform(platform_slug)
- platform_name = lb_platform.get("name", None)
- if not platform_name:
+ resolved_id = database_id
+ if resolved_id is None and remote is not None:
+ resolved_id = remote.get("DatabaseID")
+
+ if not resolved_id:
return None
- metadata_name_index_entry = await async_cache.hget(
- LAUNCHBOX_METADATA_NAME_KEY, f"{file_name}:{platform_name}"
+ metadata_image_index_entry = await async_cache.hget(
+ LAUNCHBOX_METADATA_IMAGE_KEY, str(resolved_id)
)
- if metadata_name_index_entry:
- return json.loads(metadata_name_index_entry)
+ if not metadata_image_index_entry:
+ return None
+
+ return json.loads(metadata_image_index_entry)
+
+ def get_platform(self, slug: str) -> LaunchboxPlatform:
+ slug_clean = slug.strip().lower()
+ resolved: UPS | None = None
+ for candidate in (
+ slug_clean,
+ slug_clean.replace("-", ""),
+ slug_clean.replace("_", ""),
+ slug_clean.replace("-", "").replace("_", ""),
+ ):
+ if not candidate:
+ continue
+ try:
+ ups = UPS(candidate)
+ except ValueError:
+ continue
+ if ups in LAUNCHBOX_PLATFORM_LIST:
+ resolved = ups
+ break
+
+ if resolved is None:
+ return LaunchboxPlatform(slug=slug_clean, launchbox_id=None)
+
+ platform = LAUNCHBOX_PLATFORM_LIST[resolved]
- metadata_alternate_name_index_entry = await async_cache.hget(
- LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY, file_name
+ return LaunchboxPlatform(
+ slug=slug_clean,
+ launchbox_id=platform["id"],
+ name=platform["name"],
)
- if not metadata_alternate_name_index_entry:
+ async def _get_local_rom(
+ self, fs_name: str, platform_slug: str
+ ) -> dict[str, str] | None:
+ if not LAUNCHBOX_PLATFORMS_DIR.exists():
return None
- metadata_alternate_name_index_entry = json.loads(
- metadata_alternate_name_index_entry
- )
- database_id = metadata_alternate_name_index_entry["DatabaseID"]
- metadata_database_index_entry = await async_cache.hget(
- LAUNCHBOX_METADATA_DATABASE_ID_KEY, database_id
+ platform_name = self.get_platform(platform_slug).get("name")
+ xml_path = (
+ LAUNCHBOX_PLATFORMS_DIR / f"{platform_name}.xml" if platform_name else None
)
-
- if not metadata_database_index_entry:
+ if not xml_path or not xml_path.exists():
return None
- return json.loads(metadata_database_index_entry)
+ try:
+ xml_path_str = str(xml_path.resolve())
+ mtime_ns = xml_path.stat().st_mtime_ns
+ indexed_val = {}
+
+ cached_str = await async_cache.hget(LAUNCHBOX_XML_INDEX_KEY, xml_path_str)
+ if cached_str:
+ cached = json.loads(cached_str)
+ if cached[0] == mtime_ns:
+ indexed_val = cached[1]
+ else:
+ cached = None
+ else:
+ cached = None
+
+ if cached is None:
+ root = ET.parse(xml_path_str).getroot()
+ if root:
+ for game_elem in root.findall(".//Game"):
+ entry: dict[str, str] = {}
+ for child_elem in game_elem:
+ if child_elem.tag and child_elem.text is not None:
+ entry[child_elem.tag] = child_elem.text
+ if not entry:
+ continue
+
+ app_path = (entry.get("ApplicationPath") or "").strip()
+ if app_path:
+ app_base = PureWindowsPath(app_path).name.strip().lower()
+ if app_base:
+ indexed_val.setdefault(app_base, entry)
+
+ title = (entry.get("Title") or "").strip().lower()
+ if title:
+ indexed_val.setdefault(f"title:{title}", entry)
+
+ await async_cache.hset(
+ LAUNCHBOX_XML_INDEX_KEY,
+ xml_path_str,
+ json.dumps((mtime_ns, indexed_val)),
+ )
+ except (ET.ParseError, FileNotFoundError, PermissionError) as e:
+ log.warning(f"Failed to parse local LaunchBox XML {xml_path}: {e}")
+ return None
- async def _get_game_images(self, database_id: str) -> list[dict] | None:
- metadata_image_index_entry = await async_cache.hget(
- LAUNCHBOX_METADATA_IMAGE_KEY, database_id
- )
+ if not indexed_val:
+ return None
- if not metadata_image_index_entry:
+ fs_key = fs_name.strip().lower()
+ if not fs_key:
return None
- return json.loads(metadata_image_index_entry)
+ direct = indexed_val.get(fs_key)
+ if direct is not None:
+ return direct
- def _get_best_cover_image(self, game_images: list[dict]) -> dict | None:
- """
- Get the best cover image from a list of game images based on priority order:
- """
- # Define priority order
- priority_types = [
- "Box - Front",
- "Box - 3D",
- "Fanart - Box - Front",
- "Cart - Front",
- "Cart - 3D",
- ]
+ try:
+ stem = Path(fs_name).stem.strip().lower()
+ except Exception:
+ stem = ""
- for image_type in priority_types:
- for image in game_images:
- if image.get("Type") == image_type:
- return image
+ if stem:
+ by_title = indexed_val.get(f"title:{stem}")
+ if by_title is not None:
+ return by_title
- return None
+ return indexed_val.get(f"title:{fs_key}")
- def _get_screenshots(self, game_images: list[dict]) -> list[str]:
- screenshots: list[str] = []
- for image in game_images:
- if "Screenshot" in image.get("Type", ""):
- screenshots.append(
- f"https://images.launchbox-app.com/{image.get('FileName')}"
- )
+ async def _get_remote_rom(
+ self,
+ file_name: str,
+ platform_slug: str,
+ *,
+ assume_cache_present: bool = False,
+ ) -> dict | None:
+ if not assume_cache_present and not (
+ await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)
+ ):
+ log.error("Could not find the Launchbox Metadata.xml file in cache")
+ return None
- return screenshots
+ lb_platform = self.get_platform(platform_slug)
+ platform_name = lb_platform.get("name", None)
+ if not platform_name:
+ return None
- def get_platform(self, slug: str) -> LaunchboxPlatform:
- if slug not in LAUNCHBOX_PLATFORM_LIST:
- return LaunchboxPlatform(slug=slug, launchbox_id=None)
+ file_name_clean = (file_name or "").strip()
+ if not file_name_clean:
+ return None
- platform = LAUNCHBOX_PLATFORM_LIST[UPS(slug)]
+ candidates: list[str] = [file_name_clean]
+ lower = file_name_clean.lower()
+ if lower != file_name_clean:
+ candidates.append(lower)
+
+ for candidate in candidates:
+ metadata_name_index_entry = await async_cache.hget(
+ LAUNCHBOX_METADATA_NAME_KEY, f"{candidate}:{platform_name}"
+ )
+ if metadata_name_index_entry:
+ return json.loads(metadata_name_index_entry)
+
+ for candidate in candidates:
+ metadata_alternate_name_index_entry = await async_cache.hget(
+ LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY, candidate
+ )
+ if not metadata_alternate_name_index_entry:
+ continue
+
+ metadata_alternate_name_index_entry = json.loads(
+ metadata_alternate_name_index_entry
+ )
+ database_id = metadata_alternate_name_index_entry["DatabaseID"]
+ metadata_database_index_entry = await async_cache.hget(
+ LAUNCHBOX_METADATA_DATABASE_ID_KEY, database_id
+ )
+ if metadata_database_index_entry:
+ return json.loads(metadata_database_index_entry)
- return LaunchboxPlatform(
- slug=slug,
- launchbox_id=platform["id"],
- name=platform["name"],
- )
+ return None
async def get_rom(
- self, fs_name: str, platform_slug: str, keep_tags: bool = False
+ self,
+ fs_name: str,
+ platform_slug: str,
+ keep_tags: bool = False,
+ *,
+ remote_enabled: bool = True,
) -> LaunchboxRom:
from handler.filesystem import fs_rom_handler
@@ -241,11 +914,62 @@ async def get_rom(
if not self.is_enabled():
return fallback_rom
- # Check for LaunchBox ID tag in filename first
- launchbox_id_from_tag = self.extract_launchbox_id_from_filename(fs_name)
- if launchbox_id_from_tag:
+ local = await self._get_local_rom(fs_name, platform_slug)
+
+ remote_available = remote_enabled and bool(
+ await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)
+ )
+
+ if local is not None:
+ launchbox_id_local = safe_int(local.get("DatabaseID"))
+ remote: dict | None = None
+ if remote_available:
+ if launchbox_id_local:
+ metadata_database_index_entry = await async_cache.hget(
+ LAUNCHBOX_METADATA_DATABASE_ID_KEY, str(launchbox_id_local)
+ )
+ if metadata_database_index_entry:
+ remote = json.loads(metadata_database_index_entry)
+
+ if remote is None:
+ local_title = (local.get("Title") or "").strip()
+ if local_title:
+ remote = await self._get_remote_rom(
+ local_title,
+ platform_slug,
+ assume_cache_present=True,
+ )
+ platform_name = self.get_platform(platform_slug).get("name")
+ remote_images = await self._fetch_remote_images(
+ remote=remote, remote_enabled=remote_available
+ )
+ media_req = _local_media_req(
+ platform_name=platform_name,
+ fs_name=fs_name,
+ local=local,
+ remote=remote,
+ remote_images=remote_images,
+ remote_enabled=remote_available,
+ )
+ return build_rom(
+ local=local,
+ remote=remote,
+ launchbox_id=launchbox_id_local
+ or (remote.get("DatabaseID") if remote else None),
+ media_req=media_req,
+ )
+
+ if not remote_available:
+ return fallback_rom
+
+ match = LAUNCHBOX_TAG_REGEX.search(fs_name)
+ launchbox_id_from_tag = int(match.group(1)) if match else None
+
+ if launchbox_id_from_tag is not None:
log.debug(f"Found LaunchBox ID tag in filename: {launchbox_id_from_tag}")
- rom_by_id = await self.get_rom_by_id(launchbox_id_from_tag)
+ rom_by_id = await self.get_rom_by_id(
+ launchbox_id_from_tag, remote_enabled=remote_enabled
+ )
if rom_by_id["launchbox_id"]:
log.debug(
f"Successfully matched ROM by LaunchBox ID tag: {fs_name} -> {launchbox_id_from_tag}"
@@ -268,45 +992,45 @@ async def get_rom(
search_term = search_term.lower()
+ # Check if game is scummvm shortname
if platform_slug == UPS.SCUMMVM:
search_term = await self._scummvm_format(search_term)
fallback_rom = LaunchboxRom(launchbox_id=None, name=search_term)
- index_entry = await self._get_rom_from_metadata(search_term, platform_slug)
+ index_entry = await self._get_remote_rom(
+ search_term,
+ platform_slug,
+ assume_cache_present=True,
+ )
if not index_entry:
return fallback_rom
- url_cover = None
- url_screenshots = []
-
- game_images = await self._get_game_images(index_entry["DatabaseID"])
- if game_images:
- best_cover = self._get_best_cover_image(game_images)
- if best_cover:
- url_cover = (
- f"https://images.launchbox-app.com/{best_cover.get('FileName')}"
- )
-
- url_screenshots = self._get_screenshots(game_images)
-
- rom = {
- "launchbox_id": index_entry["DatabaseID"],
- "name": index_entry["Name"],
- "summary": index_entry.get("Overview", ""),
- "url_cover": url_cover,
- "url_screenshots": url_screenshots,
- "launchbox_metadata": extract_metadata_from_launchbox_rom(
- index_entry, game_images
- ),
- }
+ remote_images = await self._fetch_remote_images(
+ remote=index_entry, remote_enabled=remote_available
+ )
+ media_req = _remote_media_req(
+ remote=index_entry,
+ remote_images=remote_images,
+ remote_enabled=remote_available,
+ )
- return LaunchboxRom({k: v for k, v in rom.items() if v}) # type: ignore[misc]
+ return build_rom(
+ local=None,
+ remote=index_entry,
+ launchbox_id=index_entry["DatabaseID"],
+ media_req=media_req,
+ )
- async def get_rom_by_id(self, database_id: int) -> LaunchboxRom:
+ async def get_rom_by_id(
+ self, database_id: int, *, remote_enabled: bool = True
+ ) -> LaunchboxRom:
if not self.is_enabled():
return LaunchboxRom(launchbox_id=None)
+ if not remote_enabled:
+ return LaunchboxRom(launchbox_id=None)
+
metadata_database_index_entry = await async_cache.hget(
LAUNCHBOX_METADATA_DATABASE_ID_KEY, str(database_id)
)
@@ -314,29 +1038,22 @@ async def get_rom_by_id(self, database_id: int) -> LaunchboxRom:
if not metadata_database_index_entry:
return LaunchboxRom(launchbox_id=None)
- # Parse the JSON string from cache
metadata_database_index_entry = json.loads(metadata_database_index_entry)
- game_images = await self._get_game_images(
- metadata_database_index_entry["DatabaseID"]
+ remote_images = await self._fetch_remote_images(
+ remote=metadata_database_index_entry, remote_enabled=remote_enabled
+ )
+ media_req = _remote_media_req(
+ remote=metadata_database_index_entry,
+ remote_images=remote_images,
+ remote_enabled=remote_enabled,
)
- rom = {
- "launchbox_id": database_id,
- "name": metadata_database_index_entry["Name"],
- "summary": metadata_database_index_entry.get("Overview", ""),
- "launchbox_metadata": extract_metadata_from_launchbox_rom(
- metadata_database_index_entry,
- game_images,
- ),
- }
-
- return LaunchboxRom({k: v for k, v in rom.items() if v}) # type: ignore[misc]
-
- async def get_matched_rom_by_id(self, database_id: int) -> LaunchboxRom | None:
- if not self.is_enabled():
- return None
-
- return await self.get_rom_by_id(database_id)
+ return build_rom(
+ local=None,
+ remote=metadata_database_index_entry,
+ launchbox_id=database_id,
+ media_req=media_req,
+ )
async def get_matched_roms_by_name(
self, search_term: str, platform_slug: str
@@ -347,6 +1064,13 @@ async def get_matched_roms_by_name(
rom = await self.get_rom(search_term, platform_slug, True)
return [rom] if rom else []
+ async def get_matched_rom_by_id(self, database_id: int) -> LaunchboxRom | None:
+ if not self.is_enabled():
+ return None
+
+ rom = await self.get_rom_by_id(database_id, remote_enabled=True)
+ return rom if rom.get("launchbox_id") else None
+
class SlugToLaunchboxId(TypedDict):
id: int
diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py
index 6e1c7b26b..f2160b2aa 100644
--- a/backend/handler/scan_handler.py
+++ b/backend/handler/scan_handler.py
@@ -287,6 +287,7 @@ async def scan_rom(
fs_rom: FSRom,
metadata_sources: list[str],
newly_added: bool,
+ launchbox_remote_enabled: bool = True,
socket_manager: socketio.AsyncRedisManager | None = None,
) -> Rom:
rom_attrs = {
@@ -591,13 +592,21 @@ async def fetch_launchbox_rom(platform_slug: str) -> LaunchboxRom:
and rom.platform_slug in LAUNCHBOX_PLATFORM_LIST
)
):
- if scan_type == ScanType.UPDATE and rom.launchbox_id:
- return await meta_launchbox_handler.get_rom_by_id(rom.launchbox_id)
- else:
- return await meta_launchbox_handler.get_rom(
- rom_attrs["fs_name"], platform_slug
+ if (
+ scan_type == ScanType.UPDATE
+ and rom.launchbox_id
+ and launchbox_remote_enabled
+ ):
+ return await meta_launchbox_handler.get_rom_by_id(
+ rom.launchbox_id, remote_enabled=True
)
+ return await meta_launchbox_handler.get_rom(
+ rom_attrs["fs_name"],
+ platform_slug,
+ remote_enabled=launchbox_remote_enabled,
+ )
+
return LaunchboxRom(launchbox_id=None)
async def fetch_ra_rom(hasheous_rom: HasheousRom) -> RAGameRom:
diff --git a/backend/tasks/scheduled/update_launchbox_metadata.py b/backend/tasks/scheduled/update_launchbox_metadata.py
index a7b0b887d..4a942538e 100644
--- a/backend/tasks/scheduled/update_launchbox_metadata.py
+++ b/backend/tasks/scheduled/update_launchbox_metadata.py
@@ -7,9 +7,9 @@
from config import (
ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA,
+ LAUNCHBOX_API_ENABLED,
SCHEDULED_UPDATE_LAUNCHBOX_METADATA_CRON,
)
-from handler.metadata import meta_launchbox_handler
from handler.metadata.launchbox_handler import (
LAUNCHBOX_FILES_KEY,
LAUNCHBOX_MAME_KEY,
@@ -44,7 +44,10 @@ def __init__(self):
async def run(self, force: bool = False) -> dict[str, Any]:
update_stats = UpdateStats()
- if not meta_launchbox_handler.is_enabled():
+ # This task pulls remote metadata from LaunchBox (Metadata.zip) and should
+ # only run when the LaunchBox API integration is enabled.
+ # `meta_launchbox_handler.is_enabled()` may also be true for local-only mode.
+ if not LAUNCHBOX_API_ENABLED:
log.warning("Launchbox API is not enabled, skipping metadata update")
return update_stats.to_dict()
diff --git a/frontend/src/locales/cs_CZ/scan.json b/frontend/src/locales/cs_CZ/scan.json
index 980578fdc..b80b64514 100644
--- a/frontend/src/locales/cs_CZ/scan.json
+++ b/frontend/src/locales/cs_CZ/scan.json
@@ -13,25 +13,24 @@
"connection-in-progress": "Probíhá připojování…",
"connection-successful": "Připojení úspěšné",
"disabled-by-admin": "Zakázáno administrátorem",
+ "hash-calculation-disabled": "Výpočet hash je zakázán",
+ "hasheous-requires-hashes": "Hasheous vyžaduje povolené počítání hashů",
"hashes": "Přepočítat hashe",
"hashes-desc": "Přepočítá hashe pro vybrané platformy",
"hashes-disabled-tooltip": "Výpočet hashů zakázán.
Hashe (MD5, SHA1, CRC32) jsou jedinečné otisky, které přesně identifikují soubory ROM.
Bez nich nemohou Hasheous a RetroAchievements porovnávat hry se svými databázemi, ale skenování bude rychlejší.",
"hashes-enabled-tooltip": "Výpočet hashů povolen.
Budou vypočítány hashe (MD5, SHA1, CRC32) pro vytvoření jedinečných otisků každého souboru ROM.
To umožňuje Hasheous a RetroAchievements přesně identifikovat hry ve svých databázích.",
- "hash-calculation-disabled": "Výpočet hash je zakázán",
- "hasheous-requires-hashes": "Hasheous vyžaduje povolené počítání hashů",
- "retroachievements-requires-hashes": "RetroAchievements vyžaduje povolené počítání hashů",
+ "launchbox-remote": "Launchbox",
"manage-library": "Správa knihovny",
"metadata-sources": "Zdroje metadat",
"new-platforms": "Nové platformy",
"new-platforms-desc": "Skenovat pouze nové platformy (nejrychlejší)",
"no-new-roms": "Nenalezeny žádné nové / změněné ROMy",
"not-identified": "Neidentifikováno",
- "update-metadata": "Aktualizovat metadata",
- "update-metadata-desc": "Aktualizuje metadata pro nalezené hry",
"platforms-scanned-n": "Platformy: {n} naskenovány",
"platforms-scanned-with-details": "Platformy: {n_scanned_platforms} naskenováno z {n_total_platforms}, {n_new_platforms} nových a {n_identified_platforms} identifikovaných",
"quick-scan": "Rychlý sken",
"quick-scan-desc": "Skenovat pouze nové hry",
+ "retroachievements-requires-hashes": "RetroAchievements vyžaduje povolené počítání hashů",
"roms-scanned-n": "ROMy: {n} naskenovány",
"roms-scanned-with-details": "ROMy: {n_scanned_roms} naskenováno z {n_total_roms}, {n_new_roms} nových a {n_identified_roms} identifikovaných",
"scan": "Skenovat",
@@ -40,5 +39,7 @@
"scan-types-more-info": "Více informací",
"select-one-source": "Vyberte alespoň jeden zdroj metadat pro doplnění knihovny o artworky a metadata",
"unmatched-games": "Nespárované hry",
- "unmatched-games-desc": "Skenovat hry s chybějícím přiřazením metadat"
+ "unmatched-games-desc": "Skenovat hry s chybějícím přiřazením metadat",
+ "update-metadata": "Aktualizovat metadata",
+ "update-metadata-desc": "Aktualizuje metadata pro nalezené hry"
}
diff --git a/frontend/src/locales/de_DE/scan.json b/frontend/src/locales/de_DE/scan.json
index 94892b01e..a816a74af 100644
--- a/frontend/src/locales/de_DE/scan.json
+++ b/frontend/src/locales/de_DE/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Verbindung wird hergestellt...",
"connection-successful": "Verbindung erfolgreich",
"disabled-by-admin": "Vom Administrator deaktiviert",
+ "hash-calculation-disabled": "Hash-Berechnung ist deaktiviert",
+ "hasheous-requires-hashes": "Hasheous erfordert aktivierte Hash-Berechnung",
"hashes": "Hashes neu berechnen",
"hashes-desc": "Berechnet Hashes für ausgewählte Plattformen neu",
"hashes-disabled-tooltip": "Hash-Berechnung deaktiviert.
Hashes (MD5, SHA1, CRC32) sind eindeutige Fingerabdrücke, die ROM-Dateien präzise identifizieren.
Ohne sie können Hasheous und RetroAchievements Spiele nicht mit ihren Datenbanken abgleichen, aber das Scannen wird schneller.",
"hashes-enabled-tooltip": "Hash-Berechnung aktiviert.
Hashes (MD5, SHA1, CRC32) werden berechnet, um eindeutige Fingerabdrücke für jede ROM-Datei zu erstellen.
Dies ermöglicht es Hasheous und RetroAchievements, Spiele in ihren Datenbanken genau zu identifizieren.",
- "hash-calculation-disabled": "Hash-Berechnung ist deaktiviert",
- "hasheous-requires-hashes": "Hasheous erfordert aktivierte Hash-Berechnung",
- "retroachievements-requires-hashes": "RetroAchievements erfordert aktivierte Hash-Berechnung",
+ "launchbox-remote": "LaunchBox Fernbedienung",
"manage-library": "Bibliothek verwalten",
"metadata-sources": "Quellen für Metadaten",
"new-platforms": "Neue Platformen",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Plattformen: {n_scanned_platforms} gescannt aus {n_total_platforms}, darunter {n_new_platforms} neue und {n_identified_platforms} identifizierte",
"quick-scan": "Schneller Scan",
"quick-scan-desc": "Nur neue Spiele scannen",
+ "retroachievements-requires-hashes": "RetroAchievements erfordert aktivierte Hash-Berechnung",
"roms-scanned-n": "Roms: {n} gescannte | Roms: {n} gescannt",
"roms-scanned-with-details": "Roms: {n_scanned_roms} gescannt aus {n_total_roms}, darunter {n_new_roms} neue und {n_identified_roms} identifizierte",
"scan": "Scannen",
diff --git a/frontend/src/locales/en_GB/scan.json b/frontend/src/locales/en_GB/scan.json
index d194f3501..086830086 100644
--- a/frontend/src/locales/en_GB/scan.json
+++ b/frontend/src/locales/en_GB/scan.json
@@ -13,20 +13,19 @@
"connection-in-progress": "Connection in progress...",
"connection-successful": "Connection successful",
"disabled-by-admin": "Disabled by the administrator",
+ "hash-calculation-disabled": "Hash calculation is disabled",
+ "hasheous-requires-hashes": "Hasheous requires hash calculation to be enabled",
"hashes": "Recalculate hashes",
"hashes-desc": "Recalculates hashes for selected platforms",
"hashes-disabled-tooltip": "File hash calculation disabled.
Hashes (MD5, SHA1, CRC32) are unique fingerprints that identify ROM files precisely.
Without them, Hasheous and RetroAchievements cannot match games to their databases, but scanning will be faster.",
"hashes-enabled-tooltip": "File hash calculation enabled.
Hashes (MD5, SHA1, CRC32) will be calculated to create unique fingerprints for each ROM file.
This enables Hasheous and RetroAchievements to accurately identify games in their databases.",
- "hash-calculation-disabled": "Hash calculation is disabled",
- "hasheous-requires-hashes": "Hasheous requires hash calculation to be enabled",
+ "launchbox-remote": "Launchbox",
"manage-library": "Manage library",
"metadata-sources": "Metadata sources",
"new-platforms": "New platforms",
"new-platforms-desc": "Scan new platforms only (fastest)",
"no-new-roms": "No new/changed roms found",
"not-identified": "Not identified",
- "update-metadata": "Update metadata",
- "update-metadata-desc": "Update metadata for matched games",
"platforms-scanned-n": "Platforms: {n} scanned",
"platforms-scanned-with-details": "Platforms: {n_scanned_platforms} scanned out of {n_total_platforms}, with {n_new_platforms} new and {n_identified_platforms} identified",
"quick-scan": "Quick scan",
@@ -40,5 +39,7 @@
"scan-types-more-info": "More information",
"select-one-source": "Please select at least one metadata source to enrich your library with artwork and metadata",
"unmatched-games": "Unmatched games",
- "unmatched-games-desc": "Scan games with missing metadata matches"
+ "unmatched-games-desc": "Scan games with missing metadata matches",
+ "update-metadata": "Update metadata",
+ "update-metadata-desc": "Update metadata for matched games"
}
diff --git a/frontend/src/locales/en_US/scan.json b/frontend/src/locales/en_US/scan.json
index 2f8b07e67..9d97dbd83 100644
--- a/frontend/src/locales/en_US/scan.json
+++ b/frontend/src/locales/en_US/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Connection in progress...",
"connection-successful": "Connection successful",
"disabled-by-admin": "Disabled by the administrator",
+ "hash-calculation-disabled": "Hash calculation is disabled",
+ "hasheous-requires-hashes": "Hasheous requires hash calculation to be enabled",
"hashes": "Recalculate hashes",
"hashes-desc": "Recalculates hashes for selected platforms",
"hashes-disabled-tooltip": "File hash calculation disabled.
Hashes (MD5, SHA1, CRC32) are unique fingerprints that identify ROM files precisely.
Without them, Hasheous and RetroAchievements cannot match games to their databases, but scanning will be faster.",
"hashes-enabled-tooltip": "File hash calculation enabled.
Hashes (MD5, SHA1, CRC32) will be calculated to create unique fingerprints for each ROM file.
This enables Hasheous and RetroAchievements to accurately identify games in their databases.",
- "hash-calculation-disabled": "Hash calculation is disabled",
- "hasheous-requires-hashes": "Hasheous requires hash calculation to be enabled",
- "retroachievements-requires-hashes": "RetroAchievements requires hash calculation to be enabled",
+ "launchbox-remote": "LaunchBox remote (enrich local with remote)",
"manage-library": "Manage library",
"metadata-sources": "Metadata sources",
"new-platforms": "New platforms",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Platforms: {n_scanned_platforms} scanned out of {n_total_platforms}, with {n_new_platforms} new and {n_identified_platforms} identified",
"quick-scan": "Quick scan",
"quick-scan-desc": "Scan new games only",
+ "retroachievements-requires-hashes": "RetroAchievements requires hash calculation to be enabled",
"roms-scanned-n": "Roms: {n} scanned",
"roms-scanned-with-details": "Roms: {n_scanned_roms} scanned out of {n_total_roms}, with {n_new_roms} new and {n_identified_roms} identified",
"scan": "Scan",
diff --git a/frontend/src/locales/es_ES/scan.json b/frontend/src/locales/es_ES/scan.json
index a4dc5c24c..808edc846 100644
--- a/frontend/src/locales/es_ES/scan.json
+++ b/frontend/src/locales/es_ES/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Conexión en progreso...",
"connection-successful": "Conexión exitosa",
"disabled-by-admin": "Deshabilitado por el administrador",
+ "hash-calculation-disabled": "El cálculo de hash está deshabilitado",
+ "hasheous-requires-hashes": "Hasheous requiere que el cálculo de hashes esté habilitado",
"hashes": "Recalcular hashes",
"hashes-desc": "Recalcula los hashes de las plataformas seleccionadas",
"hashes-disabled-tooltip": "Cálculo de hash deshabilitado.
Los hashes (MD5, SHA1, CRC32) son huellas digitales únicas que identifican archivos ROM con precisión.
Sin ellos, Hasheous y RetroAchievements no pueden comparar juegos con sus bases de datos, pero el escaneo será más rápido.",
"hashes-enabled-tooltip": "Cálculo de hash habilitado.
Se calcularán hashes (MD5, SHA1, CRC32) para crear huellas digitales únicas de cada archivo ROM.
Esto permite a Hasheous y RetroAchievements identificar juegos con precisión en sus bases de datos.",
- "hash-calculation-disabled": "El cálculo de hash está deshabilitado",
- "hasheous-requires-hashes": "Hasheous requiere que el cálculo de hashes esté habilitado",
- "retroachievements-requires-hashes": "RetroAchievements requiere que el cálculo de hashes esté habilitado",
+ "launchbox-remote": "LaunchBox Remoto",
"manage-library": "Gestionar biblioteca",
"metadata-sources": "Fuentes de metadatos",
"new-platforms": "Plataformas nuevas",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Plataformas: {n_scanned_platforms} escaneadas de {n_total_platforms}, con {n_new_platforms} nuevas y {n_identified_platforms} identificadas",
"quick-scan": "Escaneo rápido",
"quick-scan-desc": "Escanea solo juegos nuevos",
+ "retroachievements-requires-hashes": "RetroAchievements requiere que el cálculo de hashes esté habilitado",
"roms-scanned-n": "Roms: {n} escaneado | Roms: {n} escaneados",
"roms-scanned-with-details": "Roms: {n_scanned_roms} escaneados de {n_total_roms}, con {n_new_roms} nuevos y {n_identified_roms} identificados",
"scan": "Escanear",
diff --git a/frontend/src/locales/fr_FR/scan.json b/frontend/src/locales/fr_FR/scan.json
index 4552e6aad..df845321a 100644
--- a/frontend/src/locales/fr_FR/scan.json
+++ b/frontend/src/locales/fr_FR/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Connexion en cours...",
"connection-successful": "Connexion réussie",
"disabled-by-admin": "Désactivé par l'administrateur",
+ "hash-calculation-disabled": "Le calcul de hachage est désactivé",
+ "hasheous-requires-hashes": "Hasheous nécessite que le calcul de hachage soit activé",
"hashes": "Recalculer les hachages",
"hashes-desc": "Recalculer les hachages des plateformes sélectionnées",
"hashes-disabled-tooltip": "Calcul de hachage désactivé.
Les hachages (MD5, SHA1, CRC32) sont des empreintes uniques qui identifient les fichiers ROM avec précision.
Sans eux, Hasheous et RetroAchievements ne peuvent pas faire correspondre les jeux à leurs bases de données, mais l'analyse sera plus rapide.",
"hashes-enabled-tooltip": "Calcul de hachage de fichier activé.
Les hachages (MD5, SHA1, CRC32) seront calculés pour créer des empreintes uniques pour chaque fichier ROM.
Ceci permet à Hasheous et RetroAchievements d'identifier précisément les jeux dans leurs bases de données.",
- "hash-calculation-disabled": "Le calcul de hachage est désactivé",
- "hasheous-requires-hashes": "Hasheous nécessite que le calcul de hachage soit activé",
- "retroachievements-requires-hashes": "RetroAchievements nécessite que le calcul de hachage soit activé",
+ "launchbox-remote": "LaunchBox remote (enrichir le local avec le remote)",
"manage-library": "Gérer la bibliothèque",
"metadata-sources": "Sources de métadonnées",
"new-platforms": "Nouvelles plateformes",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Plateformes : {n_scanned_platforms} scannées sur {n_total_platforms}, avec {n_new_platforms} nouvelles et {n_identified_platforms} identifiées",
"quick-scan": "Scan rapide",
"quick-scan-desc": "Scanner uniquement les nouveaux jeux",
+ "retroachievements-requires-hashes": "RetroAchievements nécessite que le calcul de hachage soit activé",
"roms-scanned-n": "Roms : {n} scannée | Roms : {n} scannées",
"roms-scanned-with-details": "Roms : {n_scanned_roms} scannées sur {n_total_roms}, avec {n_new_roms} nouvelles et {n_identified_roms} identifiées",
"scan": "Scanner",
diff --git a/frontend/src/locales/hu_HU/scan.json b/frontend/src/locales/hu_HU/scan.json
index 64317285e..f91bd20de 100644
--- a/frontend/src/locales/hu_HU/scan.json
+++ b/frontend/src/locales/hu_HU/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Csatlakozás folyamatban...",
"connection-successful": "A kapcsolat sikeresen létrejött",
"disabled-by-admin": "Az adminisztrátor által letiltva",
+ "hash-calculation-disabled": "A hash számítás le van tiltva",
+ "hasheous-requires-hashes": "A Hasheous megköveteli a hash számítás engedélyezését",
"hashes": "Hash-értékek újraszámítása",
"hashes-desc": "Hash-értékek újraszámítása a kiválasztott platformokon",
"hashes-disabled-tooltip": "Fájl hash számítás letiltva.
A hash-ek (MD5, SHA1, CRC32) egyedi ujjlenyomatok, amelyek pontosan azonosítják a ROM fájlokat.
Ezek nélkül a Hasheous és a RetroAchievements nem tudja a játékokat az adatbázisukhoz rendelni, de a szkennelés gyorsabb lesz.",
"hashes-enabled-tooltip": "Fájl hash számítás engedélyezve.
Hashes (MD5, SHA1, CRC32) lesz kiszámítva, hogy egyedi ujjlenyomatokat hozzon létre minden ROM fájlhoz.
Ez lehetővé teszi a Hasheous és a RetroAchievements számára, hogy pontosan azonosítsák a játékokat az adatbázisaikban.",
- "hash-calculation-disabled": "A hash számítás le van tiltva",
- "hasheous-requires-hashes": "A Hasheous megköveteli a hash számítás engedélyezését",
- "retroachievements-requires-hashes": "A RetroAchievements-hez engedélyezni kell a hash-számítást.",
+ "launchbox-remote": "Launchbox",
"manage-library": "Könyvtár Menedzsment",
"metadata-sources": "Metaadat források",
"new-platforms": "Új platformok",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Platformok: {n_scanned_platforms} beolvasva a {n_total_platforms} közül,ebből {n_new_platforms} új és {n_identified_platforms} azonosított",
"quick-scan": "Gyors szkennelés",
"quick-scan-desc": "Csak új játékokat szkenneljen",
+ "retroachievements-requires-hashes": "A RetroAchievements-hez engedélyezni kell a hash-számítást.",
"roms-scanned-n": "ROM-ok: {n} szkennelve",
"roms-scanned-with-details": "ROM-ok: {n_scanned_roms} beolvasva a {n_total_roms} közül,ebből {n_new_roms} új és {n_identified_roms} azonosított",
"scan": "Szkennelés",
diff --git a/frontend/src/locales/it_IT/scan.json b/frontend/src/locales/it_IT/scan.json
index f5c882735..f1c7dabb2 100644
--- a/frontend/src/locales/it_IT/scan.json
+++ b/frontend/src/locales/it_IT/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Connessione in corso...",
"connection-successful": "Connessione riuscita",
"disabled-by-admin": "Disabilitato dall'amministratore",
+ "hash-calculation-disabled": "Il calcolo dell'hash è disabilitato",
+ "hasheous-requires-hashes": "Hasheous richiede che il calcolo degli hash sia abilitato",
"hashes": "Ricalcola hash",
"hashes-desc": "Ricalcola gli hash per le piattaforme selezionate",
"hashes-disabled-tooltip": "Calcolo hash disabilitato.
Gli hash (MD5, SHA1, CRC32) sono impronte digitali uniche che identificano i file ROM con precisione.
Senza di essi, Hasheous e RetroAchievements non possono confrontare i giochi con i loro database, ma la scansione sarà più veloce.",
"hashes-enabled-tooltip": "Calcolo hash abilitato.
Verranno calcolati gli hash (MD5, SHA1, CRC32) per creare impronte digitali uniche di ogni file ROM.
Questo consente a Hasheous e RetroAchievements di identificare accuratamente i giochi nei loro database.",
- "hash-calculation-disabled": "Il calcolo dell'hash è disabilitato",
- "hasheous-requires-hashes": "Hasheous richiede che il calcolo degli hash sia abilitato",
- "retroachievements-requires-hashes": "RetroAchievements richiede che il calcolo degli hash sia abilitato",
+ "launchbox-remote": "Launchbox",
"manage-library": "Gestisci libreria",
"metadata-sources": "Fonti metadati",
"new-platforms": "Nuove piattaforme",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Piattaforme: {n_scanned_platforms} scansionate su {n_total_platforms}, con {n_new_platforms} nuove e {n_identified_platforms} identificate",
"quick-scan": "Scansione rapida",
"quick-scan-desc": "Scansiona solo i nuovi giochi",
+ "retroachievements-requires-hashes": "RetroAchievements richiede che il calcolo degli hash sia abilitato",
"roms-scanned-n": "Rom: {n} scansionate",
"roms-scanned-with-details": "Rom: {n_scanned_roms} scansionate su {n_total_roms}, con {n_new_roms} nuove e {n_identified_roms} identificate",
"scan": "Scansiona",
diff --git a/frontend/src/locales/ja_JP/scan.json b/frontend/src/locales/ja_JP/scan.json
index db48ced91..d4cefef0a 100644
--- a/frontend/src/locales/ja_JP/scan.json
+++ b/frontend/src/locales/ja_JP/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "接続中...",
"connection-successful": "接続成功",
"disabled-by-admin": "管理者によって無効化されています",
+ "hash-calculation-disabled": "ハッシュ計算が無効になっています",
+ "hasheous-requires-hashes": "Hasheousはファイルハッシュが必要です",
"hashes": "ハッシュ値の再計算",
"hashes-desc": "選択されたプラットフォームのハッシュ値を再計算します",
"hashes-disabled-tooltip": "ファイルハッシュ計算が無効。
ハッシュ(MD5、SHA1、CRC32)はROMファイルを正確に識別するユニークな指紋です。
これがないと、HasheousやRetroAchievementsはゲームをデータベースとマッチングできませんが、スキャンは高速になります。",
"hashes-enabled-tooltip": "ファイルハッシュ計算が有効です。
各ROMファイルの一意の指紋を作成するために、ハッシュ(MD5、SHA1、CRC32)が計算されます。
これにより、HasheousとRetroAchievementsがデータベース内のゲームを正確に識別できます。",
- "hash-calculation-disabled": "ハッシュ計算が無効になっています",
- "hasheous-requires-hashes": "Hasheousはファイルハッシュが必要です",
+ "launchbox-remote": "Launchbox",
"manage-library": "ライブラリを編集",
"metadata-sources": "メタデータ取得元",
"new-platforms": "新規プラットフォーム",
diff --git a/frontend/src/locales/ko_KR/scan.json b/frontend/src/locales/ko_KR/scan.json
index cec6b7cc7..c464a12c2 100644
--- a/frontend/src/locales/ko_KR/scan.json
+++ b/frontend/src/locales/ko_KR/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "연결 진행 중...",
"connection-successful": "연결 성공",
"disabled-by-admin": "관리자에 의해 비활성화됨",
+ "hash-calculation-disabled": "해시 계산이 비활성화되어 있습니다",
+ "hasheous-requires-hashes": "Hasheous는 파일 해시가 필요합니다",
"hashes": "해시",
"hashes-desc": "선택된 플랫폼의 해시를 다시 계산",
"hashes-disabled-tooltip": "해시 계산이 비활성화됨.
해시(MD5, SHA1, CRC32)는 ROM 파일을 정확히 식별하는 고유한 지문입니다.
해시 없이는 Hasheous와 RetroAchievements가 데이터베이스와 게임을 매치할 수 없지만, 스캔이 더 빨라집니다.",
"hashes-enabled-tooltip": "해시 계산이 활성화됨.
각 ROM 파일의 고유한 지문을 생성하기 위해 해시(MD5, SHA1, CRC32)가 계산됩니다.
이를 통해 Hasheous와 RetroAchievements가 데이터베이스에서 게임을 정확히 식별할 수 있습니다.",
- "hash-calculation-disabled": "해시 계산이 비활성화되어 있습니다",
- "hasheous-requires-hashes": "Hasheous는 파일 해시가 필요합니다",
+ "launchbox-remote": "Launchbox",
"manage-library": "라이브러리 관리",
"metadata-sources": "메타데이터 DB",
"new-platforms": "새 플랫폼",
diff --git a/frontend/src/locales/pl_PL/scan.json b/frontend/src/locales/pl_PL/scan.json
index e3bcc284a..b84a8778c 100644
--- a/frontend/src/locales/pl_PL/scan.json
+++ b/frontend/src/locales/pl_PL/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Trwa nawiązywanie połączenia...",
"connection-successful": "Połączenie udane",
"disabled-by-admin": "Wyłączone przez administratora",
+ "hash-calculation-disabled": "Obliczanie skrótów jest wyłączone",
+ "hasheous-requires-hashes": "Hasheous wymaga włączonego obliczania skrótów",
"hashes": "Przelicz sumy kontrolne",
"hashes-desc": "Przelicza sumy kontrolne dla wybranych platform",
"hashes-disabled-tooltip": "Obliczanie skrótów wyłączone.
Skróty (MD5, SHA1, CRC32) to unikalne odciski palców, które precyzyjnie identyfikują pliki ROM.
Bez nich Hasheous i RetroAchievements nie mogą dopasować gier do swoich baz danych, ale skanowanie będzie szybsze.",
"hashes-enabled-tooltip": "Obliczanie skrótów włączone.
Zostaną obliczone skróty (MD5, SHA1, CRC32) w celu utworzenia unikalnych odcisków palców każdego pliku ROM.
To pozwala Hasheous i RetroAchievements na dokładną identyfikację gier w ich bazach danych.",
- "hash-calculation-disabled": "Obliczanie skrótów jest wyłączone",
- "hasheous-requires-hashes": "Hasheous wymaga włączonego obliczania skrótów",
- "retroachievements-requires-hashes": "RetroAchievements wymaga włączonego obliczania skrótów",
+ "launchbox-remote": "LaunchBox Zdalny",
"manage-library": "Zarządzaj biblioteką",
"metadata-sources": "Źródła metadanych",
"new-platforms": "Nowe platformy",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Platformy: {n_scanned_platforms} zeskanowano z {n_total_platforms}, z {n_new_platforms} nowych i {n_identified_platforms} zidentyfikowanych",
"quick-scan": "Szybkie skanowanie",
"quick-scan-desc": "Skanuj tylko nowe gry",
+ "retroachievements-requires-hashes": "RetroAchievements wymaga włączonego obliczania skrótów",
"roms-scanned-n": "ROM-y: zeskanowano {n}",
"roms-scanned-with-details": "ROM-y: {n_scanned_roms} zeskanowano z {n_total_roms}, z {n_new_roms} nowych i {n_identified_roms} zidentyfikowanych",
"scan": "Skanuj",
diff --git a/frontend/src/locales/pt_BR/scan.json b/frontend/src/locales/pt_BR/scan.json
index 47db866d5..e8a0cf34f 100644
--- a/frontend/src/locales/pt_BR/scan.json
+++ b/frontend/src/locales/pt_BR/scan.json
@@ -13,13 +13,13 @@
"connection-in-progress": "Conexão em andamento...",
"connection-successful": "Conexão bem-sucedida",
"disabled-by-admin": "Desativado pelo administrador",
+ "hash-calculation-disabled": "O cálculo de hash está desabilitado",
+ "hasheous-requires-hashes": "Hasheous requer que o cálculo de hash esteja habilitado",
"hashes": "Recalcular hashes",
"hashes-desc": "Recalcula hashes das plataformas selecionadas",
"hashes-disabled-tooltip": "Cálculo de hash desabilitado.
Hashes (MD5, SHA1, CRC32) são impressões digitais únicas que identificam arquivos ROM com precisão.
Sem eles, Hasheous e RetroAchievements não podem comparar jogos com seus bancos de dados, mas a varredura será mais rápida.",
"hashes-enabled-tooltip": "Cálculo de hash habilitado.
Hashes (MD5, SHA1, CRC32) serão calculados para criar impressões digitais únicas de cada arquivo ROM.
Isso permite que Hasheous e RetroAchievements identifiquem jogos com precisão em seus bancos de dados.",
- "hash-calculation-disabled": "O cálculo de hash está desabilitado",
- "hasheous-requires-hashes": "Hasheous requer que o cálculo de hash esteja habilitado",
- "retroachievements-requires-hashes": "RetroAchievements requer que o cálculo de hash esteja habilitado",
+ "launchbox-remote": "Launchbox",
"manage-library": "Gerenciar biblioteca",
"metadata-sources": "Fontes de metadados",
"new-platforms": "Novas plataformas",
@@ -30,6 +30,7 @@
"platforms-scanned-with-details": "Plataformas: {n_scanned_platforms} escaneadas de {n_total_platforms}, com {n_new_platforms} novas e {n_identified_platforms} identificadas",
"quick-scan": "Escaneamento rápido",
"quick-scan-desc": "Escanear apenas novos jogos",
+ "retroachievements-requires-hashes": "RetroAchievements requer que o cálculo de hash esteja habilitado",
"roms-scanned-n": "Roms: {n} escaneado | Roms: {n} escaneados",
"roms-scanned-with-details": "Roms: {n_scanned_roms} escaneados de {n_total_roms}, com {n_new_roms} novos e {n_identified_roms} identificados",
"scan": "Escanear",
diff --git a/frontend/src/locales/ro_RO/scan.json b/frontend/src/locales/ro_RO/scan.json
index fd32de711..b02da0400 100644
--- a/frontend/src/locales/ro_RO/scan.json
+++ b/frontend/src/locales/ro_RO/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "Conexiune în curs...",
"connection-successful": "Conexiune reușită",
"disabled-by-admin": "Dezactivat de administrator",
+ "hash-calculation-disabled": "Calculul hash-ului este dezactivat",
+ "hasheous-requires-hashes": "Hasheous necesită hash-uri de fișiere",
"hashes": "Recalculează hash-urile",
"hashes-desc": "Recalculează hash-urile pentru platformele selectate",
"hashes-disabled-tooltip": "Calculul hash-urilor dezactivat.
Hash-urile (MD5, SHA1, CRC32) sunt amprente digitale unice care identifică fișierele ROM cu precizie.
Fără ele, Hasheous și RetroAchievements nu pot potrivi jocurile cu bazele lor de date, dar scanarea va fi mai rapidă.",
"hashes-enabled-tooltip": "Calculul hash-urilor activat.
Se vor calcula hash-uri (MD5, SHA1, CRC32) pentru a crea amprente digitale unice pentru fiecare fișier ROM.
Aceasta permite lui Hasheous și RetroAchievements să identifice cu precizie jocurile în bazele lor de date.",
- "hash-calculation-disabled": "Calculul hash-ului este dezactivat",
- "hasheous-requires-hashes": "Hasheous necesită hash-uri de fișiere",
+ "launchbox-remote": "Launchbox",
"manage-library": "Gestionează biblioteca",
"metadata-sources": "Surse de metadate",
"new-platforms": "Platforme noi",
diff --git a/frontend/src/locales/ru_RU/scan.json b/frontend/src/locales/ru_RU/scan.json
index f960c3795..693d920a6 100644
--- a/frontend/src/locales/ru_RU/scan.json
+++ b/frontend/src/locales/ru_RU/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "Соединение в процессе...",
"connection-successful": "Соединение успешно установлено",
"disabled-by-admin": "Отключено администратором",
+ "hash-calculation-disabled": "Вычисление хешей отключено",
+ "hasheous-requires-hashes": "Hasheous требует хеши файлов",
"hashes": "Пересчитать хеши",
"hashes-desc": "Пересчитывает хеши для выбранных платформ",
"hashes-disabled-tooltip": "Вычисление хешей отключено.
Хеши (MD5, SHA1, CRC32) - это уникальные отпечатки, которые точно идентифицируют файлы ROM.
Без них Hasheous и RetroAchievements не могут сопоставить игры с своими базами данных, но сканирование будет быстрее.",
"hashes-enabled-tooltip": "Вычисление хешей включено.
Будут вычислены хеши (MD5, SHA1, CRC32) для создания уникальных отпечатков каждого файла ROM.
Это позволяет Hasheous и RetroAchievements точно идентифицировать игры в своих базах данных.",
- "hash-calculation-disabled": "Вычисление хешей отключено",
- "hasheous-requires-hashes": "Hasheous требует хеши файлов",
+ "launchbox-remote": "LaunchBox Пульт",
"manage-library": "Управление библиотекой",
"metadata-sources": "Источники мета��анных",
"new-platforms": "Новые платформы",
diff --git a/frontend/src/locales/zh_CN/scan.json b/frontend/src/locales/zh_CN/scan.json
index f580a192a..0ec466b6e 100644
--- a/frontend/src/locales/zh_CN/scan.json
+++ b/frontend/src/locales/zh_CN/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "连接进行中...",
"connection-successful": "连接成功",
"disabled-by-admin": "已被管理员禁用",
+ "hash-calculation-disabled": "哈希计算已禁用",
+ "hasheous-requires-hashes": "Hasheous 需要文件哈希",
"hashes": "哈希",
"hashes-desc": "重新计算选定平台的哈希值",
"hashes-disabled-tooltip": "哈希计算已禁用。
哈希值(MD5、SHA1、CRC32)是精确识别 ROM 文件的唯一指纹。
没有它们,Hasheous 和 RetroAchievements 无法将游戏与其数据库进行匹配,但扫描速度会更快。",
"hashes-enabled-tooltip": "文件哈希计算已启用。
将计算哈希值(MD5、SHA1、CRC32)为每个 ROM 文件创建唯一指纹。
这使得 Hasheous 和 RetroAchievements 能够在其数据库中准确识别游戏。",
- "hash-calculation-disabled": "哈希计算已禁用",
- "hasheous-requires-hashes": "Hasheous 需要文件哈希",
+ "launchbox-remote": "Launchbox",
"manage-library": "管理游戏库",
"metadata-sources": "元数据源",
"new-platforms": "新平台",
diff --git a/frontend/src/locales/zh_TW/scan.json b/frontend/src/locales/zh_TW/scan.json
index 8a11d6e35..4b8750951 100644
--- a/frontend/src/locales/zh_TW/scan.json
+++ b/frontend/src/locales/zh_TW/scan.json
@@ -13,12 +13,13 @@
"connection-in-progress": "連線進行中...",
"connection-successful": "連線成功",
"disabled-by-admin": "已被管理員禁用",
+ "hash-calculation-disabled": "雜湊計算已停用",
+ "hasheous-requires-hashes": "Hasheous 需要檔案哈希",
"hashes": "雜湊",
"hashes-desc": "重新計算選定平台的雜湊值",
"hashes-disabled-tooltip": "哈希計算已停用。
哈希值(MD5、SHA1、CRC32)是唯一識別 ROM 檔案的數字指紋。
沒有它們,Hasheous 和 RetroAchievements 無法將遊戲與資料庫匹配,但掃描速度會更快。",
"hashes-enabled-tooltip": "哈希計算已啟用。
將計算哈希值(MD5、SHA1、CRC32)為每個 ROM 檔案建立唯一指紋。
這使 Hasheous 和 RetroAchievements 能夠準確識別其資料庫中的遊戲。",
- "hash-calculation-disabled": "雜湊計算已停用",
- "hasheous-requires-hashes": "Hasheous 需要檔案哈希",
+ "launchbox-remote": "Launchbox",
"manage-library": "管理遊戲庫",
"metadata-sources": "元數據來源",
"new-platforms": "新平台",
diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue
index b257ee32a..3c1b35da7 100644
--- a/frontend/src/views/Scan.vue
+++ b/frontend/src/views/Scan.vue
@@ -17,6 +17,8 @@ import storeScanning from "@/stores/scanning";
import { platformCategoryToIcon } from "@/utils";
const LOCAL_STORAGE_METADATA_SOURCES_KEY = "scan.metadataSources";
+const LOCAL_STORAGE_LAUNCHBOX_REMOTE_ENABLED_KEY =
+ "scan.launchboxRemoteEnabled";
const { t } = useI18n();
const { xs, smAndDown } = useDisplay();
const scanningStore = storeScanning();
@@ -67,12 +69,20 @@ const storedMetadataSources = useLocalStorage(
LOCAL_STORAGE_METADATA_SOURCES_KEY,
[] as string[],
);
+const launchboxRemoteEnabled = useLocalStorage(
+ LOCAL_STORAGE_LAUNCHBOX_REMOTE_ENABLED_KEY,
+ true,
+);
const metadataSources = ref(
metadataOptions.value.filter(
(m) => storedMetadataSources.value.includes(m.value) && !m.disabled,
) || heartbeat.getEnabledMetadataOptions(),
);
+const isLaunchboxSelected = computed(() =>
+ metadataSources.value.some((s) => s.value === "launchbox"),
+);
+
watch(metadataOptions, (newOptions) => {
// Remove any sources that are now disabled
metadataSources.value = metadataSources.value.filter((s) =>
@@ -138,6 +148,7 @@ async function scan() {
platforms: platformsToScan.value,
type: scanType.value,
apis: metadataSources.value.map((s) => s.value),
+ launchbox_remote_enabled: launchboxRemoteEnabled.value,
});
}
@@ -363,6 +374,24 @@ async function stopScan() {
+
+
+
+
+ Remote
+
+
+
+