rommapp_romm/backend/handler/metadata/libretro_handler.py
Georges-Antoine Assi 3ce0873931
fix
2026-04-12 16:09:36 -04:00

265 lines
9.7 KiB
Python

import asyncio
import hashlib
import os
import re
from typing import Final, NotRequired, TypedDict
from adapters.services.libretro_thumbnails import LibretroThumbnailsService
from adapters.services.libretro_thumbnails_types import LibretroArtType
from config.config_manager import MetadataMediaType
from config.config_manager import config_manager as cm
from logger.logger import log
from .base_handler import MetadataHandler
from .base_handler import UniversalPlatformSlug as UPS
_PAREN_TAG_PATTERN = re.compile(r"\([^)]*\)")
# Only fetched when the user has the corresponding MetadataMediaType in SCAN_MEDIA
_GATED_ART_TYPES: list[tuple[LibretroArtType, MetadataMediaType]] = [
(LibretroArtType.SCREENSHOT, MetadataMediaType.SCREENSHOT),
(LibretroArtType.TITLE_SCREEN, MetadataMediaType.TITLE_SCREEN),
(LibretroArtType.LOGO, MetadataMediaType.LOGO),
]
def get_preferred_media_types() -> list[MetadataMediaType]:
"""Get preferred media types from config."""
config = cm.get_config()
return [MetadataMediaType(media) for media in config.SCAN_MEDIA]
class LibretroPlatform(TypedDict):
slug: str
libretro_slug: str | None
class LibretroRom(TypedDict):
libretro_id: str | None
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[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"), usedforsecurity=False).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:
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 get_platform(self, slug: str) -> LibretroPlatform:
if slug in LIBRETRO_PLATFORM_LIST:
libretro_slug = LIBRETRO_PLATFORM_LIST[UPS(slug)]
return LibretroPlatform(slug=slug, libretro_slug=libretro_slug)
return LibretroPlatform(slug=slug, libretro_slug=None)
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 libretro artwork for a ROM.
Always fetches Named_Boxarts (used for `url_cover` and `libretro_id`).
Additionally fetches Named_Snaps / Named_Titles / Named_Logos when the
matching MetadataMediaType (SCREENSHOT / TITLE_SCREEN / LOGO) is in
SCAN_MEDIA, and appends any matches to `url_screenshots` so the
scan_handler artwork loop picks them up. `name` is deliberately
omitted — libretro artwork filenames aren't proper game names.
"""
platform = self.get_platform(platform_slug)
if not platform or not platform["libretro_slug"]:
return LibretroRom(libretro_id=None)
system_name = platform["libretro_slug"]
preferred = get_preferred_media_types()
extra_art_types = [art for art, media in _GATED_ART_TYPES if media in preferred]
art_types = [LibretroArtType.BOX_ART, *extra_art_types]
listings = await asyncio.gather(
*(self.service.fetch_listing(system_name, t) for t in art_types)
)
box_listing = listings[0]
if not box_listing:
return LibretroRom(libretro_id=None)
matched = self._find_matching_art(fs_name, box_listing)
if not matched:
return LibretroRom(libretro_id=None)
url_cover = LibretroThumbnailsService.build_art_url(
system_name, LibretroArtType.BOX_ART, matched
)
url_screenshots: list[str] = []
for art_type, listing in zip(extra_art_types, listings[1:], strict=False):
if not listing:
continue
extra = self._find_matching_art(fs_name, listing)
if extra:
url_screenshots.append(
LibretroThumbnailsService.build_art_url(
system_name, art_type, extra
)
)
rom = LibretroRom(
libretro_id=libretro_id_for(matched),
url_cover=url_cover,
)
if url_screenshots:
rom["url_screenshots"] = url_screenshots
return rom
LIBRETRO_PLATFORM_LIST: Final[dict[UPS, str]] = {
UPS.ADVENTURE_VISION: "Entex - Adventure Vision",
UPS.AMIGA: "Commodore - Amiga",
UPS.AMIGA_CD32: "Commodore - Amiga",
UPS.ACPC: "Amstrad - CPC",
UPS.ATARI2600: "Atari - 2600",
UPS.ATARI5200: "Atari - 5200",
UPS.ATARI7800: "Atari - 7800",
UPS.ATARI_ST: "Atari - ST",
UPS.JAGUAR: "Atari - Jaguar",
UPS.LYNX: "Atari - Lynx",
UPS.WONDERSWAN: "Bandai - WonderSwan",
UPS.WONDERSWAN_COLOR: "Bandai - WonderSwan Color",
UPS.COLECOVISION: "Coleco - ColecoVision",
UPS.C64: "Commodore - 64",
UPS.VIC_20: "Commodore - VIC-20",
UPS.DOS: "DOS",
UPS.FAIRCHILD_CHANNEL_F: "Fairchild - Channel F",
UPS.VECTREX: "GCE - Vectrex",
UPS.ODYSSEY_2: "Magnavox - Odyssey2",
UPS.INTELLIVISION: "Mattel - Intellivision",
UPS.MSX: "Microsoft - MSX",
UPS.MSX2: "Microsoft - MSX2",
UPS.XBOX: "Microsoft - XBOX",
UPS.PC_8800_SERIES: "NEC - PC Engine - TurboGrafx 16",
UPS.PC_FX: "NEC - PC-FX",
UPS.PC_9800_SERIES: "NEC - PC-98",
UPS.SUPERGRAFX: "NEC - PC Engine SuperGrafx",
UPS.TG16: "NEC - PC Engine - TurboGrafx 16",
UPS.TURBOGRAFX_CD: "NEC - PC Engine CD - TurboGrafx-CD",
UPS.FDS: "Nintendo - Family Computer Disk System",
UPS.GB: "Nintendo - Game Boy",
UPS.GBA: "Nintendo - Game Boy Advance",
UPS.GBC: "Nintendo - Game Boy Color",
UPS.NGC: "Nintendo - GameCube",
UPS.N64: "Nintendo - Nintendo 64",
UPS.N64DD: "Nintendo - Nintendo 64DD",
UPS.N3DS: "Nintendo - Nintendo 3DS",
UPS.NDS: "Nintendo - Nintendo DS",
UPS.NES: "Nintendo - Nintendo Entertainment System",
UPS.FAMICOM: "Nintendo - Nintendo Entertainment System",
UPS.POKEMON_MINI: "Nintendo - Pokemon Mini",
UPS.SATELLAVIEW: "Nintendo - Satellaview",
UPS.SUFAMI_TURBO: "Nintendo - Sufami Turbo",
UPS.SNES: "Nintendo - Super Nintendo Entertainment System",
UPS.SFAM: "Nintendo - Super Nintendo Entertainment System",
UPS.VIRTUALBOY: "Nintendo - Virtual Boy",
UPS.WII: "Nintendo - Wii",
UPS.WIIU: "Nintendo - Wii U",
UPS.SCUMMVM: "ScummVM",
UPS.SEGA32: "Sega - 32X",
UPS.DC: "Sega - Dreamcast",
UPS.GAMEGEAR: "Sega - Game Gear",
UPS.GENESIS: "Sega - Mega Drive - Genesis",
UPS.SEGACD: "Sega - Mega-CD - Sega CD",
UPS.SMS: "Sega - Master System - Mark III",
UPS.SG1000: "Sega - SG-1000",
UPS.SATURN: "Sega - Saturn",
UPS.X1: "Sharp - X1",
UPS.SHARP_X68000: "Sharp - X68000",
UPS.ZX81: "Sinclair - ZX 81",
UPS.ZXS: "Sinclair - ZX Spectrum",
UPS.NEOGEOAES: "SNK - Neo Geo",
UPS.NEOGEOMVS: "SNK - Neo Geo",
UPS.NEO_GEO_CD: "SNK - Neo Geo CD",
UPS.NEO_GEO_POCKET: "SNK - Neo Geo Pocket",
UPS.NEO_GEO_POCKET_COLOR: "SNK - Neo Geo Pocket Color",
UPS.PSX: "Sony - PlayStation",
UPS.PS2: "Sony - PlayStation 2",
UPS.PSP: "Sony - PlayStation Portable",
UPS.TIC_80: "TIC-80",
UPS.TOMY_TUTOR: "Tomy - Tutor",
UPS.SUPERVISION: "Watara - Supervision",
}