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:
Georges-Antoine Assi
2026-04-11 22:57:20 -04:00
parent 686c609b3a
commit 522df9d31a
23 changed files with 850 additions and 2 deletions

View 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

View 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"

View 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)

View File

@@ -342,6 +342,7 @@ class ConfigManager:
"igdb",
"moby",
"ss",
"libretro",
"ra",
"launchbox",
"gamelist",

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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()

View 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]

View File

@@ -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

View File

@@ -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))

View 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

View File

@@ -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);

View File

@@ -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;

View File

@@ -15,5 +15,6 @@ export type MetadataSourcesDict = {
TGDB_API_ENABLED: boolean;
FLASHPOINT_API_ENABLED: boolean;
HLTB_API_ENABLED: boolean;
LIBRETRO_API_ENABLED: boolean;
};

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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[] {