From 11b46494a7971f1e3546543fca3f4da13aadedaf Mon Sep 17 00:00:00 2001 From: SaraVieira Date: Sat, 31 Aug 2024 19:09:20 +0100 Subject: [PATCH] start work on retro achievements --- .../versions/026_add_ra_id_to_platforms.py | 34 ++ backend/config/__init__.py | 4 + backend/endpoints/heartbeat.py | 2 + backend/endpoints/responses/heartbeat.py | 1 + backend/endpoints/sockets/scan.py | 2 +- backend/handler/metadata/__init__.py | 2 + backend/handler/metadata/ra_handler.py | 423 ++++++++++++++++++ backend/handler/scan_handler.py | 17 +- backend/models/platform.py | 1 + backend/models/rom.py | 1 + env.template | 4 + examples/docker-compose.example.yml | 2 + frontend/assets/scrappers/ra.webp | Bin 0 -> 12338 bytes .../models/MetadataSourcesDict.ts | 1 + frontend/src/views/Scan.vue | 12 +- 15 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/026_add_ra_id_to_platforms.py create mode 100644 backend/handler/metadata/ra_handler.py create mode 100644 frontend/assets/scrappers/ra.webp diff --git a/backend/alembic/versions/026_add_ra_id_to_platforms.py b/backend/alembic/versions/026_add_ra_id_to_platforms.py new file mode 100644 index 000000000..67c33ef45 --- /dev/null +++ b/backend/alembic/versions/026_add_ra_id_to_platforms.py @@ -0,0 +1,34 @@ +"""add ra_id to platforms + +Revision ID: 026_add_ra_id_to_platforms +Revises: 0025_roms_hashes +Create Date: 2024-08-31 18:48:49.772416 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "026_add_ra_id_to_platforms" +down_revision = "0025_roms_hashes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.add_column(sa.Column("ra_id", sa.Integer(), nullable=True)) + pass + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column(sa.Column("ra_id", sa.Integer(), nullable=True)) + pass + + +def downgrade() -> None: + with op.batch_alter_table("platforms", schema=None) as batch_op: + batch_op.drop_column("ra_id") + pass + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_column("ra_id") + pass diff --git a/backend/config/__init__.py b/backend/config/__init__.py index d48134a00..e24a7a38c 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -51,6 +51,10 @@ STEAMGRIDDB_API_KEY: Final = os.environ.get("STEAMGRIDDB_API_KEY", "") # MOBYGAMES MOBYGAMES_API_KEY: Final = os.environ.get("MOBYGAMES_API_KEY", "") +# RETROACHIEVEMENTS +RETROACHIEVEMENTS_API_KEY: Final = os.environ.get("RETROACHIEVEMENTS_API_KEY", "") +RETROACHIEVEMENTS_USERNAME: Final = os.environ.get("RETROACHIEVEMENTS_USERNAME", "") + # DB DRIVERS ROMM_DB_DRIVER: Final = os.environ.get("ROMM_DB_DRIVER", "mariadb") diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index e6e81f805..6dbb8d062 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -13,6 +13,7 @@ from handler.database import db_user_handler from handler.filesystem import fs_platform_handler from handler.metadata.igdb_handler import IGDB_API_ENABLED from handler.metadata.moby_handler import MOBY_API_ENABLED +from handler.metadata.ra_handler import RA_API_ENABLED from handler.metadata.sgdb_handler import STEAMGRIDDB_API_ENABLED from utils import get_version from utils.router import APIRouter @@ -36,6 +37,7 @@ def heartbeat() -> HeartbeatResponse: "IGDB_API_ENABLED": IGDB_API_ENABLED, "MOBY_API_ENABLED": MOBY_API_ENABLED, "STEAMGRIDDB_ENABLED": STEAMGRIDDB_API_ENABLED, + "RA_API_ENABLED": RA_API_ENABLED, }, "FS_PLATFORMS": fs_platform_handler.get_platforms(), "WATCHER": { diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index 6a995dc16..c66936f0d 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -20,6 +20,7 @@ class MetadataSourcesDict(TypedDict): IGDB_API_ENABLED: bool MOBY_API_ENABLED: bool STEAMGRIDDB_ENABLED: bool + RA_API_ENABLED: bool class EmulationDict(TypedDict): diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 2af19497c..4abb2f44f 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -120,7 +120,7 @@ async def scan_platforms( roms_ids = [] if not metadata_sources: - metadata_sources = ["igdb", "moby"] + metadata_sources = ["igdb", "moby", "retro_achievements"] sm = _get_socket_manager() diff --git a/backend/handler/metadata/__init__.py b/backend/handler/metadata/__init__.py index 65bd6ee37..0b7488f38 100644 --- a/backend/handler/metadata/__init__.py +++ b/backend/handler/metadata/__init__.py @@ -1,7 +1,9 @@ from .igdb_handler import IGDBBaseHandler from .moby_handler import MobyGamesHandler +from .ra_handler import RetroAchievementsHandler from .sgdb_handler import SGDBBaseHandler meta_igdb_handler = IGDBBaseHandler() meta_moby_handler = MobyGamesHandler() meta_sgdb_handler = SGDBBaseHandler() +meta_ra_handler = RetroAchievementsHandler() diff --git a/backend/handler/metadata/ra_handler.py b/backend/handler/metadata/ra_handler.py new file mode 100644 index 000000000..7e8663497 --- /dev/null +++ b/backend/handler/metadata/ra_handler.py @@ -0,0 +1,423 @@ +import asyncio +import http +import re +from typing import Final, NotRequired, TypedDict +from urllib.parse import quote + +import httpx +import pydash +import yarl +from config import RETROACHIEVEMENTS_API_KEY, RETROACHIEVEMENTS_USERNAME +from fastapi import HTTPException, status +from logger.logger import log +from unidecode import unidecode as uc +from utils.context import ctx_httpx_client + +from .base_hander import ( + PS2_OPL_REGEX, + SONY_SERIAL_REGEX, + SWITCH_PRODUCT_ID_REGEX, + SWITCH_TITLEDB_REGEX, + MetadataHandler, +) + +# Used to display the RetroAchievements API status in the frontend +RA_API_ENABLED: Final = bool(RETROACHIEVEMENTS_API_KEY) and bool( + RETROACHIEVEMENTS_USERNAME +) + +PS1_MOBY_ID: Final = 6 +PS2_MOBY_ID: Final = 7 +PSP_MOBY_ID: Final = 46 +SWITCH_MOBY_ID: Final = 203 +ARCADE_MOBY_IDS: Final = [143, 36] + + +class RAGamesPlatform(TypedDict): + slug: str + moby_id: int | None + name: NotRequired[str] + + +class RAMetadataPlatform(TypedDict): + ra_id: int + name: str + + +class RAMetadata(TypedDict): + moby_score: str + genres: list[str] + alternate_titles: list[str] + platforms: list[RAMetadataPlatform] + + +class RAGameRom(TypedDict): + moby_id: int | None + slug: NotRequired[str] + name: NotRequired[str] + summary: NotRequired[str] + url_cover: NotRequired[str] + url_screenshots: NotRequired[list[str]] + moby_metadata: NotRequired[RAMetadata] + + +def extract_metadata_from_moby_rom(rom: dict) -> RAMetadata: + return RAMetadata( + { + "moby_score": str(rom.get("moby_score", "")), + "genres": rom.get("genres.genre_name", []), + "alternate_titles": rom.get("alternate_titles.title", []), + "platforms": [ + { + "moby_id": p["platform_id"], + "name": p["platform_name"], + } + for p in rom.get("platforms", []) + ], + } + ) + + +class RetroAchievementsHandler(MetadataHandler): + def __init__(self) -> None: + self.platform_url = ( + "https://retroachievements.org/API/API_GetGameList.php?&h=1&f=1" + ) + self.games_url = "https://api.mobygames.com/v1/games" + + async def _request(self, url: str, timeout: int = 120) -> dict: + httpx_client = ctx_httpx_client.get() + authorized_url = ( + yarl.URL(url) + .update_query(z=RETROACHIEVEMENTS_USERNAME) + .update_query(y=RETROACHIEVEMENTS_API_KEY) + ) + try: + res = await httpx_client.get(str(authorized_url), timeout=timeout) + res.raise_for_status() + return res.json() + except httpx.NetworkError as exc: + log.critical( + "Connection error: can't connect to RetroAchievements", exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Can't connect to RetroAchievements, check your internet connection", + ) from exc + except httpx.HTTPStatusError as err: + if err.response.status_code == http.HTTPStatus.TOO_MANY_REQUESTS: + # Retry after 2 seconds if rate limit hit + await asyncio.sleep(2) + else: + # Log the error and return an empty dict if the request fails with a different code + log.error(err) + return {} + except httpx.TimeoutException: + # Retry the request once if it times out + pass + + try: + res = await httpx_client.get(url, timeout=timeout) + res.raise_for_status() + except (httpx.HTTPStatusError, httpx.TimeoutException) as err: + if ( + isinstance(err, httpx.HTTPStatusError) + and err.response.status_code == http.HTTPStatus.UNAUTHORIZED + ): + # Sometimes Mobygames returns 401 even with a valid API key + return {} + + # Log the error and return an empty dict if the request fails with a different code + log.error(err) + return {} + + return res.json() + + async def _search_rom(self, search_term: str, platform_moby_id: int) -> dict | None: + if not platform_moby_id: + return None + + search_term = uc(search_term) + url = yarl.URL(self.games_url).with_query( + platform=[platform_moby_id], + title=quote(search_term, safe="/ "), + ) + roms = (await self._request(str(url))).get("games", []) + + exact_matches = [ + rom + for rom in roms + if ( + rom["title"].lower() == search_term.lower() + or ( + self._normalize_exact_match(rom["title"]) + == self._normalize_exact_match(search_term) + ) + ) + ] + + return pydash.get(exact_matches or roms, "[0]", None) + + def get_platform(self, slug: str) -> RAGamesPlatform: + platform = SLUG_TO_RA_ID.get(slug.lower(), None) + + print(platform) + + if not platform: + return RAGamesPlatform(ra_id=None, slug=slug) + + return RAGamesPlatform( + ra_id=platform["id"], + slug=slug, + name=platform["name"], + ) + + async def get_rom(self, file_name: str, platform_moby_id: int) -> RAGameRom: + from handler.filesystem import fs_rom_handler + + if not RA_API_ENABLED: + return RAGameRom(moby_id=None) + + if not platform_moby_id: + return RAGameRom(moby_id=None) + + search_term = fs_rom_handler.get_file_name_with_no_tags(file_name) + fallback_rom = RAGameRom(moby_id=None) + + # Support for PS2 OPL filename format + match = PS2_OPL_REGEX.match(file_name) + if platform_moby_id == PS2_MOBY_ID and match: + search_term = await self._ps2_opl_format(match, search_term) + fallback_rom = RAGameRom(moby_id=None, name=search_term) + + # Support for sony serial filename format (PS, PS3, PS3) + match = SONY_SERIAL_REGEX.search(file_name, re.IGNORECASE) + if platform_moby_id == PS1_MOBY_ID and match: + search_term = await self._ps1_serial_format(match, search_term) + fallback_rom = RAGameRom(moby_id=None, name=search_term) + + if platform_moby_id == PS2_MOBY_ID and match: + search_term = await self._ps2_serial_format(match, search_term) + fallback_rom = RAGameRom(moby_id=None, name=search_term) + + if platform_moby_id == PSP_MOBY_ID and match: + search_term = await self._psp_serial_format(match, search_term) + fallback_rom = RAGameRom(moby_id=None, name=search_term) + + # Support for switch titleID filename format + match = SWITCH_TITLEDB_REGEX.search(file_name) + if platform_moby_id == SWITCH_MOBY_ID and match: + search_term, index_entry = await self._switch_titledb_format( + match, search_term + ) + if index_entry: + fallback_rom = RAGameRom( + moby_id=None, + name=index_entry["name"], + summary=index_entry.get("description", ""), + url_cover=index_entry.get("iconUrl", ""), + url_screenshots=index_entry.get("screenshots", None) or [], + ) + + # Support for switch productID filename format + match = SWITCH_PRODUCT_ID_REGEX.search(file_name) + if platform_moby_id == SWITCH_MOBY_ID and match: + search_term, index_entry = await self._switch_productid_format( + match, search_term + ) + if index_entry: + fallback_rom = RAGameRom( + moby_id=None, + name=index_entry["name"], + summary=index_entry.get("description", ""), + url_cover=index_entry.get("iconUrl", ""), + url_screenshots=index_entry.get("screenshots", None) or [], + ) + + # Support for MAME arcade filename format + if platform_moby_id in ARCADE_MOBY_IDS: + search_term = await self._mame_format(search_term) + fallback_rom = RAGameRom(moby_id=None, name=search_term) + + search_term = self.normalize_search_term(search_term) + res = await self._search_rom(search_term, platform_moby_id) + + # Split the search term since mobygames search doesn't support special caracters + if not res and ":" in search_term: + for term in search_term.split(":")[::-1]: + res = await self._search_rom(term, platform_moby_id) + if res: + break + + # Some MAME games have two titles split by a slash + if not res and "/" in search_term: + for term in search_term.split("/"): + res = await self._search_rom(term.strip(), platform_moby_id) + if res: + break + + if not res: + return fallback_rom + + rom = { + "moby_id": res["game_id"], + "name": res["title"], + "slug": res["moby_url"].split("/")[-1], + "summary": res.get("description", ""), + "url_cover": pydash.get(res, "sample_cover.image", ""), + "url_screenshots": [s["image"] for s in res.get("sample_screenshots", [])], + "moby_metadata": extract_metadata_from_moby_rom(res), + } + + return RAGameRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] + + async def get_rom_by_id(self, moby_id: int) -> RAGameRom: + if not RA_API_ENABLED: + return RAGameRom(moby_id=None) + + url = yarl.URL(self.games_url).with_query(id=moby_id) + roms = (await self._request(str(url))).get("games", []) + res = pydash.get(roms, "[0]", None) + + if not res: + return RAGameRom(moby_id=None) + + rom = { + "moby_id": res["game_id"], + "name": res["title"], + "slug": res["moby_url"].split("/")[-1], + "summary": res.get("description", None), + "url_cover": pydash.get(res, "sample_cover.image", None), + "url_screenshots": [s["image"] for s in res.get("sample_screenshots", [])], + "moby_metadata": extract_metadata_from_moby_rom(res), + } + + return RAGameRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] + + async def get_matched_roms_by_id(self, moby_id: int) -> list[RAGameRom]: + if not RA_API_ENABLED: + return [] + + rom = await self.get_rom_by_id(moby_id) + return [rom] if rom["moby_id"] else [] + + async def get_matched_roms_by_name( + self, search_term: str, platform_moby_id: int + ) -> list[RAGameRom]: + if not RA_API_ENABLED: + return [] + + if not platform_moby_id: + return [] + + search_term = uc(search_term) + url = yarl.URL(self.games_url).with_query( + platform=[platform_moby_id], title=quote(search_term, safe="/ ") + ) + matched_roms = (await self._request(str(url))).get("games", []) + + return [ + RAGameRom( # type: ignore[misc] + { + k: v + for k, v in { + "moby_id": rom["game_id"], + "name": rom["title"], + "slug": rom["moby_url"].split("/")[-1], + "summary": rom.get("description", ""), + "url_cover": pydash.get(rom, "sample_cover.image", ""), + "url_screenshots": [ + s["image"] for s in rom.get("sample_screenshots", []) + ], + "moby_metadata": extract_metadata_from_moby_rom(rom), + }.items() + if v + } + ) + for rom in matched_roms + ] + + +class SlugToMobyId(TypedDict): + id: int + name: str + + +SLUG_TO_RA_ID: dict[str, SlugToMobyId] = { + "3do": {"id": 43, "name": "3DO"}, + "cpc": {"id": 37, "name": "Amstrad CPC"}, + "acpc": {"id": 37, "name": "Amstrad CPC"}, + "apple2": {"id": 38, "name": "Apple II"}, + "appleii": {"id": 38, "name": "Apple II"}, + "arcade": {"id": 27, "name": "Arcade"}, + "arcadia-2001": {"id": 73, "name": "Arcadia 2001"}, + "arduboy": {"id": 71, "name": "Arduboy"}, + "atari-2600": {"id": 25, "name": "Atari 2600"}, + "atari2600": {"id": 25, "name": "Atari 2600"}, # IGDB + "atari-7800": {"id": 51, "name": "Atari 7800"}, + "atari7800": {"id": 51, "name": "Atari 7800"}, # IGDB + "atari-jaguar-cd": {"id": 77, "name": "Atari Jaguar CD"}, + "colecovision": {"id": 44, "name": "ColecoVision"}, + "dreamcast": {"id": 40, "name": "Dreamcast"}, + "dc": {"id": 40, "name": "Dreamcast"}, # IGDB + "gameboy": {"id": 4, "name": "Game Boy"}, + "gb": {"id": 4, "name": "Game Boy"}, # IGDB + "gameboy-advance": {"id": 5, "name": "Game Boy Advance"}, + "gba": {"id": 5, "name": "Game Boy Advance"}, # IGDB + "gameboy-color": {"id": 6, "name": "Game Boy Color"}, + "gbc": {"id": 6, "name": "Game Boy Color"}, # IGDB + "game-gear": {"id": 15, "name": "Game Gear"}, + "gamegear": {"id": 15, "name": "Game Gear"}, # IGDB + "gamecube": {"id": 16, "name": "GameCube"}, + "ngc": {"id": 14, "name": "GameCube"}, # IGDB + "genesis": {"id": 1, "name": "Genesis/Mega Drive"}, + "genesis-slash-megadrive": {"id": 16, "name": "Genesis/Mega Drive"}, + "intellivision": {"id": 45, "name": "Intellivision"}, + "jaguar": {"id": 17, "name": "Jaguar"}, + "lynx": {"id": 13, "name": "Lynx"}, + "msx": {"id": 29, "name": "MSX"}, + "mega-duck-slash-cougar-boy": {"id": 69, "name": "Mega Duck/Cougar Boy"}, + "nes": {"id": 7, "name": "NES"}, + "famicom": {"id": 7, "name": "NES"}, + "neo-geo-cd": {"id": 56, "name": "Neo Geo CD"}, + "neo-geo-pocket": {"id": 14, "name": "Neo Geo Pocket"}, + "neo-geo-pocket-color": {"id": 14, "name": "Neo Geo Pocket Color"}, + "n64": {"id": 2, "name": "Nintendo 64"}, + "nintendo-ds": {"id": 18, "name": "Nintendo DS"}, + "nds": {"id": 18, "name": "Nintendo DS"}, # IGDB + "nintendo-dsi": {"id": 78, "name": "Nintendo DSi"}, + "odyssey-2": {"id": 23, "name": "Odyssey 2"}, + "pc-8000": {"id": 47, "name": "PC-8000"}, + "pc-8800-series": {"id": 47, "name": "PC-8800 Series"}, # IGDB + "pc-fx": {"id": 49, "name": "PC-FX"}, + "psp": {"id": 41, "name": "PSP"}, + "playstation": {"id": 12, "name": "PlayStation"}, + "ps": {"id": 12, "name": "PlayStation"}, # IGDB + "ps2": {"id": 21, "name": "PlayStation 2"}, + "pokemon-mini": {"id": 24, "name": "Pokémon Mini"}, + "saturn": {"id": 39, "name": "Sega Saturn"}, + "sega-32x": {"id": 10, "name": "SEGA 32X"}, + "sega32": {"id": 10, "name": "SEGA 32X"}, # IGDB + "sega-cd": {"id": 9, "name": "SEGA CD"}, + "segacd": {"id": 9, "name": "SEGA CD"}, # IGDB + "sega-master-system": {"id": 11, "name": "SEGA Master System"}, + "sms": {"id": 11, "name": "SEGA Master System"}, # IGDB + "sg-1000": {"id": 33, "name": "SG-1000"}, + "snes": {"id": 3, "name": "SNES"}, + "turbografx-cd": {"id": 76, "name": "TurboGrafx CD"}, + "turbografx-16-slash-pc-engine-cd": {"id": 76, "name": "TurboGrafx CD"}, + "turbo-grafx": {"id": 8, "name": "TurboGrafx-16"}, + "turbografx16--1": {"id": 8, "name": "TurboGrafx-16"}, # IGDB + "vectrex": {"id": 26, "name": "Vectrex"}, + "virtual-boy": {"id": 28, "name": "Virtual Boy"}, + "virtualboy": {"id": 28, "name": "Virtual Boy"}, + "watara-slash-quickshot-supervision": { + "id": 63, + "name": "Watara/QuickShot Supervision", + }, + "wonderswan": {"id": 53, "name": "WonderSwan"}, + "wonderswan-color": {"id": 53, "name": "WonderSwan Color"}, +} + +# Reverse lookup +MOBY_ID_TO_SLUG = {v["id"]: k for k, v in SLUG_TO_RA_ID.items()} diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 13bc0937e..6887dbdcd 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -7,9 +7,10 @@ from config.config_manager import config_manager as cm from handler.database import db_platform_handler from handler.filesystem import fs_asset_handler, fs_firmware_handler, fs_rom_handler from handler.filesystem.roms_handler import FSRom -from handler.metadata import meta_igdb_handler, meta_moby_handler +from handler.metadata import meta_igdb_handler, meta_moby_handler, meta_ra_handler from handler.metadata.igdb_handler import IGDBPlatform, IGDBRom from handler.metadata.moby_handler import MobyGamesPlatform, MobyGamesRom +from handler.metadata.ra_handler import RAGameRom, RAGamesPlatform from logger.logger import log from models.assets import Save, Screenshot, State from models.firmware import Firmware @@ -62,7 +63,7 @@ async def scan_platform( log.info(f"· {fs_slug}") if metadata_sources is None: - metadata_sources = ["igdb", "moby"] + metadata_sources = ["igdb", "moby", "retro_achievements"] platform_attrs: dict[str, Any] = {} platform_attrs["fs_slug"] = fs_slug @@ -87,7 +88,6 @@ async def scan_platform( platform_attrs["slug"] = fs_slug except (KeyError, TypeError, AttributeError): platform_attrs["slug"] = fs_slug - igdb_platform = ( (await meta_igdb_handler.get_platform(platform_attrs["slug"])) if "igdb" in metadata_sources @@ -99,8 +99,16 @@ async def scan_platform( else MobyGamesPlatform(moby_id=None, slug=platform_attrs["slug"]) ) + ra_platform = ( + meta_ra_handler.get_platform(platform_attrs["slug"]) + if "retro_achievements" in metadata_sources + else RAGamesPlatform(ra_id=None, slug=platform_attrs["slug"]) + ) + platform_attrs["name"] = platform_attrs["slug"].replace("-", " ").title() - platform_attrs.update({**moby_platform, **igdb_platform}) # Reverse order + platform_attrs.update( + {**ra_platform, **moby_platform, **igdb_platform} + ) # Reverse order if platform_attrs["igdb_id"] or platform_attrs["moby_id"]: log.info( @@ -192,6 +200,7 @@ async def scan_rom( "igdb_id": rom.igdb_id, "moby_id": rom.moby_id, "sgdb_id": rom.sgdb_id, + "ra_id": rom.ra_id, "name": rom.name, "slug": rom.slug, "summary": rom.summary, diff --git a/backend/models/platform.py b/backend/models/platform.py index 2e0d9ffdc..98027aae0 100644 --- a/backend/models/platform.py +++ b/backend/models/platform.py @@ -18,6 +18,7 @@ class Platform(BaseModel): igdb_id: Mapped[int | None] sgdb_id: Mapped[int | None] moby_id: Mapped[int | None] + ra_id: Mapped[int | None] slug: Mapped[str] = mapped_column(String(length=50)) fs_slug: Mapped[str] = mapped_column(String(length=50)) name: Mapped[str] = mapped_column(String(length=400)) diff --git a/backend/models/rom.py b/backend/models/rom.py index 0f196a251..3373b367d 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -38,6 +38,7 @@ class Rom(BaseModel): igdb_id: Mapped[int | None] sgdb_id: Mapped[int | None] moby_id: Mapped[int | None] + ra_id: Mapped[int | None] file_name: Mapped[str] = mapped_column(String(length=450)) file_name_no_tags: Mapped[str] = mapped_column(String(length=450)) diff --git a/env.template b/env.template index 76dd91517..2577c4f63 100644 --- a/env.template +++ b/env.template @@ -14,6 +14,10 @@ MOBYGAMES_API_KEY= # SteamGridDB STEAMGRIDDB_API_KEY= +# RetroAchievements +RETROACHIEVEMENTS_USERNAME= +RETROACHIEVEMENTS_API_KEY= + # Database config DB_HOST=127.0.0.1 DB_PORT=3306 diff --git a/examples/docker-compose.example.yml b/examples/docker-compose.example.yml index f8fb7c448..108689561 100644 --- a/examples/docker-compose.example.yml +++ b/examples/docker-compose.example.yml @@ -20,6 +20,8 @@ services: - IGDB_CLIENT_SECRET= # https://api-docs.igdb.com/#account-creation - MOBYGAMES_API_KEY= # https://www.mobygames.com/info/api/ - STEAMGRIDDB_API_KEY # https://github.com/rommapp/romm/wiki/Generate-API-Keys#steamgriddb + - RETROACHIEVEMENTS_API_KEY # https://api-docs.retroachievements.org/#api-access + - RETROACHIEVEMENTS_USERNAME # https://api-docs.retroachievements.org/#api-access volumes: - romm_resources:/romm/resources # Resources fetched from IGDB (covers, screenshots, etc.) - romm_redis_data:/redis-data # Cached data for background tasks diff --git a/frontend/assets/scrappers/ra.webp b/frontend/assets/scrappers/ra.webp new file mode 100644 index 0000000000000000000000000000000000000000..1566039267739c977969490b07e98f2501e36a80 GIT binary patch literal 12338 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 [ logo_path: "/assets/scrappers/moby.png", disabled: !heartbeat.value.METADATA_SOURCES?.MOBY_API_ENABLED, }, + { + name: "RetroAchievements", + value: "retro_achievements", + logo_path: "/assets/scrappers/ra.webp", + disabled: !heartbeat.value.METADATA_SOURCES?.RA_API_ENABLED, + }, ]); // Use the computed metadataOptions to filter out disabled sources const metadataSources = ref(metadataOptions.value.filter((s) => !s.disabled)); @@ -311,7 +317,11 @@ async function stopScan() { {{ platform.name }}