import base64 import re from datetime import datetime from typing import Final, NotRequired, TypedDict from urllib.parse import quote 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 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 ( strip_sensitive_query_params, ) SS_DEV_ID: Final = base64.b64decode("enVyZGkxNQ==").decode() SS_DEV_PASSWORD: Final = base64.b64decode("eFRKd29PRmpPUUc=").decode() SENSITIVE_KEYS = {"ssid", "sspassword"} def get_preferred_regions() -> list[str]: """Get preferred regions from config""" config = cm.get_config() return list( dict.fromkeys(config.SCAN_REGION_PRIORITY + ["us", "wor", "ss", "eu", "jp"]) ) + ["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) ACCEPTABLE_FILE_EXTENSIONS_BY_PLATFORM_SLUG = { UPS.DC: ["cue", "chd", "gdi", "cdi"], UPS.SEGACD: ["cue", "chd", "bin"], UPS.NGC: ["rvz", "iso", "gcz"], } class SSPlatform(TypedDict): slug: str ss_id: int | None name: NotRequired[str] class SSAgeRating(TypedDict): rating: str category: str rating_cover_url: 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 # mixrbv1 | 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 physical_path: str | None marquee_path: str | None logo_path: str | None video_path: str | None class SSMetadata(SSMetadataMedia): ss_score: str | None first_release_date: int | None alternative_names: list[str] 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 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, 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, physical_path=None, marquee_path=None, logo_path=None, video_path=None, ) for region in get_preferred_regions(): 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" or media.get("type") == "mixrbv2" ) 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") == "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 ) 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 ) 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 _get_lowest_date(dates: list[SSGameDate]) -> int | None: lowest_date = min(dates, default=None, key=lambda v: v.get("text", "")) if not lowest_date: return None try: return int(datetime.strptime(lowest_date["text"], "%Y-%m-%d").timestamp()) except ValueError: try: return int(datetime.strptime(lowest_date["text"], "%Y").timestamp()) except ValueError: 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) return SSMetadata( { "ss_score": _normalize_score(game.get("note", {}).get("text", "")), "alternative_names": [name["text"] for name in game.get("noms", [])], "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(): 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": res_name.replace(" : ", ": "), # Normalize colons "summary": 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: 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] ) -> SSRom: if not self.is_enabled(): return SSRom(ss_id=None) if not platform_ss_id: return SSRom(ss_id=None) 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) 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) 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, ) if not res: return SSRom(ss_id=None) return build_ss_game(rom, res) 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) # 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 _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()}