import json import re from datetime import datetime from typing import Final, NotRequired, TypedDict import pydash from config import LAUNCHBOX_API_ENABLED from handler.redis_handler import async_cache from logger.logger import log from utils.database import safe_str_to_bool from .base_handler import BaseRom, MetadataHandler from .base_handler import UniversalPlatformSlug as UPS LAUNCHBOX_PLATFORMS_KEY: Final[str] = "romm:launchbox_platforms" LAUNCHBOX_METADATA_DATABASE_ID_KEY: Final[str] = "romm:launchbox_metadata_database_id" LAUNCHBOX_METADATA_NAME_KEY: Final[str] = "romm:launchbox_metadata_name" LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY: Final[str] = ( "romm:launchbox_metadata_alternate_name" ) LAUNCHBOX_METADATA_IMAGE_KEY: Final[str] = "romm:launchbox_metadata_image" LAUNCHBOX_MAME_KEY: Final[str] = "romm:launchbox_mame" LAUNCHBOX_FILES_KEY: Final[str] = "romm:launchbox_files" # Regex to detect LaunchBox ID tags in filenames like (launchbox-12345) LAUNCHBOX_TAG_REGEX = re.compile(r"\(launchbox-(\d+)\)", re.IGNORECASE) DASH_COLON_REGEX = re.compile(r"\s?-\s") class LaunchboxPlatform(TypedDict): slug: str launchbox_id: int | None name: NotRequired[str] class LaunchboxImage(TypedDict): url: str type: NotRequired[str] region: NotRequired[str] class LaunchboxMetadata(TypedDict): first_release_date: int | None max_players: NotRequired[int] release_type: NotRequired[str] cooperative: NotRequired[bool] youtube_video_id: NotRequired[str] community_rating: NotRequired[float] community_rating_count: NotRequired[int] wikipedia_url: NotRequired[str] esrb: NotRequired[str] genres: NotRequired[list[str]] companies: NotRequired[list[str]] images: list[LaunchboxImage] class LaunchboxRom(BaseRom): launchbox_id: int | None launchbox_metadata: NotRequired[LaunchboxMetadata] def extract_video_id_from_youtube_url(url: str | None) -> str: """ Extracts the video ID from a YouTube URL. Returns None if the URL is not a valid YouTube URL. """ if not url: return "" if "youtube.com/watch?v=" in url: return url.split("v=")[-1].split("&")[0] elif "youtu.be/" in url: return url.split("/")[-1].split("?")[0] return "" def extract_metadata_from_launchbox_rom( index_entry: dict, game_images: list[dict] | None ) -> LaunchboxMetadata: try: first_release_date = int( datetime.strptime( index_entry["ReleaseDate"], "%Y-%m-%dT%H:%M:%S%z" ).timestamp() ) except (ValueError, KeyError, IndexError): first_release_date = None return LaunchboxMetadata( { "first_release_date": first_release_date, "max_players": int(index_entry.get("MaxPlayers") or 0), "release_type": index_entry.get("ReleaseType", ""), "cooperative": safe_str_to_bool(index_entry.get("Cooperative") or "false"), "youtube_video_id": extract_video_id_from_youtube_url( index_entry.get("VideoURL") ), "community_rating": float(index_entry.get("CommunityRating") or 0.0), "community_rating_count": int(index_entry.get("CommunityRatingCount") or 0), "wikipedia_url": index_entry.get("WikipediaURL", ""), "esrb": index_entry.get("ESRB", "").split(" - ")[0].strip(), "genres": ( index_entry["Genres"].split() if index_entry.get("Genres", None) else [] ), "companies": pydash.compact( [ index_entry.get("Publisher", None), index_entry.get("Developer", None), ] ), "images": [ LaunchboxImage( { "url": f"https://images.launchbox-app.com/{image['FileName']}", "type": image.get("Type", ""), "region": image.get("Region", ""), } ) for image in game_images or [] ], } ) class LaunchboxHandler(MetadataHandler): @classmethod def is_enabled(cls) -> bool: return LAUNCHBOX_API_ENABLED async def heartbeat(self) -> bool: return self.is_enabled() @staticmethod def extract_launchbox_id_from_filename(fs_name: str) -> int | None: """Extract LaunchBox ID from filename tag like (launchbox-12345).""" match = LAUNCHBOX_TAG_REGEX.search(fs_name) if match: return int(match.group(1)) return None async def _get_rom_from_metadata( self, file_name: str, platform_slug: str ) -> dict | None: if not (await async_cache.exists(LAUNCHBOX_METADATA_NAME_KEY)): log.error("Could not find the Launchbox Metadata.xml file in cache") return None lb_platform = self.get_platform(platform_slug) platform_name = lb_platform.get("name", None) if not platform_name: return None metadata_name_index_entry = await async_cache.hget( LAUNCHBOX_METADATA_NAME_KEY, f"{file_name}:{platform_name}" ) if metadata_name_index_entry: return json.loads(metadata_name_index_entry) metadata_alternate_name_index_entry = await async_cache.hget( LAUNCHBOX_METADATA_ALTERNATE_NAME_KEY, file_name ) if not metadata_alternate_name_index_entry: return None metadata_alternate_name_index_entry = json.loads( metadata_alternate_name_index_entry ) database_id = metadata_alternate_name_index_entry["DatabaseID"] metadata_database_index_entry = await async_cache.hget( LAUNCHBOX_METADATA_DATABASE_ID_KEY, database_id ) if not metadata_database_index_entry: return None return json.loads(metadata_database_index_entry) async def _get_game_images(self, database_id: str) -> list[dict] | None: metadata_image_index_entry = await async_cache.hget( LAUNCHBOX_METADATA_IMAGE_KEY, database_id ) if not metadata_image_index_entry: return None return json.loads(metadata_image_index_entry) def _get_best_cover_image(self, game_images: list[dict]) -> dict | None: """ Get the best cover image from a list of game images based on priority order: """ # Define priority order priority_types = [ "Box - Front", "Box - 3D", "Fanart - Box - Front", "Cart - Front", "Cart - 3D", ] for image_type in priority_types: for image in game_images: if image.get("Type") == image_type: return image return None def _get_screenshots(self, game_images: list[dict]) -> list[str]: screenshots: list[str] = [] for image in game_images: if "Screenshot" in image.get("Type", ""): screenshots.append( f"https://images.launchbox-app.com/{image.get('FileName')}" ) return screenshots def get_platform(self, slug: str) -> LaunchboxPlatform: if slug not in LAUNCHBOX_PLATFORM_LIST: return LaunchboxPlatform(slug=slug, launchbox_id=None) platform = LAUNCHBOX_PLATFORM_LIST[UPS(slug)] return LaunchboxPlatform( slug=slug, launchbox_id=platform["id"], name=platform["name"], ) async def get_rom( self, fs_name: str, platform_slug: str, keep_tags: bool = False ) -> LaunchboxRom: from handler.filesystem import fs_rom_handler fallback_rom = LaunchboxRom(launchbox_id=None) if not self.is_enabled(): return fallback_rom # Check for LaunchBox ID tag in filename first launchbox_id_from_tag = self.extract_launchbox_id_from_filename(fs_name) if launchbox_id_from_tag: log.debug(f"Found LaunchBox ID tag in filename: {launchbox_id_from_tag}") rom_by_id = await self.get_rom_by_id(launchbox_id_from_tag) if rom_by_id["launchbox_id"]: log.debug( f"Successfully matched ROM by LaunchBox ID tag: {fs_name} -> {launchbox_id_from_tag}" ) return rom_by_id else: log.warning( f"LaunchBox ID {launchbox_id_from_tag} from filename tag not found in LaunchBox" ) # `keep_tags` prevents stripping content that is considered a tag, e.g., anything between `()` or `[]`. # By default, tags are still stripped to keep scan behavior consistent with previous versions. # If `keep_tags` is True, the full `fs_name` is used for searching. if not keep_tags: search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name) # We replace " - "/"- " with ": " to match Launchbox's naming convention search_term = re.sub(DASH_COLON_REGEX, ": ", search_term) else: search_term = fs_name search_term = search_term.lower() if platform_slug == UPS.SCUMMVM: search_term = await self._scummvm_format(search_term) fallback_rom = LaunchboxRom(launchbox_id=None, name=search_term) index_entry = await self._get_rom_from_metadata(search_term, platform_slug) if not index_entry: return fallback_rom url_cover = None url_screenshots = [] game_images = await self._get_game_images(index_entry["DatabaseID"]) if game_images: best_cover = self._get_best_cover_image(game_images) if best_cover: url_cover = ( f"https://images.launchbox-app.com/{best_cover.get('FileName')}" ) url_screenshots = self._get_screenshots(game_images) rom = { "launchbox_id": index_entry["DatabaseID"], "name": index_entry["Name"], "summary": index_entry.get("Overview", ""), "url_cover": url_cover, "url_screenshots": url_screenshots, "launchbox_metadata": extract_metadata_from_launchbox_rom( index_entry, game_images ), } return LaunchboxRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] async def get_rom_by_id(self, database_id: int) -> LaunchboxRom: if not self.is_enabled(): return LaunchboxRom(launchbox_id=None) metadata_database_index_entry = await async_cache.hget( LAUNCHBOX_METADATA_DATABASE_ID_KEY, str(database_id) ) if not metadata_database_index_entry: return LaunchboxRom(launchbox_id=None) # Parse the JSON string from cache metadata_database_index_entry = json.loads(metadata_database_index_entry) game_images = await self._get_game_images( metadata_database_index_entry["DatabaseID"] ) rom = { "launchbox_id": database_id, "name": metadata_database_index_entry["Name"], "summary": metadata_database_index_entry.get("Overview", ""), "launchbox_metadata": extract_metadata_from_launchbox_rom( metadata_database_index_entry, game_images, ), } return LaunchboxRom({k: v for k, v in rom.items() if v}) # type: ignore[misc] async def get_matched_rom_by_id(self, database_id: int) -> LaunchboxRom | None: if not self.is_enabled(): return None return await self.get_rom_by_id(database_id) async def get_matched_roms_by_name( self, search_term: str, platform_slug: str ) -> list[LaunchboxRom]: if not self.is_enabled(): return [] rom = await self.get_rom(search_term, platform_slug, True) return [rom] if rom else [] class SlugToLaunchboxId(TypedDict): id: int name: str LAUNCHBOX_PLATFORM_LIST: dict[UPS, SlugToLaunchboxId] = { UPS.VECTOR_06C: {"id": 199, "name": "Vector-06C"}, UPS._3DO: {"id": 1, "name": "3DO Interactive Multiplayer"}, UPS.N3DS: {"id": 24, "name": "Nintendo 3DS"}, UPS.N64DD: {"id": 194, "name": "Nintendo 64DD"}, UPS.ACORN_ARCHIMEDES: {"id": 74, "name": "Acorn Archimedes"}, UPS.ACORN_ELECTRON: {"id": 65, "name": "Acorn Electron"}, UPS.ACPC: {"id": 3, "name": "Amstrad CPC"}, UPS.ACTION_MAX: {"id": 154, "name": "WoW Action Max"}, UPS.ADVENTURE_VISION: { "id": 67, "name": "Entex Adventure Vision", }, UPS.ALICE_3290: {"id": 189, "name": "Matra and Hachette Alice"}, UPS.AMIGA: {"id": 2, "name": "Commodore Amiga"}, UPS.AMIGA_CD32: {"id": 119, "name": "Commodore Amiga CD32"}, UPS.AMSTRAD_GX4000: {"id": 109, "name": "Amstrad GX4000"}, UPS.ANDROID: {"id": 4, "name": "Android"}, UPS.APF: {"id": 68, "name": "APF Imagination Machine"}, UPS.APPLE_IIGS: {"id": 112, "name": "Apple IIGS"}, UPS.APPLEII: {"id": 110, "name": "Apple II"}, UPS.ARCADE: {"id": 5, "name": "Arcade"}, UPS.ARCADIA_2001: {"id": 79, "name": "Emerson Arcadia 2001"}, UPS.ASTROCADE: {"id": 77, "name": "Bally Astrocade"}, UPS.ATARI_JAGUAR_CD: {"id": 10, "name": "Atari Jaguar CD"}, UPS.ATARI_ST: {"id": 76, "name": "Atari ST"}, UPS.ATARI_XEGS: {"id": 12, "name": "Atari XEGS"}, UPS.ATARI2600: {"id": 6, "name": "Atari 2600"}, UPS.ATARI5200: {"id": 7, "name": "Atari 5200"}, UPS.ATARI7800: {"id": 8, "name": "Atari 7800"}, UPS.ATARI800: {"id": 102, "name": "Atari 800"}, UPS.ATMOS: {"id": 64, "name": "Oric Atmos"}, UPS.ATOM: {"id": 107, "name": "Acorn Atom"}, UPS.BBCMICRO: {"id": 59, "name": "BBC Microcomputer System"}, UPS.BK: {"id": 131, "name": "Elektronika BK"}, UPS.BK_01: {"id": 175, "name": "Apogee BK-01"}, UPS.BROWSER: {"id": 85, "name": "Web Browser"}, UPS.C_PLUS_4: {"id": 121, "name": "Commodore Plus 4"}, UPS.C128: {"id": 118, "name": "Commodore 128"}, UPS.C64: {"id": 14, "name": "Commodore 64"}, UPS.CAMPUTERS_LYNX: {"id": 61, "name": "Camputers Lynx"}, UPS.CASIO_LOOPY: {"id": 114, "name": "Casio Loopy"}, UPS.CASIO_PV_1000: {"id": 115, "name": "Casio PV-1000"}, UPS.COLECOADAM: {"id": 117, "name": "Coleco Adam"}, UPS.COLECOVISION: {"id": 13, "name": "ColecoVision"}, UPS.COLOUR_GENIE: {"id": 73, "name": "EACA EG2000 Colour Genie"}, UPS.COMMODORE_CDTV: {"id": 120, "name": "Commodore CDTV"}, UPS.CPET: {"id": 180, "name": "Commodore PET"}, UPS.CREATIVISION: {"id": 152, "name": "VTech CreatiVision"}, UPS.DC: {"id": 40, "name": "Sega Dreamcast"}, UPS.DOS: {"id": 83, "name": "MS-DOS"}, UPS.DRAGON_32_SLASH_64: {"id": 66, "name": "Dragon 32/64"}, UPS.ENTERPRISE: {"id": 72, "name": "Enterprise"}, UPS.EPOCH_GAME_POCKET_COMPUTER: { "id": 132, "name": "Epoch Game Pocket Computer", }, UPS.EPOCH_SUPER_CASSETTE_VISION: { "id": 81, "name": "Epoch Super Cassette Vision", }, UPS.EXELVISION: {"id": 183, "name": "Exelvision EXL 100"}, UPS.EXIDY_SORCERER: {"id": 184, "name": "Exidy Sorcerer"}, UPS.FAIRCHILD_CHANNEL_F: { "id": 58, "name": "Fairchild Channel F", }, UPS.FAMICOM: {"id": 157, "name": "Nintendo Famicom Disk System"}, UPS.FDS: {"id": 157, "name": "Nintendo Famicom Disk System"}, UPS.FM_7: {"id": 186, "name": "Fujitsu FM-7"}, UPS.FM_TOWNS: {"id": 124, "name": "Fujitsu FM Towns Marty"}, UPS.G_AND_W: {"id": 166, "name": "Nintendo Game & Watch"}, UPS.GAME_DOT_COM: {"id": 63, "name": "Tiger Game.com"}, UPS.GAME_WAVE: {"id": 216, "name": "GameWave"}, UPS.GAMEGEAR: {"id": 41, "name": "Sega Game Gear"}, UPS.GB: {"id": 28, "name": "Nintendo Game Boy"}, UPS.GBA: {"id": 29, "name": "Nintendo Game Boy Advance"}, UPS.GBC: {"id": 30, "name": "Nintendo Game Boy Color"}, UPS.GENESIS: {"id": 42, "name": "Sega Genesis"}, UPS.GP32: {"id": 135, "name": "GamePark GP32"}, UPS.HARTUNG: {"id": 136, "name": "Hartung Game Master"}, UPS.HIKARU: {"id": 208, "name": "Sega Hikaru"}, UPS.HRX: {"id": 187, "name": "Hector HRX"}, UPS.HYPERSCAN: {"id": 171, "name": "Mattel HyperScan"}, UPS.INTELLIVISION: {"id": 15, "name": "Mattel Intellivision"}, UPS.IOS: {"id": 18, "name": "Apple iOS"}, UPS.JAGUAR: {"id": 9, "name": "Atari Jaguar"}, UPS.JUPITER_ACE: {"id": 70, "name": "Jupiter Ace"}, UPS.LINUX: {"id": 218, "name": "Linux"}, UPS.LYNX: {"id": 11, "name": "Atari Lynx"}, UPS.MAC: {"id": 16, "name": "Apple Mac OS"}, UPS.AQUARIUS: {"id": 69, "name": "Mattel Aquarius"}, UPS.MEGA_DUCK_SLASH_COUGAR_BOY: {"id": 127, "name": "Mega Duck"}, UPS.MODEL1: {"id": 104, "name": "Sega Model 1"}, UPS.MODEL2: {"id": 88, "name": "Sega Model 2"}, UPS.MODEL3: {"id": 94, "name": "Sega Model 3"}, UPS.MSX: {"id": 82, "name": "Microsoft MSX"}, UPS.MSX2: {"id": 190, "name": "Microsoft MSX2"}, UPS.MSX2PLUS: {"id": 191, "name": "Microsoft MSX2+"}, UPS.MTX512: {"id": 60, "name": "Memotech MTX512"}, UPS.MUGEN: {"id": 138, "name": "MUGEN"}, UPS.MULTIVISION: {"id": 197, "name": "Othello Multivision"}, UPS.N64: {"id": 25, "name": "Nintendo 64"}, UPS.NDS: {"id": 26, "name": "Nintendo DS"}, UPS.NEO_GEO_CD: {"id": 167, "name": "SNK Neo Geo CD"}, UPS.NEO_GEO_POCKET: {"id": 21, "name": "SNK Neo Geo Pocket"}, UPS.NEO_GEO_POCKET_COLOR: { "id": 22, "name": "SNK Neo Geo Pocket Color", }, UPS.NEOGEOAES: {"id": 23, "name": "SNK Neo Geo AES"}, UPS.NEOGEOMVS: {"id": 210, "name": "SNK Neo Geo MVS"}, UPS.NES: {"id": 27, "name": "Nintendo Entertainment System"}, UPS.NGAGE: {"id": 213, "name": "Nokia N-Gage"}, UPS.NGC: {"id": 31, "name": "Nintendo GameCube"}, UPS.NUON: {"id": 126, "name": "Nuon"}, UPS.ODYSSEY: {"id": 78, "name": "Magnavox Odyssey"}, UPS.ODYSSEY_2: { "id": 57, "name": "Magnavox Odyssey 2", }, UPS.OPENBOR: {"id": 139, "name": "OpenBOR"}, UPS.OUYA: {"id": 35, "name": "Ouya"}, UPS.PC_8800_SERIES: {"id": 192, "name": "NEC PC-8801"}, UPS.PC_9800_SERIES: {"id": 193, "name": "NEC PC-9801"}, UPS.PC_FX: {"id": 161, "name": "NEC PC-FX"}, UPS.PEGASUS: {"id": 174, "name": "Aamber Pegasus"}, UPS.PHILIPS_CD_I: {"id": 37, "name": "Philips CD-i"}, UPS.PHILIPS_VG_5000: {"id": 140, "name": "Philips VG 5000"}, UPS.PICO: {"id": 220, "name": "PICO-8"}, UPS.PINBALL: {"id": 151, "name": "Pinball"}, UPS.POCKETSTATION: {"id": 203, "name": "Sony PocketStation"}, UPS.POKEMON_MINI: {"id": 195, "name": "Nintendo Pokemon Mini"}, UPS.PS2: {"id": 48, "name": "Sony Playstation 2"}, UPS.PS3: {"id": 49, "name": "Sony Playstation 3"}, UPS.PS4: {"id": 50, "name": "Sony Playstation 4"}, UPS.PS5: {"id": 219, "name": "Sony Playstation 5"}, UPS.PSP: {"id": 52, "name": "Sony PSP"}, UPS.PSP_MINIS: {"id": 202, "name": "Sony PSP Minis"}, UPS.PSVITA: {"id": 51, "name": "Sony Playstation Vita"}, UPS.PSX: {"id": 47, "name": "Sony Playstation"}, UPS.RCA_STUDIO_II: {"id": 142, "name": "RCA Studio II"}, UPS.SAM_COUPE: {"id": 71, "name": "SAM Coupé"}, UPS.SATELLAVIEW: {"id": 168, "name": "Nintendo Satellaview"}, UPS.SATURN: {"id": 45, "name": "Sega Saturn"}, UPS.SC3000: {"id": 145, "name": "Sega SC-3000"}, UPS.SCUMMVM: {"id": 143, "name": "ScummVM"}, UPS.SEGA_PICO: {"id": 105, "name": "Sega Pico"}, UPS.SEGA32: {"id": 38, "name": "Sega 32X"}, UPS.SEGACD: {"id": 39, "name": "Sega CD"}, UPS.SEGACD32: {"id": 173, "name": "Sega CD 32X"}, UPS.SERIES_X_S: {"id": 222, "name": "Microsoft Xbox Series X/S"}, UPS.SFAM: {"id": 53, "name": "Super Famicom"}, UPS.SG1000: {"id": 80, "name": "Sega SG-1000"}, UPS.SHARP_MZ_80B20002500: {"id": 205, "name": "Sharp MZ-2500"}, UPS.SHARP_X68000: {"id": 128, "name": "Sharp X68000"}, UPS.SMS: {"id": 43, "name": "Sega Master System"}, UPS.SNES: { "id": 53, "name": "Super Nintendo Entertainment System", }, UPS.SOCRATES: {"id": 198, "name": "VTech Socrates"}, UPS.SORD_M5: {"id": 148, "name": "Sord M5"}, UPS.SPECTRAVIDEO: {"id": 201, "name": "Spectravideo"}, UPS.STV: {"id": 146, "name": "Sega ST-V"}, UPS.SUPER_VISION_8000: { "id": 223, "name": "Bandai Super Vision 8000", }, UPS.SUPERGRAFX: {"id": 162, "name": "PC Engine SuperGrafx"}, UPS.SWITCH: {"id": 211, "name": "Nintendo Switch"}, UPS.SWITCH_2: {"id": 224, "name": "Nintendo Switch 2"}, UPS.SYSTEM_32: {"id": 93, "name": "Namco System 22"}, UPS.SYSTEM16: {"id": 97, "name": "Sega System 16"}, UPS.SYSTEM32: {"id": 96, "name": "Sega System 32"}, UPS.TG16: {"id": 54, "name": "NEC TurboGrafx-16"}, UPS.TI_994A: {"id": 149, "name": "Texas Instruments TI 99/4A"}, UPS.TOMY_TUTOR: {"id": 200, "name": "Tomy Tutor"}, UPS.TRS_80: {"id": 129, "name": "Tandy TRS-80"}, UPS.TRS_80_COLOR_COMPUTER: { "id": 164, "name": "TRS-80 Color Computer", }, UPS.TURBOGRAFX_CD: {"id": 163, "name": "NEC TurboGrafx-CD"}, UPS.TYPE_X: {"id": 169, "name": "Taito Type X"}, UPS.VC_4000: {"id": 137, "name": "Interton VC 4000"}, UPS.VECTREX: {"id": 125, "name": "GCE Vectrex"}, UPS.VIC_20: {"id": 122, "name": "Commodore VIC-20"}, UPS.VIDEOPAC_G7400: {"id": 141, "name": "Philips Videopac+"}, UPS.VIRTUALBOY: {"id": 32, "name": "Nintendo Virtual Boy"}, UPS.VMU: {"id": 144, "name": "Sega Dreamcast VMU"}, UPS.VSMILE: {"id": 221, "name": "VTech V.Smile"}, UPS.SUPERVISION: {"id": 153, "name": "Watara Supervision"}, UPS.WII: {"id": 33, "name": "Nintendo Wii"}, UPS.WIIU: {"id": 34, "name": "Nintendo Wii U"}, UPS.WIN: {"id": 84, "name": "Windows"}, UPS.WIN3X: {"id": 212, "name": "Windows 3.X"}, UPS.WONDERSWAN: {"id": 55, "name": "WonderSwan"}, UPS.WONDERSWAN_COLOR: {"id": 56, "name": "WonderSwan Color"}, UPS.X1: {"id": 204, "name": "Sharp X1"}, UPS.XAVIXPORT: {"id": 170, "name": "XaviXPORT"}, UPS.XBOX: {"id": 18, "name": "Microsoft Xbox"}, UPS.XBOX360: {"id": 19, "name": "Microsoft Xbox 360"}, UPS.XBOXONE: {"id": 20, "name": "Microsoft Xbox One"}, UPS.ZINC: {"id": 155, "name": "ZiNc"}, UPS.ZOD: {"id": 75, "name": "Tapwave Zodiac"}, UPS.ZX81: {"id": 147, "name": "Sinclair ZX-81"}, UPS.ZXS: {"id": 46, "name": "Sinclair ZX Spectrum"}, } # Reverse lookup LAUNCHBOX_PLATFORM_NAME_TO_SLUG = { v["id"]: k for k, v in LAUNCHBOX_PLATFORM_LIST.items() }