Files
romm/backend/handler/metadata/ss_handler.py
2026-05-28 20:15:40 +00:00

1082 lines
41 KiB
Python

import html
import re
from datetime import datetime
from typing import Final, NotRequired, TypedDict
from urllib.parse import quote, urlparse
import pydash
from unidecode import unidecode as uc
from adapters.services.screenscraper import ScreenScraperService
from adapters.services.screenscraper_types import SSGame, SSGameDate
from config import SCREENSCRAPER_PASSWORD, SCREENSCRAPER_USER
from config.config_manager import MetadataMediaType
from config.config_manager import config_manager as cm
from handler.filesystem import fs_resource_handler
from handler.filesystem.base_handler import region_name_to_provider_shortcode
from logger.logger import log
from models.rom import Rom, RomFile
from .base_handler import (
PS2_OPL_REGEX,
SONY_SERIAL_REGEX,
SWITCH_PRODUCT_ID_REGEX,
SWITCH_TITLEDB_REGEX,
BaseRom,
MetadataHandler,
)
from .base_handler import UniversalPlatformSlug as UPS
from .base_handler import (
restore_sensitive_query_params,
strip_sensitive_query_params,
)
SENSITIVE_KEYS = {"ssid", "sspassword"}
def _is_screenscraper_host(url: str) -> bool:
"""True only if the URL's hostname is screenscraper.fr or a subdomain.
Substring matching would let an attacker-controlled host like
screenscraper.fr.evil.example receive the user's credentials.
"""
try:
host = urlparse(url).hostname
except ValueError:
return False
if not host:
return False
return host.lower() == "screenscraper.fr" or host.lower().endswith(
".screenscraper.fr"
)
def add_ss_auth_to_url(url: str | None) -> str:
"""Re-add SS user credentials to a media URL at download time (never stored).
Only injects credentials for screenscraper.fr URLs; returns other URLs
unchanged to avoid leaking credentials to third-party sources.
"""
if not url or not _is_screenscraper_host(url):
return url or ""
if not SCREENSCRAPER_USER or not SCREENSCRAPER_PASSWORD:
return url
return restore_sensitive_query_params(
url,
{
"ssid": SCREENSCRAPER_USER,
"sspassword": SCREENSCRAPER_PASSWORD,
},
)
def get_preferred_regions(rom: Rom | None = None) -> list[str]:
"""Get preferred regions, prepending the rom's own region tags when available.
When a rom is tagged with multiple regions (e.g. "(Japan, USA)"), the rom's
own tags are reordered according to the user's SCAN_REGION_PRIORITY so the
user's preference wins among the regions the file is actually tagged as.
Filename-tagged regions not present in the priority list keep their relative
order and follow the prioritized ones.
"""
config = cm.get_config()
priority = config.SCAN_REGION_PRIORITY
rom_codes: list[str] = []
if rom is not None and isinstance(rom.regions, list):
for region_name in rom.regions:
code = region_name_to_provider_shortcode(region_name)
if code:
rom_codes.append(code)
rom_codes.sort(
key=lambda code: priority.index(code) if code in priority else len(priority)
)
return list(
dict.fromkeys(rom_codes + priority + ["us", "wor", "ss", "eu", "jp", "cus"])
) + ["unk"]
def get_preferred_languages() -> list[str]:
"""Get preferred languages from config.
Returns language priority list with default fallbacks.
"""
config = cm.get_config()
return list(dict.fromkeys(config.SCAN_LANGUAGE_PRIORITY + ["en", "fr"]))
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]
PS1_SS_ID: Final = 57
PS2_SS_ID: Final = 58
PSP_SS_ID: Final = 61
SWITCH_SS_ID: Final = 225
ARCADE_SS_ID: Final = 75
CPS1_SS_ID: Final = 6
CPS2_SS_ID: Final = 7
CPS3_SS_ID: Final = 8
ARCADES_SS_IDS: Final = [ARCADE_SS_ID, CPS1_SS_ID, CPS2_SS_ID, CPS3_SS_ID]
# Regex to detect ScreenScraper ID tags in filenames like (ssfr-12345)
SS_TAG_REGEX = re.compile(r"\(ssfr-(\d+)\)", re.IGNORECASE)
NOTGAME_NAME_PREFIX: Final = "ZZZ(NOTGAME)"
_ISO_EXTENSIONS: Final = frozenset({"iso", "cue", "chd", "gdi", "cdi", "bin"})
ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG = {
UPS.DC: ["cue", "chd", "gdi", "cdi"],
UPS.SEGACD: ["cue", "chd", "bin"],
UPS.NGC: ["rvz", "iso", "gcz"],
}
def _is_notgame(game: SSGame) -> bool:
if game.get("notgame") == "true":
return True
return any(
name.get("text", "").upper().startswith(NOTGAME_NAME_PREFIX)
for name in game.get("noms", [])
)
class SSPlatform(TypedDict):
slug: str
ss_id: int | None
name: NotRequired[str]
class SSAgeRating(TypedDict):
rating: str
category: str
class SSMetadataMedia(TypedDict):
bezel_url: str | None # bezel-16-9
box2d_url: str | None # box-2D
box2d_side_url: str | None # box-2D-side
box2d_back_url: str | None # box-2D-back
box3d_url: str | None # box-3D
fanart_url: str | None # fanart
fullbox_url: str | None # box-texture
logo_url: str | None # wheel-hd or wheel
manual_url: str | None # manual
marquee_url: str | None # screenmarquee
miximage_url: str | None # miximage1 | miximage2 | mixrbv1
miximage_v2_url: str | None # mixrbv2
physical_url: str | None # support-2D
screenshot_url: str | None # ss
steamgrid_url: str | None # steamgrid
title_screen_url: str | None # sstitle
video_url: str | None # video
video_normalized_url: str | None # video-normalized
# Resources stored in filesystem
bezel_path: str | None
box2d_back_path: str | None
box3d_path: str | None
fanart_path: str | None
miximage_path: str | None
miximage_v2_path: str | None
physical_path: str | None
marquee_path: str | None
logo_path: str | None
title_screen_path: str | None
video_path: str | None
video_normalized_path: str | None
class SSMetadata(SSMetadataMedia):
ss_score: str | None
first_release_date: int | None
alternative_names: list[str]
age_ratings: list[SSAgeRating]
companies: list[str]
franchises: list[str]
game_modes: list[str]
genres: list[str]
player_count: str
class SSRom(BaseRom):
ss_id: int | None
ss_metadata: NotRequired[SSMetadata]
def _get_rom_type(file: RomFile) -> str:
if not file.is_top_level:
return "dossier"
if file.file_extension.lower() in _ISO_EXTENSIONS:
return "iso"
return "rom"
def extract_media_from_ss_game(rom: Rom, game: SSGame) -> SSMetadataMedia:
preferred_media_types = get_preferred_media_types()
ss_media = SSMetadataMedia(
bezel_url=None,
box2d_url=None,
box2d_back_url=None,
box2d_side_url=None,
box3d_url=None,
fanart_url=None,
fullbox_url=None,
logo_url=None,
manual_url=None,
marquee_url=None,
miximage_url=None,
miximage_v2_url=None,
physical_url=None,
screenshot_url=None,
steamgrid_url=None,
title_screen_url=None,
video_url=None,
video_normalized_url=None,
bezel_path=None,
box2d_back_path=None,
box3d_path=None,
fanart_path=None,
miximage_path=None,
miximage_v2_path=None,
physical_path=None,
marquee_path=None,
logo_path=None,
title_screen_path=None,
video_path=None,
video_normalized_path=None,
)
for region in get_preferred_regions(rom):
for media in game.get("medias", []):
if media.get("region", "unk") != region or media.get("parent") != "jeu":
continue
if media.get("type") == "box-2D-back" and not ss_media["box2d_back_url"]:
ss_media["box2d_back_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.BOX2D_BACK in preferred_media_types:
ss_media["box2d_back_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX2D_BACK)}/box2d_back.png"
)
elif media.get("type") == "bezel-16-9" and not ss_media["bezel_url"]:
ss_media["bezel_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.BEZEL in preferred_media_types:
ss_media["bezel_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BEZEL)}/bezel.png"
)
elif media.get("type") == "box-2D" and not ss_media["box2d_url"]:
ss_media["box2d_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "fanart" and not ss_media["fanart_url"]:
ss_media["fanart_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.FANART in preferred_media_types:
ss_media["fanart_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.FANART)}/fanart.png"
)
elif media.get("type") == "box-texture" and not ss_media["fullbox_url"]:
ss_media["fullbox_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "wheel-hd" and not ss_media["logo_url"]:
ss_media["logo_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.LOGO in preferred_media_types:
ss_media["logo_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.LOGO)}/logo.png"
)
elif media.get("type") == "wheel" and not ss_media["logo_url"]:
ss_media["logo_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.LOGO in preferred_media_types:
ss_media["logo_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.LOGO)}/logo.png"
)
elif media.get("type") == "manuel" and not ss_media["manual_url"]:
ss_media["manual_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "screenmarquee" and not ss_media["marquee_url"]:
ss_media["marquee_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.MARQUEE in preferred_media_types:
ss_media["marquee_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.MARQUEE)}/marquee.png"
)
elif (
media.get("type") == "miximage1"
or media.get("type") == "miximage2"
or media.get("type") == "mixrbv1"
) and not ss_media["miximage_url"]:
ss_media["miximage_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.MIXIMAGE in preferred_media_types:
ss_media["miximage_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.MIXIMAGE)}/miximage.png"
)
elif (
media.get("type") == "mixrbv2"
and not ss_media["miximage_v2_url"]
):
ss_media["miximage_v2_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.MIXIMAGE_V2 in preferred_media_types:
ss_media["miximage_v2_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.MIXIMAGE_V2)}/miximage_v2.png"
)
elif media.get("type") == "support-2D" and not ss_media["physical_url"]:
ss_media["physical_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.PHYSICAL in preferred_media_types:
ss_media["physical_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.PHYSICAL)}/physical.png"
)
elif media.get("type") == "ss" and not ss_media["screenshot_url"]:
ss_media["screenshot_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "box-2D-side" and not ss_media["box2d_side_url"]:
ss_media["box2d_side_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "steamgrid" and not ss_media["steamgrid_url"]:
ss_media["steamgrid_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
elif media.get("type") == "box-3D" and not ss_media["box3d_url"]:
ss_media["box3d_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.BOX3D in preferred_media_types:
ss_media["box3d_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX3D)}/box3d.png"
)
elif media.get("type") == "sstitle" and not ss_media["title_screen_url"]:
ss_media["title_screen_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.TITLE_SCREEN in preferred_media_types:
ss_media["title_screen_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.TITLE_SCREEN)}/title_screen.png"
)
elif media.get("type") == "video" and not ss_media["video_url"]:
ss_media["video_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.VIDEO in preferred_media_types:
ss_media["video_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.VIDEO)}/video.mp4"
)
elif (
media.get("type") == "video-normalized"
and not ss_media["video_normalized_url"]
):
ss_media["video_normalized_url"] = strip_sensitive_query_params(
media["url"], SENSITIVE_KEYS
)
if MetadataMediaType.VIDEO_NORMALIZED in preferred_media_types:
ss_media["video_normalized_path"] = (
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.VIDEO_NORMALIZED)}/video-normalized.mp4"
)
return ss_media
def extract_metadata_from_ss_rom(rom: Rom, game: SSGame) -> SSMetadata:
preferred_languages = get_preferred_languages()
def _normalize_score(score: str) -> str:
"""Normalize the score to be between 0 and 10 because for some reason Screenscraper likes to rate over 20."""
try:
return str(int(score) / 2)
except (ValueError, TypeError):
return ""
def _parse_date(date_text: str) -> int | None:
try:
return int(datetime.strptime(date_text, "%Y-%m-%d").timestamp())
except ValueError:
try:
return int(datetime.strptime(date_text, "%Y").timestamp())
except ValueError:
return None
def _get_lowest_date(dates: list[SSGameDate]) -> int | None:
if not dates:
return None
for region in get_preferred_regions(rom):
region_dates = sorted(
(d for d in dates if d.get("region", "unk") == region),
key=lambda v: v.get("text", ""),
)
for region_date in region_dates:
parsed_date = _parse_date(region_date.get("text", ""))
if parsed_date is not None:
return parsed_date
for date in sorted(dates, key=lambda v: v.get("text", "")):
parsed_date = _parse_date(date.get("text", ""))
if parsed_date is not None:
return parsed_date
return None
def _get_genres(game: SSGame) -> list[str]:
return [
genre_name["text"]
for genre in game.get("genres", [])
for genre_name in genre.get("noms", [])
if genre_name.get("langue") == "en"
]
def _get_franchises(game: SSGame) -> list[str]:
for lang in preferred_languages:
franchises = [
franchise_name["text"]
for franchise in game.get("familles", [])
for franchise_name in franchise.get("noms", [])
if franchise_name.get("langue") == lang
]
if franchises:
return franchises
return []
def _get_game_modes(game: SSGame) -> list[str]:
for lang in preferred_languages:
modes = [
mode_name["text"]
for mode in game.get("modes", [])
for mode_name in mode.get("noms", [])
if mode_name.get("langue") == lang
]
if modes:
return modes
return []
def _get_player_count(game: SSGame) -> str:
player_count = game.get("joueurs", {}).get("text")
if not player_count or str(player_count).lower() in ("null", "none"):
return "1"
return str(player_count)
def _get_age_ratings(game: SSGame) -> list[SSAgeRating]:
return [
SSAgeRating(
rating=classification["text"],
category=classification["type"],
)
for classification in game.get("classifications", [])
if classification.get("type") and classification.get("text")
]
return SSMetadata(
{
"ss_score": _normalize_score(game.get("note", {}).get("text", "")),
"alternative_names": [name["text"] for name in game.get("noms", [])],
"age_ratings": _get_age_ratings(game),
"companies": pydash.compact(
[
game.get("editeur", {}).get("text"),
game.get("developpeur", {}).get("text"),
]
),
"genres": _get_genres(game),
"first_release_date": _get_lowest_date(game.get("dates", [])),
"franchises": _get_franchises(game),
"game_modes": _get_game_modes(game),
"player_count": _get_player_count(game),
**extract_media_from_ss_game(rom, game),
}
)
def build_ss_game(rom: Rom, game: SSGame) -> SSRom:
ss_metadata = extract_metadata_from_ss_rom(rom, game)
preferred_media_types = get_preferred_media_types()
res_name = ""
for region in get_preferred_regions(rom):
res_name = next(
(
name["text"]
for name in game.get("noms", [])
if name.get("region", "unk") == region
),
"",
)
if res_name:
break
res_summary = ""
preferred_languages = get_preferred_languages()
used_lang = None
for lang in preferred_languages:
res_summary = next(
(
synopsis["text"]
for synopsis in game.get("synopsis", [])
if synopsis.get("langue") == lang
),
"",
)
if res_summary:
used_lang = lang
break
# Log warning if we had to fall back from the preferred locale
if preferred_languages and used_lang and used_lang != preferred_languages[0]:
log.warning(
f"ScreenScraper locale '{preferred_languages[0]}' not found for '{res_name}', using '{used_lang}'"
)
url_cover = ss_metadata["box2d_url"]
url_manual = (
ss_metadata["manual_url"]
if MetadataMediaType.MANUAL in preferred_media_types
else None
)
url_screenshots = pydash.compact(
[
(
ss_metadata["screenshot_url"]
if MetadataMediaType.SCREENSHOT in preferred_media_types
else None
),
(
ss_metadata["title_screen_url"]
if MetadataMediaType.TITLE_SCREEN in preferred_media_types
else None
),
(
ss_metadata["fanart_url"]
if MetadataMediaType.FANART in preferred_media_types
else None
),
]
)
ss_id = int(game["id"]) if game.get("id") is not None else None
game_rom: SSRom = {
"ss_id": ss_id,
"name": html.unescape(res_name.replace(" : ", ": ")), # Normalize colons
"summary": html.unescape(res_summary),
"url_cover": str(url_cover) if url_cover else "",
"url_manual": str(url_manual) if url_manual else "",
"url_screenshots": url_screenshots,
"ss_metadata": ss_metadata,
}
return SSRom({k: v for k, v in game_rom.items() if v}) # type: ignore[misc]
class SSHandler(MetadataHandler):
def __init__(self) -> None:
self.ss_service = ScreenScraperService()
@classmethod
def is_enabled(cls) -> bool:
return bool(SCREENSCRAPER_USER and SCREENSCRAPER_PASSWORD)
async def heartbeat(self) -> bool:
if not self.is_enabled():
return False
try:
response = await self.ss_service.get_infra_info()
except Exception as e:
log.error("Error checking ScreenScraper API: %s", e)
return False
return bool(response.get("response", {}))
@staticmethod
def extract_ss_id_from_filename(fs_name: str) -> int | None:
"""Extract ScreenScraper ID from filename tag like (ss-12345)."""
match = SS_TAG_REGEX.search(fs_name)
if match:
return int(match.group(1))
return None
async def _search_rom(
self, search_term: str, platform_ss_id: int, split_game_name: bool = False
) -> SSGame | None:
if not platform_ss_id:
return None
roms = await self.ss_service.search_games(
term=quote(uc(search_term), safe="/ "),
system_id=platform_ss_id,
)
games_by_name: dict[str, SSGame] = {}
for rom in roms:
if _is_notgame(rom):
log.warning(
"ScreenScraper: Received notgame entry in search results, ignoring"
)
continue
for name in rom.get("noms", []):
if name["text"] not in games_by_name or int(rom["id"]) < int(
games_by_name[name["text"]]["id"]
):
games_by_name[name["text"]] = rom
best_match, best_score = self.find_best_match(
search_term,
list(games_by_name.keys()),
split_game_name=split_game_name,
)
if best_match:
log.debug(
f"Found match for '{search_term}' -> '{best_match}' (score: {best_score:.3f})"
)
return games_by_name[best_match]
return None
def get_platform(self, slug: str) -> SSPlatform:
if slug not in SCREENSAVER_PLATFORM_LIST:
return SSPlatform(ss_id=None, slug=slug)
platform = SCREENSAVER_PLATFORM_LIST[UPS(slug)]
return SSPlatform(
ss_id=platform["id"],
slug=slug,
name=platform["name"],
)
async def lookup_rom(
self, rom: Rom, platform_ss_id: int, files: list[RomFile]
) -> tuple[SSRom, bool]:
if not self.is_enabled():
return SSRom(ss_id=None), False
if not platform_ss_id:
return SSRom(ss_id=None), False
filtered_files = [
file
for file in files
if file.file_size_bytes > 0
and file.is_top_level
and (
UPS(rom.platform_slug)
not in ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG
or file.file_extension
in ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG[UPS(rom.platform_slug)]
)
]
# Select the largest file by size, as it is most likely to be the main ROM file.
# This increases the accuracy of metadata lookups, since the largest file is
# expected to have the correct and complete hash values for external services.
first_file = max(filtered_files, key=lambda f: f.file_size_bytes, default=None)
if first_file is None:
return SSRom(ss_id=None), False
md5_hash = first_file.md5_hash
sha1_hash = first_file.sha1_hash
crc_hash = first_file.crc_hash
fs_size_bytes = first_file.file_size_bytes
if not (md5_hash or sha1_hash or crc_hash):
log.info(
"No hashes provided for ScreenScraper lookup. "
"At least one of md5_hash, sha1_hash, or crc_hash is required."
)
return SSRom(ss_id=None), False
res = await self.ss_service.get_game_info(
system_id=platform_ss_id,
md5=md5_hash,
sha1=sha1_hash,
crc=crc_hash,
rom_size_bytes=fs_size_bytes,
rom_name=first_file.file_name,
rom_type=_get_rom_type(first_file),
)
if not res:
return SSRom(ss_id=None), False
if _is_notgame(res):
log.warning(
"ScreenScraper: Received notgame entry from hash lookup, ignoring"
)
return SSRom(ss_id=None), True
return build_ss_game(rom, res), False
async def get_rom(self, rom: Rom, file_name: str, platform_ss_id: int) -> SSRom:
from handler.filesystem import fs_rom_handler
if not self.is_enabled():
return SSRom(ss_id=None)
if not platform_ss_id:
return SSRom(ss_id=None)
# Check for ScreenScraper ID tag in filename first
ss_id_from_tag = self.extract_ss_id_from_filename(file_name)
if ss_id_from_tag:
log.debug(f"Found ScreenScraper ID tag in filename: {ss_id_from_tag}")
rom_by_id = await self.get_rom_by_id(rom, ss_id_from_tag)
if rom_by_id["ss_id"]:
log.debug(
f"Successfully matched ROM by ScreenScraper ID tag: {file_name} -> {ss_id_from_tag}"
)
return rom_by_id
else:
log.warning(
f"ScreenScraper ID {ss_id_from_tag} from filename tag not found in ScreenScraper"
)
search_term = fs_rom_handler.get_file_name_with_no_tags(file_name)
fallback_rom = SSRom(ss_id=None)
if not search_term:
return fallback_rom
# Support for PS2 OPL filename format
match = PS2_OPL_REGEX.match(file_name)
if platform_ss_id == PS2_SS_ID and match:
search_term = await self._ps2_opl_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
# Support for sony serial filename format (PS, PS3, PS3)
match = SONY_SERIAL_REGEX.search(file_name, re.IGNORECASE)
if platform_ss_id == PS1_SS_ID and match:
search_term = await self._ps1_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
if platform_ss_id == PS2_SS_ID and match:
search_term = await self._ps2_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
if platform_ss_id == PSP_SS_ID and match:
search_term = await self._psp_serial_format(match, search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
# Support for switch titleID filename format
match = SWITCH_TITLEDB_REGEX.search(file_name)
if platform_ss_id == SWITCH_SS_ID and match:
search_term, index_entry = await self._switch_titledb_format(
match, search_term
)
if index_entry:
fallback_rom = SSRom(
ss_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_manual=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for switch productID filename format
match = SWITCH_PRODUCT_ID_REGEX.search(file_name)
if platform_ss_id == SWITCH_SS_ID and match:
search_term, index_entry = await self._switch_productid_format(
match, search_term
)
if index_entry:
fallback_rom = SSRom(
ss_id=None,
name=index_entry["name"],
summary=index_entry.get("description", ""),
url_cover=index_entry.get("iconUrl", ""),
url_manual=index_entry.get("iconUrl", ""),
url_screenshots=index_entry.get("screenshots", None) or [],
)
# Support for MAME arcade filename format
if platform_ss_id in ARCADES_SS_IDS:
search_term = await self._mame_format(search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
# Support for ScummVM filename format
scummvm_platform = self.get_platform(UPS.SCUMMVM)
if platform_ss_id == scummvm_platform.get("ss_id"):
search_term = await self._scummvm_format(search_term)
fallback_rom = SSRom(ss_id=None, name=search_term)
## SS API requires punctuation to match
normalized_search_term = self.normalize_search_term(
search_term, remove_punctuation=False
)
res = await self._search_rom(
self.SEARCH_TERM_NORMALIZER.sub(" - ", normalized_search_term),
platform_ss_id,
)
# SS API doesn't handle some special characters well
if not res and " : " in search_term:
terms = re.split(self.SEARCH_TERM_SPLIT_PATTERN, search_term)
res = await self._search_rom(
terms[-1], platform_ss_id, split_game_name=True
)
if not res or not res.get("id"):
return fallback_rom
return build_ss_game(rom, res)
async def get_rom_by_id(self, rom: Rom, ss_id: int) -> SSRom:
if not self.is_enabled():
return SSRom(ss_id=None)
res = await self.ss_service.get_game_info(game_id=ss_id)
if not res:
return SSRom(ss_id=None)
return build_ss_game(rom, res)
async def get_matched_rom_by_id(self, rom: Rom, ss_id: int) -> SSRom | None:
if not self.is_enabled():
return None
game_rom = await self.get_rom_by_id(rom, ss_id)
return game_rom if game_rom.get("ss_id", "") else None
async def get_matched_roms_by_name(
self, rom: Rom, search_term: str, platform_ss_id: int | None
) -> list[SSRom]:
if not self.is_enabled():
return []
if not platform_ss_id:
return []
matched_games = await self.ss_service.search_games(
term=quote(uc(search_term), safe="/ "),
system_id=platform_ss_id,
)
def _is_ss_region(game: SSGame) -> bool:
return any(name.get("region") == "ss" for name in game.get("noms", []))
return [
build_ss_game(rom, game)
for game in matched_games
if not _is_notgame(game) and _is_ss_region(game) and game.get("id")
]
class SlugToSSId(TypedDict):
id: int
name: str
SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = {
UPS._3DO: {"id": 29, "name": "3DO"},
UPS.AMIGA: {"id": 64, "name": "Amiga"},
UPS.AMIGA_CD: {"id": 134, "name": "Amiga CD"},
UPS.AMIGA_CD32: {"id": 130, "name": "Amiga CD32"},
UPS.ACPC: {"id": 65, "name": "CPC"},
UPS.ACTION_MAX: {"id": 81, "name": "Action Max"},
UPS.ADVENTURE_VISION: {
"id": 78,
"name": "Entex Adventure Vision",
},
UPS.AMSTRAD_GX4000: {"id": 87, "name": "Amstrad GX4000"},
UPS.ANDROID: {"id": 63, "name": "Android"},
UPS.APPLE: {"id": 86, "name": "Apple I"},
UPS.APPLEIII: {"id": 86, "name": "Apple III"},
UPS.APPLEII: {"id": 86, "name": "Apple II"},
UPS.APPLE_IIGS: {"id": 51, "name": "Apple IIGS"},
UPS.ARCADE: {"id": ARCADE_SS_ID, "name": "Arcade"},
UPS.ARCADIA_2001: {"id": 94, "name": "Arcadia 2001"},
UPS.ARDUBOY: {"id": 263, "name": "Arduboy"},
UPS.ATARI2600: {"id": 26, "name": "Atari 2600"},
UPS.ATARI5200: {"id": 40, "name": "Atari 5200"},
UPS.ATARI7800: {"id": 41, "name": "Atari 7800"},
UPS.ATARI800: {"id": 43, "name": "Atari 800"},
UPS.ATARI_XEGS: {"id": 43, "name": "Atari XEGS"},
UPS.ATARI8BIT: {"id": 43, "name": "Atari 8bit"},
UPS.ATARI_JAGUAR_CD: {"id": 171, "name": "Atari Jaguar CD"},
UPS.ATARI_ST: {"id": 42, "name": "Atari ST"},
UPS.ATOM: {"id": 36, "name": "Atom"},
UPS.ACORN_ARCHIMEDES: {"id": 84, "name": "Acorn Archimedes"},
UPS.ATMOS: {"id": 131, "name": "Oric Atmos"},
UPS.BBCMICRO: {"id": 37, "name": "BBC Micro"},
UPS.BK: {"id": 93, "name": "Elektronika BK"},
UPS.ASTROCADE: {"id": 44, "name": "Astrocade"},
UPS.PHILIPS_CD_I: {"id": 133, "name": "CD-i"},
UPS.COMMODORE_CDTV: {"id": 129, "name": "Amiga CDTV"},
UPS.CAMPUTERS_LYNX: {"id": 88, "name": "Camputers Lynx"},
UPS.CASIO_LOOPY: {"id": 98, "name": "Loopy"},
UPS.CASIO_PV_1000: {"id": 74, "name": "PV-1000"},
UPS.FAIRCHILD_CHANNEL_F: {"id": 80, "name": "Channel F"},
UPS.COLECOADAM: {"id": 89, "name": "Coleco Adam"},
UPS.COLECOVISION: {"id": 48, "name": "Colecovision"},
UPS.COLOUR_GENIE: {"id": 92, "name": "EG2000 Colour Genie"},
UPS.C128: {"id": 66, "name": "Commodore 64"},
UPS.C_PLUS_4: {"id": 99, "name": "Plus/4"},
UPS.C16: {"id": 99, "name": "Plus/4"},
UPS.C64: {"id": 66, "name": "Commodore 64"},
UPS.CPS1: {"id": CPS1_SS_ID, "name": "Capcom Play System"},
UPS.CPS2: {"id": CPS2_SS_ID, "name": "Capcom Play System 2"},
UPS.CPS3: {"id": CPS3_SS_ID, "name": "Capcom Play System 3"},
UPS.CPET: {"id": 240, "name": "PET"},
UPS.CREATIVISION: {"id": 241, "name": "CreatiVision"},
UPS.DOS: {"id": 135, "name": "PC Dos"},
UPS.DRAGON_32_SLASH_64: {"id": 91, "name": "Dragon 32/64"},
UPS.DC: {"id": 23, "name": "Dreamcast"},
UPS.ACORN_ELECTRON: {"id": 85, "name": "Electron"},
UPS.EPOCH_GAME_POCKET_COMPUTER: {
"id": 95,
"name": "Game Pocket Computer",
},
UPS.EPOCH_SUPER_CASSETTE_VISION: {
"id": 67,
"name": "Super Cassette Vision",
},
UPS.EXELVISION: {"id": 96, "name": "EXL 100"},
UPS.EXIDY_SORCERER: {"id": 165, "name": "Exidy"},
UPS.FM_TOWNS: {"id": 253, "name": "FM Towns"},
UPS.FM_7: {"id": 97, "name": "FM-7"},
UPS.G_AND_W: {"id": 52, "name": "Game & Watch"},
UPS.GP32: {"id": 101, "name": "GP32"},
UPS.GB: {"id": 9, "name": "Game Boy"},
UPS.GBA: {"id": 12, "name": "Game Boy Advance"},
UPS.GBC: {"id": 10, "name": "Game Boy Color"},
UPS.GAMATE: {"id": 266, "name": "Gamate"},
UPS.GAMEGEAR: {"id": 21, "name": "Game Gear"},
UPS.GAME_DOT_COM: {"id": 121, "name": "Game.com"},
UPS.NGC: {"id": 13, "name": "GameCube"},
UPS.GENESIS: {"id": 1, "name": "Megadrive"},
UPS.HARTUNG: {"id": 103, "name": "Game Master"},
UPS.HIKARU: {"id": 258, "name": "Sega Hikaru"},
UPS.INTELLIVISION: {"id": 115, "name": "Intellivision"},
UPS.JAGUAR: {"id": 27, "name": "Jaguar"},
UPS.MODEL2: {"id": 54, "name": "Sega Model 2"},
UPS.MODEL3: {"id": 55, "name": "Sega Model 3"},
UPS.MSX2PLUS: {"id": 117, "name": "Microsoft MSX2+"},
UPS.JUPITER_ACE: {"id": 126, "name": "Jupiter Ace"},
UPS.LINUX: {"id": 145, "name": "Linux"},
UPS.LYNX: {"id": 28, "name": "Lynx"},
UPS.MSX: {"id": 113, "name": "MSX"},
UPS.MSX2: {"id": 116, "name": "MSX2"},
UPS.MSX_TURBO: {"id": 118, "name": "MSX Turbo R"},
UPS.MAC: {"id": 146, "name": "Mac OS"},
UPS.NGAGE: {"id": 30, "name": "N-Gage"},
UPS.NES: {"id": 3, "name": "NES"},
UPS.FAMICOM: {"id": 3, "name": "Famicom"},
UPS.FDS: {"id": 106, "name": "Famicom"},
UPS.NEOGEOAES: {"id": 142, "name": "Neo-Geo"},
UPS.NEOGEOMVS: {"id": 68, "name": "Neo-Geo MVS"},
UPS.NEO_GEO_CD: {"id": 70, "name": "Neo-Geo CD"},
UPS.NEO_GEO_POCKET: {"id": 25, "name": "Neo-Geo Pocket"},
UPS.NEO_GEO_POCKET_COLOR: {
"id": 82,
"name": "Neo-Geo Pocket Color",
},
UPS.N3DS: {"id": 17, "name": "Nintendo 3DS"},
UPS.N64: {"id": 14, "name": "Nintendo 64"},
UPS.N64DD: {"id": 122, "name": "Nintendo 64DD"},
UPS.NDS: {"id": 15, "name": "Nintendo DS"},
UPS.NINTENDO_DSI: {"id": 15, "name": "Nintendo DS"},
UPS.SWITCH: {"id": SWITCH_SS_ID, "name": "Switch"},
UPS.ODYSSEY_2: {"id": 104, "name": "Videopac G7000"},
UPS.OPENBOR: {"id": 214, "name": "OpenBOR"},
UPS.ORIC: {"id": 131, "name": "Oric 1 / Atmos"},
UPS.PC_8800_SERIES: {"id": 221, "name": "NEC PC-8801"},
UPS.PC_9800_SERIES: {"id": 208, "name": "NEC PC-9801"},
UPS.PC_FX: {"id": 72, "name": "PC-FX"},
UPS.PEGASUS: {"id": 83, "name": "Aamber Pegasus"},
UPS.PICO: {"id": 234, "name": "Pico-8"},
UPS.PINBALL: {"id": 197, "name": "Pinball"},
UPS.POCKET_CHALLENGE_V2: {"id": 237, "name": "Benesse Pocket Challenge V2"},
UPS.PSVITA: {"id": 62, "name": "PS Vita"},
UPS.PSP: {"id": PSP_SS_ID, "name": "PSP"},
UPS.PSP_MINIS: {"id": 172, "name": "PSP Minis"},
UPS.PALM_OS: {"id": 219, "name": "Palm OS"},
UPS.PHILIPS_VG_5000: {"id": 261, "name": "Philips VG 5000"},
UPS.PSX: {"id": PS1_SS_ID, "name": "Playstation"},
UPS.PS2: {"id": PS2_SS_ID, "name": "Playstation 2"},
UPS.PS3: {"id": 59, "name": "Playstation 3"},
UPS.PS4: {"id": 60, "name": "Playstation 4"},
UPS.PS5: {"id": 284, "name": "Playstation 5"},
UPS.POKEMON_MINI: {"id": 211, "name": "Pokémon mini"},
UPS.SAM_COUPE: {"id": 213, "name": "MGT SAM Coupé"},
UPS.SCUMMVM: {"id": 123, "name": "ScummVM"},
UPS.SEGA32: {"id": 19, "name": "Megadrive 32X"},
UPS.SEGACD: {"id": 20, "name": "Mega-CD"},
UPS.SMS: {"id": 2, "name": "Master System"},
UPS.SEGA_PICO: {"id": 250, "name": "Sega Pico"},
UPS.SATURN: {"id": 22, "name": "Saturn"},
UPS.SG1000: {"id": 109, "name": "SG-1000"},
UPS.SNES: {"id": 4, "name": "Super Nintendo"},
UPS.SFAM: {"id": 4, "name": "Super Famicom"},
UPS.SATELLAVIEW: {"id": 107, "name": "Satellaview"},
UPS.X1: {"id": 220, "name": "Sharp X1"},
UPS.SHARP_X68000: {"id": 79, "name": "Sharp X68000"},
UPS.SPECTRAVIDEO: {"id": 218, "name": "Spectravideo"},
UPS.SUFAMI_TURBO: {"id": 108, "name": "Sufami Turbo"},
UPS.SUPER_ACAN: {"id": 100, "name": "Super A'can"},
UPS.SUPERGRAFX: {"id": 105, "name": "PC Engine SuperGrafx"},
UPS.SUPERVISION: {"id": 207, "name": "Watara Supervision"},
UPS.STV: {"id": 69, "name": "Sega ST-V"},
UPS.SWITCH_2: {"id": 296, "name": "Nintendo Switch 2"},
UPS.SYSTEM_32: {"id": 156, "name": "Namco System 22"},
UPS.TI_994A: {"id": 205, "name": "TI-99/4A"},
UPS.TI_99: {"id": 205, "name": "TI-99"},
UPS.TIC_80: {"id": 222, "name": "TIC-80"},
UPS.TRS_80_COLOR_COMPUTER: {
"id": 144,
"name": "TRS-80 Color Computer",
},
UPS.TYPE_X: {"id": 112, "name": "Type X"},
UPS.TAITO_X_55: {"id": 112, "name": "Type X 55"},
UPS.THOMSON_MO5: {"id": 141, "name": "Thomson MO/TO"},
UPS.THOMSON_TO: {"id": 141, "name": "Thomson MO/TO"},
UPS.TURBOGRAFX_CD: {"id": 114, "name": "PC Engine CD-Rom"},
UPS.TG16: {"id": 31, "name": "PC Engine"},
UPS.UZEBOX: {"id": 216, "name": "UzeBox"},
UPS.VC_4000: {"id": 281, "name": "VC 4000"},
UPS.VSMILE: {"id": 120, "name": "V.Smile"},
UPS.VIC_20: {"id": 73, "name": "Vic-20"},
UPS.VECTREX: {"id": 102, "name": "Vectrex"},
UPS.VIDEOPAC_G7400: {"id": 104, "name": "Videopac G7000"},
UPS.VIRTUALBOY: {"id": 11, "name": "Virtual Boy"},
UPS.WII: {"id": 16, "name": "Wii"},
UPS.WIIU: {"id": 18, "name": "Wii U"},
UPS.WIN: {"id": 138, "name": "PC Windows"},
UPS.WIN3X: {"id": 136, "name": "PC Win3.xx"},
UPS.WASM_4: {"id": 262, "name": "WASM-4"},
UPS.WONDERSWAN: {"id": 45, "name": "WonderSwan"},
UPS.WONDERSWAN_COLOR: {"id": 46, "name": "WonderSwan Color"},
UPS.XBOX: {"id": 32, "name": "Xbox"},
UPS.XBOX360: {"id": 33, "name": "Xbox 360"},
UPS.XBOXONE: {"id": 34, "name": "Xbox One"},
UPS.Z_MACHINE: {"id": 215, "name": "Z-Machine"},
UPS.ZXS: {"id": 76, "name": "ZX Spectrum"},
UPS.ZX81: {"id": 77, "name": "ZX81"},
}
# Reverse lookup
SS_ID_TO_SLUG = {v["id"]: k for k, v in SCREENSAVER_PLATFORM_LIST.items()}