mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
feat: add libretro thumbnails as an artwork source
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) <noreply@anthropic.com>
This commit is contained in:
132
backend/adapters/services/libretro_thumbnails.py
Normal file
132
backend/adapters/services/libretro_thumbnails.py
Normal file
@@ -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 <a> 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
|
||||
9
backend/adapters/services/libretro_thumbnails_types.py
Normal file
9
backend/adapters/services/libretro_thumbnails_types.py
Normal file
@@ -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"
|
||||
36
backend/alembic/versions/0077_add_libretro_id_to_roms.py
Normal file
36
backend/alembic/versions/0077_add_libretro_id_to_roms.py
Normal file
@@ -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)
|
||||
@@ -342,6 +342,7 @@ class ConfigManager:
|
||||
"igdb",
|
||||
"moby",
|
||||
"ss",
|
||||
"libretro",
|
||||
"ra",
|
||||
"launchbox",
|
||||
"gamelist",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
284
backend/handler/metadata/libretro_handler.py
Normal file
284
backend/handler/metadata/libretro_handler.py
Normal file
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
261
backend/tests/handler/metadata/test_libretro_handler.py
Normal file
261
backend/tests/handler/metadata/test_libretro_handler.py
Normal file
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,5 +15,6 @@ export type MetadataSourcesDict = {
|
||||
TGDB_API_ENABLED: boolean;
|
||||
FLASHPOINT_API_ENABLED: boolean;
|
||||
HLTB_API_ENABLED: boolean;
|
||||
LIBRETRO_API_ENABLED: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
location="top"
|
||||
class="tooltip"
|
||||
transition="fade-transition"
|
||||
:text="
|
||||
heartbeat.value.METADATA_SOURCES.LIBRETRO_API_ENABLED
|
||||
? 'Filter Libretro matches'
|
||||
: 'Libretro source is not enabled'
|
||||
"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-avatar
|
||||
v-bind="props"
|
||||
variant="text"
|
||||
class="ml-3 cursor-pointer opacity-40"
|
||||
:class="{
|
||||
'opacity-100':
|
||||
isLibretroFiltered &&
|
||||
heartbeat.value.METADATA_SOURCES.LIBRETRO_API_ENABLED,
|
||||
'cursor-not-allowed':
|
||||
!heartbeat.value.METADATA_SOURCES.LIBRETRO_API_ENABLED,
|
||||
}"
|
||||
size="30"
|
||||
rounded="1"
|
||||
@click="toggleSourceFilter('Libretro')"
|
||||
>
|
||||
<v-img src="/assets/scrappers/libretro.png" />
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-chip label class="ml-4 pr-0" size="small">
|
||||
{{ t("rom.results-found") }}:
|
||||
<v-chip color="primary" class="ml-2 px-2" label>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user