From 522df9d31a09053e17570df4e4132d7be1f2c243 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sat, 11 Apr 2026 22:57:20 -0400 Subject: [PATCH] feat: add libretro thumbnails as an artwork source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the libretro thumbnail repository as a first-class artwork source so region-correct box art (PAL/Europe, Japan, etc.) can be matched directly to ROM filenames, addressing rommapp/romm#3239. Implementation follows the SGDB handler pattern (artwork-only, no game metadata): MetadataSource enum entry, scan-time fetch wired into the SCAN_ARTWORK_PRIORITY loop, /search/roms integration, MatchRom dialog chip + cover selection, and a heartbeat flag. Matching is exact case-insensitive against the directory listing first (so a ROM named "(Europe)" lands on the (Europe) artwork), with a JaroWinkler fuzzy fallback at 0.8 that strips parenthetical tags from both sides. Listings are cached in Redis with a 24h TTL. `libretro_id` is persisted on the Rom model as the SHA1 hex of the matched libretro filename — stable across scans, distinct per region, indexed for lookup. Migration 0077 adds the column. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../adapters/services/libretro_thumbnails.py | 132 ++++++++ .../services/libretro_thumbnails_types.py | 9 + .../versions/0077_add_libretro_id_to_roms.py | 36 +++ backend/config/config_manager.py | 1 + backend/endpoints/heartbeat.py | 6 + backend/endpoints/responses/heartbeat.py | 1 + backend/endpoints/responses/rom.py | 1 + backend/endpoints/responses/search.py | 2 + backend/endpoints/roms/__init__.py | 9 + backend/endpoints/search.py | 13 + backend/handler/metadata/__init__.py | 2 + backend/handler/metadata/libretro_handler.py | 284 ++++++++++++++++++ backend/handler/scan_handler.py | 24 ++ backend/models/rom.py | 2 + .../handler/metadata/test_libretro_handler.py | 261 ++++++++++++++++ .../Body_update_rom_api_roms__id__put.ts | 1 + .../__generated__/models/DetailedRomSchema.ts | 1 + .../models/MetadataSourcesDict.ts | 1 + .../__generated__/models/SearchRomSchema.ts | 2 + .../__generated__/models/SimpleRomSchema.ts | 1 + .../common/Game/Dialog/MatchRom.vue | 53 +++- frontend/src/services/api/rom.ts | 1 + frontend/src/stores/heartbeat.ts | 9 + 23 files changed, 850 insertions(+), 2 deletions(-) create mode 100644 backend/adapters/services/libretro_thumbnails.py create mode 100644 backend/adapters/services/libretro_thumbnails_types.py create mode 100644 backend/alembic/versions/0077_add_libretro_id_to_roms.py create mode 100644 backend/handler/metadata/libretro_handler.py create mode 100644 backend/tests/handler/metadata/test_libretro_handler.py diff --git a/backend/adapters/services/libretro_thumbnails.py b/backend/adapters/services/libretro_thumbnails.py new file mode 100644 index 000000000..3c345c6a0 --- /dev/null +++ b/backend/adapters/services/libretro_thumbnails.py @@ -0,0 +1,132 @@ +import json +from html.parser import HTMLParser +from urllib.parse import quote, unquote + +import aiohttp +import aiohttp.client_exceptions +import yarl +from aiohttp.client import ClientTimeout + +from adapters.services.libretro_thumbnails_types import LibretroArtType +from handler.redis_handler import async_cache +from logger.logger import log +from utils import get_version +from utils.context import ctx_aiohttp_session + +LIBRETRO_THUMBNAIL_ROOT = "https://thumbnails.libretro.com" +LIBRETRO_LISTING_CACHE_KEY = "romm:libretro:listing" +LIBRETRO_LISTING_CACHE_TTL = 60 * 60 * 24 # 24 hours + + +class _AnchorHrefParser(HTMLParser): + """Collects filenames from href attributes of tags in an Apache autoindex page.""" + + def __init__(self) -> None: + super().__init__() + self.filenames: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + for name, value in attrs: + if name != "href" or not value: + continue + # Skip sort/parent/query links exposed by Apache autoindex. + if value.startswith(("?", "/", "#")) or ".." in value: + return + # Only keep image files — libretro stores PNGs. + decoded = unquote(value) + if decoded.lower().endswith((".png", ".jpg", ".jpeg", ".webp")): + self.filenames.append(decoded) + return + + +class LibretroThumbnailsService: + """Service to interact with the libretro thumbnail server. + + The server hosts Apache-style directory listings of per-system PNG art at + https://thumbnails.libretro.com/{System}/{Named_Boxarts|Named_Titles|Named_Logos|Named_Snaps}/ + """ + + def __init__(self, base_url: str | None = None) -> None: + self.url = yarl.URL(base_url or LIBRETRO_THUMBNAIL_ROOT) + + @staticmethod + def _cache_key(system_name: str, art_type: LibretroArtType) -> str: + return f"{LIBRETRO_LISTING_CACHE_KEY}:{system_name}:{art_type.value}" + + @staticmethod + def build_art_url( + system_name: str, art_type: LibretroArtType, filename: str + ) -> str: + subdir = f"{system_name}/{art_type.value}" + return ( + f"{LIBRETRO_THUMBNAIL_ROOT}/{quote(subdir, safe='/')}" + f"/{quote(filename, safe='')}" + ) + + async def fetch_listing( + self, system_name: str, art_type: LibretroArtType + ) -> list[str]: + """Return the list of art filenames for a given system + art type. + + Cached in Redis with a 24h TTL to avoid hammering the libretro server. + """ + cache_key = self._cache_key(system_name, art_type) + + cached = await async_cache.get(cache_key) + if cached: + try: + return json.loads(cached) + except json.JSONDecodeError: + log.warning("Invalid cached libretro listing for %s", cache_key) + + subdir = f"{system_name}/{art_type.value}" + url = f"{self.url}/{quote(subdir, safe='/')}/?F=2" + + aiohttp_session = ctx_aiohttp_session.get() + try: + res = await aiohttp_session.get( + url, + headers={"user-agent": f"RomM/{get_version()}"}, + timeout=ClientTimeout(total=60), + ) + res.raise_for_status() + body = await res.text() + except aiohttp.client_exceptions.ClientResponseError as exc: + log.warning( + "Libretro listing request failed with status %s for URL: %s", + exc.status, + url, + ) + return [] + except aiohttp.client_exceptions.ClientError as exc: + log.warning("Libretro listing request failed for URL %s: %s", url, exc) + return [] + + parser = _AnchorHrefParser() + parser.feed(body) + filenames = parser.filenames + + try: + await async_cache.set( + cache_key, json.dumps(filenames), ex=LIBRETRO_LISTING_CACHE_TTL + ) + except Exception as exc: # pragma: no cover - cache is best-effort + log.warning("Failed to cache libretro listing %s: %s", cache_key, exc) + + return filenames + + async def head(self) -> bool: + """Lightweight connectivity check against the thumbnail server root.""" + aiohttp_session = ctx_aiohttp_session.get() + try: + res = await aiohttp_session.head( + str(self.url), + headers={"user-agent": f"RomM/{get_version()}"}, + timeout=ClientTimeout(total=10), + allow_redirects=True, + ) + return res.status < 500 + except aiohttp.client_exceptions.ClientError: + return False diff --git a/backend/adapters/services/libretro_thumbnails_types.py b/backend/adapters/services/libretro_thumbnails_types.py new file mode 100644 index 000000000..46cb82d61 --- /dev/null +++ b/backend/adapters/services/libretro_thumbnails_types.py @@ -0,0 +1,9 @@ +import enum + + +@enum.unique +class LibretroArtType(enum.StrEnum): + BOX_ART = "Named_Boxarts" + TITLE_SCREEN = "Named_Titles" + LOGO = "Named_Logos" + SCREENSHOT = "Named_Snaps" diff --git a/backend/alembic/versions/0077_add_libretro_id_to_roms.py b/backend/alembic/versions/0077_add_libretro_id_to_roms.py new file mode 100644 index 000000000..64429099a --- /dev/null +++ b/backend/alembic/versions/0077_add_libretro_id_to_roms.py @@ -0,0 +1,36 @@ +"""Add libretro_id to roms + +Revision ID: 0077_add_libretro_id_to_roms +Revises: 0076_play_sessions +Create Date: 2026-04-11 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0077_add_libretro_id_to_roms" +down_revision = "0076_play_sessions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.add_column( + sa.Column("libretro_id", sa.String(length=64), nullable=True), + if_not_exists=True, + ) + batch_op.create_index( + "idx_roms_libretro_id", + ["libretro_id"], + unique=False, + if_not_exists=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_index("idx_roms_libretro_id", if_exists=True) + batch_op.drop_column("libretro_id", if_exists=True) diff --git a/backend/config/config_manager.py b/backend/config/config_manager.py index b6491db62..ae5b964e5 100644 --- a/backend/config/config_manager.py +++ b/backend/config/config_manager.py @@ -342,6 +342,7 @@ class ConfigManager: "igdb", "moby", "ss", + "libretro", "ra", "launchbox", "gamelist", diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index 19f02b635..236037eb5 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -37,6 +37,7 @@ from handler.metadata import ( meta_hltb_handler, meta_igdb_handler, meta_launchbox_handler, + meta_libretro_handler, meta_moby_handler, meta_playmatch_handler, meta_ra_handler, @@ -73,6 +74,7 @@ async def heartbeat() -> HeartbeatResponse: playmatch_enabled = meta_playmatch_handler.is_enabled() hltb_enabled = meta_hltb_handler.is_enabled() tgdb_enabled = meta_tgdb_handler.is_enabled() + libretro_enabled = meta_libretro_handler.is_enabled() return { "SYSTEM": { @@ -91,6 +93,7 @@ async def heartbeat() -> HeartbeatResponse: or tgdb_enabled or flashpoint_enabled or hltb_enabled + or libretro_enabled ), "IGDB_API_ENABLED": igdb_enabled, "SS_API_ENABLED": ss_enabled, @@ -103,6 +106,7 @@ async def heartbeat() -> HeartbeatResponse: "TGDB_API_ENABLED": tgdb_enabled, "FLASHPOINT_API_ENABLED": flashpoint_enabled, "HLTB_API_ENABLED": hltb_enabled, + "LIBRETRO_API_ENABLED": libretro_enabled, }, "FILESYSTEM": { "FS_PLATFORMS": await fs_platform_handler.get_platforms(), @@ -165,6 +169,8 @@ async def metadata_heartbeat(source: str) -> bool: return await meta_hltb_handler.heartbeat() case MetadataSource.GAMELIST: return await meta_gamelist_handler.heartbeat() + case MetadataSource.LIBRETRO: + return await meta_libretro_handler.heartbeat() case _: return False diff --git a/backend/endpoints/responses/heartbeat.py b/backend/endpoints/responses/heartbeat.py index 348ea5e3b..6e4c705f6 100644 --- a/backend/endpoints/responses/heartbeat.py +++ b/backend/endpoints/responses/heartbeat.py @@ -19,6 +19,7 @@ class MetadataSourcesDict(TypedDict): TGDB_API_ENABLED: bool FLASHPOINT_API_ENABLED: bool HLTB_API_ENABLED: bool + LIBRETRO_API_ENABLED: bool class FilesystemDict(TypedDict): diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 94b69e214..1653e771a 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -228,6 +228,7 @@ class RomSchema(BaseModel): flashpoint_id: str | None hltb_id: int | None gamelist_id: str | None + libretro_id: str | None platform_id: int platform_slug: str diff --git a/backend/endpoints/responses/search.py b/backend/endpoints/responses/search.py index ca4983cf8..d71188128 100644 --- a/backend/endpoints/responses/search.py +++ b/backend/endpoints/responses/search.py @@ -11,6 +11,7 @@ class SearchRomSchema(BaseModel): sgdb_id: int | None = None flashpoint_id: str | None = None launchbox_id: int | None = None + libretro_id: str | None = None platform_id: int name: str slug: str = "" @@ -21,6 +22,7 @@ class SearchRomSchema(BaseModel): sgdb_url_cover: str = "" flashpoint_url_cover: str = "" launchbox_url_cover: str = "" + libretro_url_cover: str = "" is_unidentified: bool is_identified: bool diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 914b1aead..d1d7be784 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -102,6 +102,7 @@ class RomUpdateForm(BaseModel): tgdb_id: str | None = Field(default=None, description="TheGamesDB game ID.") flashpoint_id: str | None = Field(default=None, description="Flashpoint game ID.") hltb_id: str | None = Field(default=None, description="HowLongToBeat game ID.") + libretro_id: str | None = Field(default=None, description="Libretro thumbnail ID.") raw_igdb_metadata: str | None = Field( default=None, description="Raw IGDB metadata as JSON string." ) @@ -176,6 +177,7 @@ async def parse_rom_update_form( tgdb_id: str | None = Form(default=None), flashpoint_id: str | None = Form(default=None), hltb_id: str | None = Form(default=None), + libretro_id: str | None = Form(default=None), raw_igdb_metadata: str | None = Form(default=None), raw_moby_metadata: str | None = Form(default=None), raw_ss_metadata: str | None = Form(default=None), @@ -203,6 +205,7 @@ async def parse_rom_update_form( "tgdb_id": tgdb_id, "flashpoint_id": flashpoint_id, "hltb_id": hltb_id, + "libretro_id": libretro_id, "raw_igdb_metadata": raw_igdb_metadata, "raw_moby_metadata": raw_moby_metadata, "raw_ss_metadata": raw_ss_metadata, @@ -1087,6 +1090,7 @@ async def update_rom( "tgdb_id": None, "flashpoint_id": None, "hltb_id": None, + "libretro_id": None, "name": rom.fs_name, "summary": "", "url_screenshots": [], @@ -1167,6 +1171,11 @@ async def update_rom( if "hltb_id" in provided_fields else rom.hltb_id ), + "libretro_id": ( + form_data.libretro_id or None + if "libretro_id" in provided_fields + else rom.libretro_id + ), } # Add raw metadata parsing diff --git a/backend/endpoints/search.py b/backend/endpoints/search.py index 26b3462d2..645828dc0 100644 --- a/backend/endpoints/search.py +++ b/backend/endpoints/search.py @@ -11,6 +11,7 @@ from handler.metadata import ( meta_flashpoint_handler, meta_igdb_handler, meta_launchbox_handler, + meta_libretro_handler, meta_moby_handler, meta_sgdb_handler, meta_ss_handler, @@ -18,6 +19,7 @@ from handler.metadata import ( from handler.metadata.flashpoint_handler import FlashpointRom from handler.metadata.igdb_handler import IGDBRom from handler.metadata.launchbox_handler.types import LaunchboxRom +from handler.metadata.libretro_handler import LibretroRom from handler.metadata.moby_handler import MobyGamesRom from handler.metadata.sgdb_handler import SGDBRom from handler.metadata.ss_handler import SSRom @@ -94,6 +96,7 @@ async def search_rom( ss_matched_roms: list[SSRom] = [] flashpoint_matched_roms: list[FlashpointRom] = [] launchbox_matched_roms: list[LaunchboxRom] = [] + libretro_matched_roms: list[LibretroRom] = [] if search_by.lower() == "id": try: @@ -121,6 +124,7 @@ async def search_rom( ss_matched_roms, flashpoint_matched_roms, launchbox_matched_roms, + libretro_matched_roms, ) = await asyncio.gather( meta_igdb_handler.get_matched_roms_by_name( search_term, get_main_platform_igdb_id(rom.platform) @@ -137,6 +141,9 @@ async def search_rom( meta_launchbox_handler.get_matched_roms_by_name( search_term, rom.platform.slug ), + meta_libretro_handler.get_matched_roms_by_name( + search_term, rom.platform.slug + ), ) merged_dict: dict[str, dict] = {} @@ -167,6 +174,12 @@ async def search_rom( "launchbox_url_cover", ), MetadataSource.SS: (ss_matched_roms, meta_ss_handler, "ss_id", "ss_url_cover"), + MetadataSource.LIBRETRO: ( + libretro_matched_roms, + meta_libretro_handler, + "libretro_id", + "libretro_url_cover", + ), } ordered_sources = get_priority_ordered_metadata_sources( diff --git a/backend/handler/metadata/__init__.py b/backend/handler/metadata/__init__.py index f09657f25..7a64e6e0c 100644 --- a/backend/handler/metadata/__init__.py +++ b/backend/handler/metadata/__init__.py @@ -4,6 +4,7 @@ from .hasheous_handler import HasheousHandler from .hltb_handler import HLTBHandler from .igdb_handler import IGDBHandler from .launchbox_handler import LaunchboxHandler +from .libretro_handler import LibretroHandler from .moby_handler import MobyGamesHandler from .playmatch_handler import PlaymatchHandler from .ra_handler import RAHandler @@ -18,6 +19,7 @@ meta_sgdb_handler = SGDBBaseHandler() meta_ra_handler = RAHandler() meta_playmatch_handler = PlaymatchHandler() meta_launchbox_handler = LaunchboxHandler() +meta_libretro_handler = LibretroHandler() meta_hasheous_handler = HasheousHandler() meta_tgdb_handler = TGDBHandler() meta_flashpoint_handler = FlashpointHandler() diff --git a/backend/handler/metadata/libretro_handler.py b/backend/handler/metadata/libretro_handler.py new file mode 100644 index 000000000..a06b86a7e --- /dev/null +++ b/backend/handler/metadata/libretro_handler.py @@ -0,0 +1,284 @@ +import hashlib +import os +import re +from typing import Final, NotRequired, TypedDict + +from adapters.services.libretro_thumbnails import ( + LIBRETRO_THUMBNAIL_ROOT, + LibretroThumbnailsService, +) +from adapters.services.libretro_thumbnails_types import LibretroArtType +from logger.logger import log + +from .base_handler import MetadataHandler, UniversalPlatformSlug + +# Mapping of RomM UniversalPlatformSlug values to libretro thumbnail +# directory names (https://thumbnails.libretro.com/). +LIBRETRO_PLATFORM_LIST: Final[dict[UniversalPlatformSlug, str]] = { + UniversalPlatformSlug.ADVENTURE_VISION: "Entex - Adventure Vision", + UniversalPlatformSlug.AMIGA: "Commodore - Amiga", + UniversalPlatformSlug.AMIGA_CD32: "Commodore - Amiga", + UniversalPlatformSlug.ACPC: "Amstrad - CPC", + UniversalPlatformSlug.ATARI2600: "Atari - 2600", + UniversalPlatformSlug.ATARI5200: "Atari - 5200", + UniversalPlatformSlug.ATARI7800: "Atari - 7800", + UniversalPlatformSlug.ATARI_ST: "Atari - ST", + UniversalPlatformSlug.JAGUAR: "Atari - Jaguar", + UniversalPlatformSlug.LYNX: "Atari - Lynx", + UniversalPlatformSlug.WONDERSWAN: "Bandai - WonderSwan", + UniversalPlatformSlug.WONDERSWAN_COLOR: "Bandai - WonderSwan Color", + UniversalPlatformSlug.COLECOVISION: "Coleco - ColecoVision", + UniversalPlatformSlug.C64: "Commodore - 64", + UniversalPlatformSlug.VIC_20: "Commodore - VIC-20", + UniversalPlatformSlug.DOS: "DOS", + UniversalPlatformSlug.FAIRCHILD_CHANNEL_F: "Fairchild - Channel F", + UniversalPlatformSlug.VECTREX: "GCE - Vectrex", + UniversalPlatformSlug.ODYSSEY_2: "Magnavox - Odyssey2", + UniversalPlatformSlug.INTELLIVISION: "Mattel - Intellivision", + UniversalPlatformSlug.MSX: "Microsoft - MSX", + UniversalPlatformSlug.MSX2: "Microsoft - MSX2", + UniversalPlatformSlug.XBOX: "Microsoft - XBOX", + UniversalPlatformSlug.PC_8800_SERIES: "NEC - PC Engine - TurboGrafx 16", + UniversalPlatformSlug.PC_FX: "NEC - PC-FX", + UniversalPlatformSlug.PC_9800_SERIES: "NEC - PC-98", + UniversalPlatformSlug.SUPERGRAFX: "NEC - PC Engine SuperGrafx", + UniversalPlatformSlug.TG16: "NEC - PC Engine - TurboGrafx 16", + UniversalPlatformSlug.TURBOGRAFX_CD: "NEC - PC Engine CD - TurboGrafx-CD", + UniversalPlatformSlug.FDS: "Nintendo - Family Computer Disk System", + UniversalPlatformSlug.GB: "Nintendo - Game Boy", + UniversalPlatformSlug.GBA: "Nintendo - Game Boy Advance", + UniversalPlatformSlug.GBC: "Nintendo - Game Boy Color", + UniversalPlatformSlug.NGC: "Nintendo - GameCube", + UniversalPlatformSlug.N64: "Nintendo - Nintendo 64", + UniversalPlatformSlug.N64DD: "Nintendo - Nintendo 64DD", + UniversalPlatformSlug.N3DS: "Nintendo - Nintendo 3DS", + UniversalPlatformSlug.NDS: "Nintendo - Nintendo DS", + UniversalPlatformSlug.NES: "Nintendo - Nintendo Entertainment System", + UniversalPlatformSlug.FAMICOM: "Nintendo - Nintendo Entertainment System", + UniversalPlatformSlug.POKEMON_MINI: "Nintendo - Pokemon Mini", + UniversalPlatformSlug.SATELLAVIEW: "Nintendo - Satellaview", + UniversalPlatformSlug.SUFAMI_TURBO: "Nintendo - Sufami Turbo", + UniversalPlatformSlug.SNES: "Nintendo - Super Nintendo Entertainment System", + UniversalPlatformSlug.SFAM: "Nintendo - Super Nintendo Entertainment System", + UniversalPlatformSlug.VIRTUALBOY: "Nintendo - Virtual Boy", + UniversalPlatformSlug.WII: "Nintendo - Wii", + UniversalPlatformSlug.WIIU: "Nintendo - Wii U", + UniversalPlatformSlug.SCUMMVM: "ScummVM", + UniversalPlatformSlug.SEGA32: "Sega - 32X", + UniversalPlatformSlug.DC: "Sega - Dreamcast", + UniversalPlatformSlug.GAMEGEAR: "Sega - Game Gear", + UniversalPlatformSlug.GENESIS: "Sega - Mega Drive - Genesis", + UniversalPlatformSlug.SEGACD: "Sega - Mega-CD - Sega CD", + UniversalPlatformSlug.SMS: "Sega - Master System - Mark III", + UniversalPlatformSlug.SG1000: "Sega - SG-1000", + UniversalPlatformSlug.SATURN: "Sega - Saturn", + UniversalPlatformSlug.X1: "Sharp - X1", + UniversalPlatformSlug.SHARP_X68000: "Sharp - X68000", + UniversalPlatformSlug.ZX81: "Sinclair - ZX 81", + UniversalPlatformSlug.ZXS: "Sinclair - ZX Spectrum", + UniversalPlatformSlug.NEOGEOAES: "SNK - Neo Geo", + UniversalPlatformSlug.NEOGEOMVS: "SNK - Neo Geo", + UniversalPlatformSlug.NEO_GEO_CD: "SNK - Neo Geo CD", + UniversalPlatformSlug.NEO_GEO_POCKET: "SNK - Neo Geo Pocket", + UniversalPlatformSlug.NEO_GEO_POCKET_COLOR: "SNK - Neo Geo Pocket Color", + UniversalPlatformSlug.PSX: "Sony - PlayStation", + UniversalPlatformSlug.PS2: "Sony - PlayStation 2", + UniversalPlatformSlug.PSP: "Sony - PlayStation Portable", + UniversalPlatformSlug.TIC_80: "TIC-80", + UniversalPlatformSlug.TOMY_TUTOR: "Tomy - Tutor", + UniversalPlatformSlug.SUPERVISION: "Watara - Supervision", +} + + +_PAREN_TAG_PATTERN = re.compile(r"\([^)]*\)") + + +class LibretroRom(TypedDict): + libretro_id: str | None + url_cover: NotRequired[str] + name: NotRequired[str] + + +def _remove_file_extension(filename: str) -> str: + return os.path.splitext(filename)[0] + + +def _strip_paren_tags(s: str) -> str: + """Remove parenthetical tags like (USA), (SGB Enhanced) from a filename.""" + return _PAREN_TAG_PATTERN.sub("", s).strip() + + +def libretro_id_for(filename: str) -> str: + """Deterministic ID for a libretro art filename. + + SHA1 hex of the full filename (extension included). Stable across scans + for the same matched art, fits in the `roms.libretro_id` column (40 chars + in a varchar(64)). + """ + return hashlib.sha1(filename.encode("utf-8")).hexdigest() + + +class LibretroHandler(MetadataHandler): + """Handler for libretro thumbnails (https://thumbnails.libretro.com). + + Artwork-only source, supplies box-art URLs but no game IDs, summaries, + or metadata. Follows the same integration pattern as SGDBBaseHandler. + """ + + def __init__(self) -> None: + self.service = LibretroThumbnailsService() + self.min_similarity_score: Final = 0.8 + + @classmethod + def is_enabled(cls) -> bool: + # Public server, no API key required. Always enabled. + return True + + async def heartbeat(self) -> bool: + try: + return await self.service.head() + except Exception as exc: + log.error("Error checking libretro thumbnails: %s", exc) + return False + + def _resolve_system(self, platform_slug: str) -> str | None: + try: + ups = UniversalPlatformSlug(platform_slug) + except ValueError: + return None + return LIBRETRO_PLATFORM_LIST.get(ups) + + def _find_exact_match(self, target: str, listing: list[str]) -> str | None: + """Case-insensitive exact match on filename (extension stripped).""" + target_lower = target.lower() + for filename in listing: + if _remove_file_extension(filename).lower() == target_lower: + return filename + return None + + def _find_fuzzy_match(self, target: str, listing: list[str]) -> str | None: + """Fuzzy fallback, strips parenthetical tags from both sides and uses + JaroWinkler via MetadataHandler.find_best_match.""" + if not listing: + return None + query = _strip_paren_tags(target) + # Build candidate list of tag-stripped names that map back to original filenames + stripped_to_original: dict[str, str] = {} + for filename in listing: + stripped = _strip_paren_tags(_remove_file_extension(filename)) + # Keep the first occurrence, libretro typically has one canonical + # entry per region; ties are acceptable since we fall back here. + stripped_to_original.setdefault(stripped, filename) + + match, _score = self.find_best_match( + query, + list(stripped_to_original.keys()), + min_similarity_score=self.min_similarity_score, + ) + if not match: + return None + return stripped_to_original[match] + + def _find_matching_art(self, fs_name: str, listing: list[str]) -> str | None: + # Libretro's filename convention replaces '&' with '_'. + cleaned = fs_name.replace("&", "_") + target = _remove_file_extension(cleaned) + + exact = self._find_exact_match(target, listing) + if exact: + return exact + + return self._find_fuzzy_match(target, listing) + + async def get_rom(self, fs_name: str, platform_slug: str) -> LibretroRom: + """Find box art for a ROM on the libretro thumbnail server. + + Scan-time callers use the returned `url_cover`. `name` is deliberately + omitted because libretro artwork filenames are not proper game names — + letting them overwrite a real name from IGDB/Moby would be wrong. + """ + system_name = self._resolve_system(platform_slug) + if not system_name: + return LibretroRom(libretro_id=None) + + listing = await self.service.fetch_listing(system_name, LibretroArtType.BOX_ART) + if not listing: + return LibretroRom(libretro_id=None) + + matched = self._find_matching_art(fs_name, listing) + if not matched: + return LibretroRom(libretro_id=None) + + url = LibretroThumbnailsService.build_art_url( + system_name, LibretroArtType.BOX_ART, matched + ) + return LibretroRom( + libretro_id=libretro_id_for(matched), + url_cover=url, + ) + + async def get_matched_roms_by_name( + self, search_term: str, platform_slug: str, limit: int = 25 + ) -> list[LibretroRom]: + """Return candidate libretro art matches for a search term. + + Used by the `/search/roms` endpoint. Returns all filenames whose + tag-stripped title matches the search term on either an exact or + fuzzy basis. + """ + system_name = self._resolve_system(platform_slug) + if not system_name: + return [] + + listing = await self.service.fetch_listing(system_name, LibretroArtType.BOX_ART) + if not listing: + return [] + + normalized_query = self.normalize_search_term( + search_term, remove_articles=False + ) + matches: list[LibretroRom] = [] + seen: set[str] = set() + + # Collect all filenames whose normalized tag-stripped title matches the + # normalized search term. The exposed `name` is the tag-stripped title + # so merge-by-normalized-name lines up with IGDB/Moby/etc. The full + # filename is preserved in `libretro_id` for traceability. + for filename in listing: + stripped = _strip_paren_tags(_remove_file_extension(filename)) + if not stripped: + continue + normalized_candidate = self.normalize_search_term( + stripped, remove_articles=False + ) + if normalized_candidate != normalized_query: + continue + if filename in seen: + continue + seen.add(filename) + matches.append( + LibretroRom( + libretro_id=libretro_id_for(filename), + url_cover=LibretroThumbnailsService.build_art_url( + system_name, LibretroArtType.BOX_ART, filename + ), + name=stripped, + ) + ) + + if matches: + return matches[:limit] + + # No exact-title hits, fall back to a single fuzzy match. + fuzzy = self._find_fuzzy_match(search_term, listing) + if fuzzy: + matches.append( + LibretroRom( + libretro_id=libretro_id_for(fuzzy), + url_cover=LibretroThumbnailsService.build_art_url( + system_name, LibretroArtType.BOX_ART, fuzzy + ), + name=_strip_paren_tags(_remove_file_extension(fuzzy)), + ) + ) + return matches[:limit] diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index dbc074ffe..47f73e71e 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -16,6 +16,7 @@ from handler.metadata import ( meta_hltb_handler, meta_igdb_handler, meta_launchbox_handler, + meta_libretro_handler, meta_moby_handler, meta_playmatch_handler, meta_ra_handler, @@ -30,6 +31,7 @@ from handler.metadata.hltb_handler import HLTB_PLATFORM_LIST, HLTBRom from handler.metadata.igdb_handler import IGDB_PLATFORM_LIST, IGDBRom from handler.metadata.launchbox_handler.platforms import LAUNCHBOX_PLATFORM_LIST from handler.metadata.launchbox_handler.types import LaunchboxRom +from handler.metadata.libretro_handler import LIBRETRO_PLATFORM_LIST, LibretroRom from handler.metadata.moby_handler import MOBYGAMES_PLATFORM_LIST, MobyGamesRom from handler.metadata.playmatch_handler import PlaymatchRomMatch from handler.metadata.ra_handler import RA_PLATFORM_LIST, RAGameRom @@ -71,6 +73,7 @@ class MetadataSource(enum.StrEnum): FLASHPOINT = "flashpoint" # Flashpoint Project HLTB = "hltb" # HowLongToBeat GAMELIST = "gamelist" # ES-DE gamelist.xml + LIBRETRO = "libretro" # Libretro thumbnails def get_main_platform_igdb_id(platform: Platform): @@ -348,6 +351,7 @@ async def scan_rom( "gamelist_id": rom.gamelist_id, "flashpoint_id": rom.flashpoint_id, "hltb_id": rom.hltb_id, + "libretro_id": rom.libretro_id, "igdb_metadata": rom.igdb_metadata, "moby_metadata": rom.moby_metadata, "ss_metadata": rom.ss_metadata, @@ -510,6 +514,23 @@ async def scan_rom( return FlashpointRom(flashpoint_id=None) + async def fetch_libretro_rom() -> LibretroRom: + if ( + MetadataSource.LIBRETRO in metadata_sources + and platform.slug in LIBRETRO_PLATFORM_LIST + and ( + newly_added + or scan_type == ScanType.COMPLETE + or (scan_type == ScanType.UPDATE and rom.libretro_id) + or (scan_type == ScanType.UNMATCHED and not rom.libretro_id) + ) + ): + return await meta_libretro_handler.get_rom( + rom_attrs["fs_name"], platform.slug + ) + + return LibretroRom(libretro_id=None) + async def fetch_hltb_rom() -> HLTBRom: if ( MetadataSource.HLTB in metadata_sources @@ -689,6 +710,7 @@ async def scan_rom( flashpoint_handler_rom, hltb_handler_rom, gamelist_handler_rom, + libretro_handler_rom, ) = await asyncio.gather( fetch_igdb_rom(playmatch_hash_match, hasheous_hash_match), fetch_moby_rom(), @@ -699,6 +721,7 @@ async def scan_rom( fetch_flashpoint_rom(), fetch_hltb_rom(), fetch_gamelist_rom(), + fetch_libretro_rom(), ) metadata_handlers = { @@ -711,6 +734,7 @@ async def scan_rom( MetadataSource.FLASHPOINT: flashpoint_handler_rom, MetadataSource.HLTB: hltb_handler_rom, MetadataSource.GAMELIST: gamelist_handler_rom, + MetadataSource.LIBRETRO: libretro_handler_rom, } # Determine which metadata sources are available diff --git a/backend/models/rom.py b/backend/models/rom.py index 3ff51ec06..dd2ad2e7a 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -160,6 +160,7 @@ class Rom(BaseModel): flashpoint_id: Mapped[str | None] = mapped_column(String(length=100), default=None) hltb_id: Mapped[int | None] = mapped_column(Integer(), default=None) gamelist_id: Mapped[str | None] = mapped_column(String(length=100), default=None) + libretro_id: Mapped[str | None] = mapped_column(String(length=64), default=None) __table_args__ = ( Index("idx_roms_igdb_id", "igdb_id"), @@ -173,6 +174,7 @@ class Rom(BaseModel): Index("idx_roms_flashpoint_id", "flashpoint_id"), Index("idx_roms_hltb_id", "hltb_id"), Index("idx_roms_gamelist_id", "gamelist_id"), + Index("idx_roms_libretro_id", "libretro_id"), ) fs_name: Mapped[str] = mapped_column(String(length=FILE_NAME_MAX_LENGTH)) diff --git a/backend/tests/handler/metadata/test_libretro_handler.py b/backend/tests/handler/metadata/test_libretro_handler.py new file mode 100644 index 000000000..3f4564fb6 --- /dev/null +++ b/backend/tests/handler/metadata/test_libretro_handler.py @@ -0,0 +1,261 @@ +"""Tests for the libretro thumbnails metadata handler.""" + +import hashlib +from unittest.mock import AsyncMock, patch + +import pytest + +from adapters.services.libretro_thumbnails import LibretroThumbnailsService +from adapters.services.libretro_thumbnails_types import LibretroArtType +from handler.metadata.libretro_handler import ( + LIBRETRO_PLATFORM_LIST, + LibretroHandler, + _strip_paren_tags, + libretro_id_for, +) + +# Sample directory listing for Sony - PlayStation +PSX_LISTING = [ + "Castlevania - Symphony of the Night (USA).png", + "Castlevania - Symphony of the Night (Europe).png", + "Castlevania - Symphony of the Night (Japan).png", + "Final Fantasy VII (USA).png", + "Final Fantasy VII (Europe).png", + "Metal Gear Solid (USA).png", + "Sonic _ Knuckles Collection (USA).png", +] + + +@pytest.fixture +def handler() -> LibretroHandler: + return LibretroHandler() + + +# --------------------------------------------------------------------------- +# Pure utilities +# --------------------------------------------------------------------------- + + +def test_strip_paren_tags_removes_single_tag(): + assert _strip_paren_tags("Foo (USA)") == "Foo" + + +def test_strip_paren_tags_removes_multiple_tags(): + assert _strip_paren_tags("Foo (USA) (Rev 1)") == "Foo" + + +def test_strip_paren_tags_preserves_when_no_tags(): + assert _strip_paren_tags("Foo") == "Foo" + + +# --------------------------------------------------------------------------- +# Platform resolution +# --------------------------------------------------------------------------- + + +def test_resolve_system_supported_platform(handler: LibretroHandler): + # PSX is explicitly mapped to "Sony - PlayStation" + assert handler._resolve_system("psx") == "Sony - PlayStation" + + +def test_resolve_system_unsupported_platform(handler: LibretroHandler): + assert handler._resolve_system("not-a-real-platform") is None + + +def test_platform_list_uses_ups_keys(): + """Every entry in LIBRETRO_PLATFORM_LIST should be a UniversalPlatformSlug.""" + from handler.metadata.base_handler import UniversalPlatformSlug + + for key in LIBRETRO_PLATFORM_LIST.keys(): + assert isinstance(key, UniversalPlatformSlug) + + +# --------------------------------------------------------------------------- +# Matching logic +# --------------------------------------------------------------------------- + + +def test_find_matching_art_exact_case_insensitive(handler: LibretroHandler): + # The match should prefer the exact case-insensitive filename — region tag + # included — so a PAL ROM lands on the (Europe) artwork. + result = handler._find_matching_art( + "Castlevania - Symphony of the Night (Europe).iso", PSX_LISTING + ) + assert result == "Castlevania - Symphony of the Night (Europe).png" + + +def test_find_matching_art_different_case(handler: LibretroHandler): + result = handler._find_matching_art( + "CASTLEVANIA - SYMPHONY OF THE NIGHT (USA).bin", PSX_LISTING + ) + assert result == "Castlevania - Symphony of the Night (USA).png" + + +def test_find_matching_art_ampersand_normalized(handler: LibretroHandler): + # Libretro filenames replace `&` with `_`; ROM filename uses `&`. + result = handler._find_matching_art( + "Sonic & Knuckles Collection (USA).iso", PSX_LISTING + ) + assert result == "Sonic _ Knuckles Collection (USA).png" + + +def test_find_matching_art_fuzzy_fallback(handler: LibretroHandler): + # No exact match — ROM has an extra `(Rev 1)` tag that libretro doesn't + # index. Fuzzy fallback strips tags from both sides; the Europe variant + # is the first tag-stripped candidate and wins. + result = handler._find_matching_art( + "Castlevania - Symphony of the Night (Europe) (Rev 1).iso", PSX_LISTING + ) + assert result is not None + assert result.startswith("Castlevania - Symphony of the Night") + + +def test_find_matching_art_no_match(handler: LibretroHandler): + result = handler._find_matching_art( + "Completely Made Up Game Title XYZ.iso", PSX_LISTING + ) + assert result is None + + +# --------------------------------------------------------------------------- +# get_rom (scan path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_rom_unsupported_platform_returns_empty(handler: LibretroHandler): + result = await handler.get_rom("whatever.iso", "not-a-real-platform") + assert result == {"libretro_id": None} + + +@pytest.mark.asyncio +async def test_get_rom_matched_returns_cover_url(handler: LibretroHandler): + with patch.object( + handler.service, + "fetch_listing", + AsyncMock(return_value=PSX_LISTING), + ) as mock_fetch: + result = await handler.get_rom( + "Castlevania - Symphony of the Night (Europe).iso", "psx" + ) + + mock_fetch.assert_awaited_once() + # libretro_id is the SHA1 hex of the matched libretro filename + expected_id = libretro_id_for("Castlevania - Symphony of the Night (Europe).png") + assert result["libretro_id"] + assert result["libretro_id"] == expected_id + assert len(result["libretro_id"]) == 40 # SHA1 hex + assert result.get("url_cover", "").startswith("https://thumbnails.libretro.com/") + assert "Sony%20-%20PlayStation" in result.get("url_cover", "") + assert "Named_Boxarts" in result.get("url_cover", "") + assert "Europe" in result.get("url_cover", "") + # Scan path intentionally does not populate `name` so it doesn't + # overwrite a real IGDB name. + assert "name" not in result + + +def test_libretro_id_for_is_deterministic(): + f = "Castlevania - Symphony of the Night (Europe).png" + assert libretro_id_for(f) == libretro_id_for(f) + # Sanity-check the algorithm so the ID is stable across releases. + assert libretro_id_for(f) == hashlib.sha1(f.encode("utf-8")).hexdigest() + + +def test_libretro_id_for_distinguishes_regions(): + assert libretro_id_for( + "Castlevania - Symphony of the Night (USA).png" + ) != libretro_id_for("Castlevania - Symphony of the Night (Europe).png") + + +@pytest.mark.asyncio +async def test_get_rom_no_match_returns_empty(handler: LibretroHandler): + with patch.object( + handler.service, + "fetch_listing", + AsyncMock(return_value=PSX_LISTING), + ): + result = await handler.get_rom("Totally Unknown Title.iso", "psx") + + assert result == {"libretro_id": None} + + +@pytest.mark.asyncio +async def test_get_rom_empty_listing_returns_empty(handler: LibretroHandler): + with patch.object( + handler.service, + "fetch_listing", + AsyncMock(return_value=[]), + ): + result = await handler.get_rom("Whatever.iso", "psx") + + assert result == {"libretro_id": None} + + +# --------------------------------------------------------------------------- +# get_matched_roms_by_name (search path) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_matched_roms_returns_tag_stripped_name(handler: LibretroHandler): + with patch.object( + handler.service, + "fetch_listing", + AsyncMock(return_value=PSX_LISTING), + ): + results = await handler.get_matched_roms_by_name( + "Castlevania - Symphony of the Night", "psx" + ) + + assert len(results) == 3 # USA, Europe, Japan + ids = {r["libretro_id"] for r in results} + # Each region produces a distinct SHA1 of its own filename. + assert len(ids) == 3 + for r in results: + # Exposed name is tag-stripped so merging by normalized name lines up + # with IGDB/Moby etc. in /search/roms. + assert r.get("name", "") == "Castlevania - Symphony of the Night" + assert r["libretro_id"] + assert len(r["libretro_id"]) == 40 # SHA1 hex + assert r.get("url_cover", "").startswith("https://thumbnails.libretro.com/") + + +@pytest.mark.asyncio +async def test_get_matched_roms_unsupported_platform_empty(handler: LibretroHandler): + results = await handler.get_matched_roms_by_name("Foo", "not-a-real-platform") + assert results == [] + + +# --------------------------------------------------------------------------- +# LibretroThumbnailsService helpers +# --------------------------------------------------------------------------- + + +def test_build_art_url_encodes_spaces_and_special_chars(): + url = LibretroThumbnailsService.build_art_url( + "Sony - PlayStation", + LibretroArtType.BOX_ART, + "Castlevania - Symphony of the Night (Europe).png", + ) + assert url.startswith("https://thumbnails.libretro.com/") + assert "Sony%20-%20PlayStation" in url + assert "Named_Boxarts" in url + # Filename-level encoding is strict (space → %20, paren encoded). + assert "Castlevania%20-%20Symphony%20of%20the%20Night" in url + + +def test_art_type_values(): + assert LibretroArtType.BOX_ART.value == "Named_Boxarts" + assert LibretroArtType.TITLE_SCREEN.value == "Named_Titles" + assert LibretroArtType.LOGO.value == "Named_Logos" + assert LibretroArtType.SCREENSHOT.value == "Named_Snaps" + + +# --------------------------------------------------------------------------- +# Handler basics +# --------------------------------------------------------------------------- + + +def test_is_enabled_always_true(): + # No API key required — public server. + assert LibretroHandler.is_enabled() is True diff --git a/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts b/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts index 1ef0abda2..1a58f7fa6 100644 --- a/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts +++ b/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts @@ -17,6 +17,7 @@ export type Body_update_rom_api_roms__id__put = { tgdb_id?: (string | null); flashpoint_id?: (string | null); hltb_id?: (string | null); + libretro_id?: (string | null); raw_igdb_metadata?: (string | null); raw_moby_metadata?: (string | null); raw_ss_metadata?: (string | null); diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index d91ad9e09..2d484727d 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -34,6 +34,7 @@ export type DetailedRomSchema = { flashpoint_id: (string | null); hltb_id: (number | null); gamelist_id: (string | null); + libretro_id: (string | null); platform_id: number; platform_slug: string; platform_fs_slug: string; diff --git a/frontend/src/__generated__/models/MetadataSourcesDict.ts b/frontend/src/__generated__/models/MetadataSourcesDict.ts index c251a5526..11860fab2 100644 --- a/frontend/src/__generated__/models/MetadataSourcesDict.ts +++ b/frontend/src/__generated__/models/MetadataSourcesDict.ts @@ -15,5 +15,6 @@ export type MetadataSourcesDict = { TGDB_API_ENABLED: boolean; FLASHPOINT_API_ENABLED: boolean; HLTB_API_ENABLED: boolean; + LIBRETRO_API_ENABLED: boolean; }; diff --git a/frontend/src/__generated__/models/SearchRomSchema.ts b/frontend/src/__generated__/models/SearchRomSchema.ts index b72f97950..5484d63bf 100644 --- a/frontend/src/__generated__/models/SearchRomSchema.ts +++ b/frontend/src/__generated__/models/SearchRomSchema.ts @@ -10,6 +10,7 @@ export type SearchRomSchema = { sgdb_id?: (number | null); flashpoint_id?: (string | null); launchbox_id?: (number | null); + libretro_id?: (string | null); platform_id: number; name: string; slug?: string; @@ -20,6 +21,7 @@ export type SearchRomSchema = { sgdb_url_cover?: string; flashpoint_url_cover?: string; launchbox_url_cover?: string; + libretro_url_cover?: string; is_unidentified: boolean; is_identified: boolean; }; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 5ad546242..68b97669f 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -29,6 +29,7 @@ export type SimpleRomSchema = { flashpoint_id: (string | null); hltb_id: (number | null); gamelist_id: (string | null); + libretro_id: (string | null); platform_id: number; platform_slug: string; platform_fs_slug: string; diff --git a/frontend/src/components/common/Game/Dialog/MatchRom.vue b/frontend/src/components/common/Game/Dialog/MatchRom.vue index 1bf0115aa..7016c66e5 100644 --- a/frontend/src/components/common/Game/Dialog/MatchRom.vue +++ b/frontend/src/components/common/Game/Dialog/MatchRom.vue @@ -23,6 +23,7 @@ type MatchedSource = { | "Screenscraper" | "Flashpoint" | "Launchbox" + | "Libretro" | "SteamGridDB"; logo_path: string; }; @@ -52,6 +53,7 @@ const isMobyFiltered = ref(true); const isSSFiltered = ref(true); const isFlashpointFiltered = ref(true); const isLaunchboxFiltered = ref(true); +const isLibretroFiltered = ref(true); const computedAspectRatio = computed(() => { return galleryViewStore.getAspectRatio({ @@ -97,6 +99,11 @@ function toggleSourceFilter(source: MatchedSource["name"]) { heartbeat.value.METADATA_SOURCES.LAUNCHBOX_API_ENABLED ) { isLaunchboxFiltered.value = !isLaunchboxFiltered.value; + } else if ( + source == "Libretro" && + heartbeat.value.METADATA_SOURCES.LIBRETRO_API_ENABLED + ) { + isLibretroFiltered.value = !isLibretroFiltered.value; } filteredMatchedRoms.value = matchedRoms.value.filter((rom) => { @@ -105,7 +112,8 @@ function toggleSourceFilter(source: MatchedSource["name"]) { (rom.moby_id && isMobyFiltered.value) || (rom.ss_id && isSSFiltered.value) || (rom.flashpoint_id && isFlashpointFiltered.value) || - (rom.launchbox_id && isLaunchboxFiltered.value) + (rom.launchbox_id && isLaunchboxFiltered.value) || + (rom.libretro_id && isLibretroFiltered.value) ) { return true; } @@ -137,7 +145,8 @@ async function searchRom() { (rom.moby_id && isMobyFiltered.value) || (rom.ss_id && isSSFiltered.value) || (rom.flashpoint_id && isFlashpointFiltered.value) || - (rom.launchbox_id && isLaunchboxFiltered.value) + (rom.launchbox_id && isLaunchboxFiltered.value) || + (rom.libretro_id && isLibretroFiltered.value) ) { return true; } @@ -209,6 +218,13 @@ function showSources(matchedRom: SearchRom) { logo_path: "/assets/scrappers/launchbox.png", }); } + if (matchedRom.libretro_url_cover) { + sources.value.push({ + url_cover: matchedRom.libretro_url_cover, + name: "Libretro", + logo_path: "/assets/scrappers/libretro.png", + }); + } if (sources.value.length == 1) { selectedCover.value = sources.value[0]; } @@ -257,6 +273,7 @@ async function updateRom(selectedRom: SearchRom, urlCover: string | undefined) { moby_id: selectedRom.moby_id || null, flashpoint_id: selectedRom.flashpoint_id || null, launchbox_id: selectedRom.launchbox_id || null, + libretro_id: selectedRom.libretro_id || null, name: selectedRom.name || null, slug: selectedRom.slug || null, summary: selectedRom.summary || null, @@ -267,6 +284,7 @@ async function updateRom(selectedRom: SearchRom, urlCover: string | undefined) { selectedRom.moby_url_cover || selectedRom.flashpoint_url_cover || selectedRom.launchbox_url_cover || + selectedRom.libretro_url_cover || null, }; @@ -485,6 +503,37 @@ onBeforeUnmount(() => { + + + {{ t("rom.results-found") }}: diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 865fc38d1..f218b545b 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -447,6 +447,7 @@ async function updateRom({ ["hasheous_id", toFormIdValue(rom.hasheous_id)], ["tgdb_id", toFormIdValue(rom.tgdb_id)], ["hltb_id", toFormIdValue(rom.hltb_id)], + ["libretro_id", toFormIdValue(rom.libretro_id)], ]; if (rom.manual_metadata) { diff --git a/frontend/src/stores/heartbeat.ts b/frontend/src/stores/heartbeat.ts index f5d2573ac..464c0c34c 100644 --- a/frontend/src/stores/heartbeat.ts +++ b/frontend/src/stores/heartbeat.ts @@ -30,6 +30,7 @@ const defaultHeartbeat: Heartbeat = { TGDB_API_ENABLED: false, FLASHPOINT_API_ENABLED: false, HLTB_API_ENABLED: false, + LIBRETRO_API_ENABLED: false, }, FILESYSTEM: { FS_PLATFORMS: [], @@ -169,6 +170,14 @@ export default defineStore("heartbeat", { logo_path: "/assets/scrappers/esde.png", disabled: "", }, + { + name: "Libretro", + value: "libretro", + logo_path: "/assets/scrappers/libretro.png", + disabled: !this.value.METADATA_SOURCES?.LIBRETRO_API_ENABLED + ? i18n.global.t("scan.disabled-by-admin") + : "", + }, ]; }, getEnabledMetadataOptions(): MetadataOption[] {