2025-09-11 21:27:48 -04:00

749 lines
23 KiB
Python

import abc
import enum
import json
import re
import unicodedata
from functools import lru_cache
from pathlib import Path
from typing import Final, NotRequired, TypedDict
from strsimpy.jaro_winkler import JaroWinkler
from handler.redis_handler import async_cache
from logger.logger import log
from tasks.scheduled.update_switch_titledb import (
SWITCH_PRODUCT_ID_KEY,
SWITCH_TITLEDB_INDEX_KEY,
update_switch_titledb_task,
)
jarowinkler = JaroWinkler()
METADATA_FIXTURES_DIR: Final = Path(__file__).parent / "fixtures"
# These are loaded in cache in update_switch_titledb_task
SWITCH_TITLEDB_REGEX: Final = re.compile(r"(70[0-9]{12})")
SWITCH_PRODUCT_ID_REGEX: Final = re.compile(r"(0100[0-9A-F]{12})")
# No regex needed for MAME
MAME_XML_KEY: Final = "romm:mame_xml"
# PS2 OPL
PS2_OPL_REGEX: Final = re.compile(r"^([A-Z]{4}_\d{3}\.\d{2})\..*$")
PS2_OPL_KEY: Final = "romm:ps2_opl_index"
# Sony serial codes for PS1, PS2, and PSP
SONY_SERIAL_REGEX: Final = re.compile(r".*([a-zA-Z]{4}-\d{5}).*$")
PS1_SERIAL_INDEX_KEY: Final = "romm:ps1_serial_index"
PS2_SERIAL_INDEX_KEY: Final = "romm:ps2_serial_index"
PSP_SERIAL_INDEX_KEY: Final = "romm:psp_serial_index"
LEADING_ARTICLE_PATTERN = re.compile(r"^(a|an|the)\b")
COMMA_ARTICLE_PATTERN = re.compile(r",\s(a|an|the)\b$")
NON_WORD_SPACE_PATTERN = re.compile(r"[^\w\s]")
MULTIPLE_SPACE_PATTERN = re.compile(r"\s+")
class BaseRom(TypedDict):
name: NotRequired[str]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[str]]
url_manual: NotRequired[str]
# This caches results to avoid repeated normalization of the same search term
@lru_cache(maxsize=1024)
def _normalize_search_term(
name: str, remove_articles: bool = True, remove_punctuation: bool = True
) -> str:
# Lower and replace underscores with spaces
name = name.lower().replace("_", " ")
# Remove articles (combined if possible)
if remove_articles:
name = LEADING_ARTICLE_PATTERN.sub("", name)
name = COMMA_ARTICLE_PATTERN.sub("", name)
# Remove punctuation and normalize spaces in one step
if remove_punctuation:
name = NON_WORD_SPACE_PATTERN.sub(" ", name)
name = MULTIPLE_SPACE_PATTERN.sub(" ", name)
# Unicode normalization and accent removal
if any(ord(c) > 127 for c in name): # Only if non-ASCII chars present
normalized = unicodedata.normalize("NFD", name)
name = "".join(c for c in normalized if not unicodedata.combining(c))
return name.strip()
class MetadataHandler(abc.ABC):
SEARCH_TERM_SPLIT_PATTERN = re.compile(r"[\:\-\/]")
SEARCH_TERM_NORMALIZER = re.compile(r"\s*[:-]\s*")
@classmethod
@abc.abstractmethod
def is_enabled(cls) -> bool:
"""Return whether this metadata handler is enabled."""
def normalize_cover_url(self, url: str) -> str:
return url if not url else f"https:{url.replace('https:', '')}"
def normalize_search_term(
self, name: str, remove_articles: bool = True, remove_punctuation: bool = True
) -> str:
return _normalize_search_term(name, remove_articles, remove_punctuation)
def find_best_match(
self,
search_term: str,
game_names: list[str],
min_similarity_score: float = 0.75,
split_game_name: bool = False,
) -> tuple[str | None, float]:
"""
Find the best matching game name from a list of candidates.
Args:
search_term: The search term to match
game_names: List of game names to check against
min_similarity_score: Minimum similarity score to consider a match
Returns:
Tuple of (best_match_name, similarity_score) or (None, 0.0) if no good match
"""
if not game_names:
return None, 0.0
best_match = None
best_score = 0.0
search_term_normalized = self.normalize_search_term(search_term)
for game_name in game_names:
game_name_normalized = self.normalize_search_term(game_name)
# If the game name is split, normalize the last term
if split_game_name and re.search(self.SEARCH_TERM_SPLIT_PATTERN, game_name):
game_name_normalized = self.normalize_search_term(
re.split(self.SEARCH_TERM_SPLIT_PATTERN, game_name)[-1]
)
score = jarowinkler.similarity(search_term_normalized, game_name_normalized)
if score > best_score:
best_score = score
best_match = game_name
# Early exit for perfect match
if score == 1.0:
break
if best_score >= min_similarity_score:
return best_match, best_score
return None, 0.0
async def _ps2_opl_format(self, match: re.Match[str], search_term: str) -> str:
serial_code = match.group(1)
index_entry = await async_cache.hget(PS2_OPL_KEY, serial_code)
if index_entry:
index_entry = json.loads(index_entry)
search_term = index_entry["Name"] # type: ignore
return search_term
async def _sony_serial_format(self, index_key: str, serial_code: str) -> str | None:
index_entry = await async_cache.hget(index_key, serial_code)
if index_entry:
index_entry = json.loads(index_entry)
return index_entry["title"]
return None
async def _ps1_serial_format(self, match: re.Match[str], search_term: str) -> str:
serial_code = match.group(1)
return (
await self._sony_serial_format(PS1_SERIAL_INDEX_KEY, serial_code)
or search_term
)
async def _ps2_serial_format(self, match: re.Match[str], search_term: str) -> str:
serial_code = match.group(1)
return (
await self._sony_serial_format(PS2_SERIAL_INDEX_KEY, serial_code)
or search_term
)
async def _psp_serial_format(self, match: re.Match[str], search_term: str) -> str:
serial_code = match.group(1)
return (
await self._sony_serial_format(PSP_SERIAL_INDEX_KEY, serial_code)
or search_term
)
async def _switch_titledb_format(
self, match: re.Match[str], search_term: str
) -> tuple[str, dict | None]:
title_id = match.group(1)
if not (await async_cache.exists(SWITCH_TITLEDB_INDEX_KEY)):
log.warning("Fetching the Switch titleID index file...")
await update_switch_titledb_task.run(force=True)
if not (await async_cache.exists(SWITCH_TITLEDB_INDEX_KEY)):
log.error("Could not fetch the Switch titleID index file")
return search_term, None
index_entry = await async_cache.hget(SWITCH_TITLEDB_INDEX_KEY, title_id)
if index_entry:
index_entry = json.loads(index_entry)
return index_entry["name"], index_entry
return search_term, None
async def _switch_productid_format(
self, match: re.Match[str], search_term: str
) -> tuple[str, dict | None]:
product_id = match.group(1)
# Game updates have the same product ID as the main application, except with bitmask 0x800 set
product_id = list(product_id)
product_id[-3] = "0"
product_id = "".join(product_id)
if not (await async_cache.exists(SWITCH_PRODUCT_ID_KEY)):
log.warning("Fetching the Switch productID index file...")
await update_switch_titledb_task.run(force=True)
if not (await async_cache.exists(SWITCH_PRODUCT_ID_KEY)):
log.error("Could not fetch the Switch productID index file")
return search_term, None
index_entry = await async_cache.hget(SWITCH_PRODUCT_ID_KEY, product_id)
if index_entry:
index_entry = json.loads(index_entry)
return index_entry["name"], index_entry
return search_term, None
async def _mame_format(self, search_term: str) -> str:
from handler.filesystem import fs_rom_handler
index_entry = await async_cache.hget(MAME_XML_KEY, search_term)
if index_entry:
index_entry = json.loads(index_entry)
search_term = fs_rom_handler.get_file_name_with_no_tags(
index_entry.get("description", search_term)
)
return search_term
def _mask_sensitive_values(self, values: dict[str, str]) -> dict[str, str]:
"""
Mask sensitive values (headers or params), leaving only the first 3 and last 3 characters of the token.
This is valid for a dictionary with any of the following keys:
- "Authorization" (Bearer token)
- "Client-ID"
- "Client-Secret"
- "client_id"
- "client_secret"
- "api_key"
- "ssid"
- "sspassword"
- "devid"
- "devpassword"
- "y" (RA API key)
"""
return {
key: (
f"Bearer {values[key].split(' ')[1][:2]}***{values[key].split(' ')[1][-2:]}"
if key == "Authorization" and values[key].startswith("Bearer ")
else (
f"{values[key][:2]}***{values[key][-2:]}"
if key
in {
"Client-ID",
"Client-Secret",
"client_id",
"client_secret",
"api_key",
"ssid",
"sspassword",
"devid",
"devpassword",
"y",
}
# Leave other keys unchanged
else values[key]
)
)
for key in values
}
class UniversalPlatformSlug(enum.StrEnum):
_3DO = "3do"
ABC_80 = "abc-80"
ACORN_ARCHIMEDES = "acorn-archimedes"
ACORN_ELECTRON = "acorn-electron"
ACPC = "acpc"
ACTION_MAX = "action-max"
ADVANCED_PICO_BEENA = "advanced-pico-beena"
ADVENTURE_VISION = "adventure-vision"
AIRCONSOLE = "airconsole"
ALICE_3290 = "alice-3290"
ALTAIR_680 = "altair-680"
ALTAIR_8800 = "altair-8800"
AMAZON_ALEXA = "amazon-alexa"
AMAZON_FIRE_TV = "amazon-fire-tv"
AMIGA = "amiga"
AMIGA_CD = "amiga-cd"
AMIGA_CD32 = "amiga-cd32"
AMSTRAD_GX4000 = "amstrad-gx4000"
AMSTRAD_PCW = "amstrad-pcw"
ANALOGUEELECTRONICS = "analogueelectronics"
ANDROID = "android"
ANTSTREAM = "antstream"
APF = "apf"
APPLE = "apple"
APPLE_IIGS = "apple-iigs"
APPLE_LISA = "apple-lisa"
APPLE_PIPPIN = "apple-pippin"
APPLEII = "appleii"
APPLEIII = "appleiii"
APVS = "1292-advanced-programmable-video-system"
AQUARIUS = "aquarius"
ARCADE = "arcade"
ARCADIA_2001 = "arcadia-2001"
ARDUBOY = "arduboy"
ASTRAL_2000 = "astral-2000"
ASTROCADE = "astrocade"
ATARI_JAGUAR_CD = "atari-jaguar-cd"
ATARI_ST = "atari-st"
ATARI_VCS = "atari-vcs"
ATARI_XEGS = "atari-xegs"
ATARI2600 = "atari2600"
ATARI5200 = "atari5200"
ATARI7800 = "atari7800"
ATARI800 = "atari800"
ATARI8BIT = "atari8bit"
ATMOS = "atmos"
ATOM = "atom"
AY_3_8500 = "ay-3-8500"
AY_3_8603 = "ay-3-8603"
AY_3_8605 = "ay-3-8605"
AY_3_8606 = "ay-3-8606"
AY_3_8607 = "ay-3-8607"
AY_3_8610 = "ay-3-8610"
AY_3_8710 = "ay-3-8710"
AY_3_8760 = "ay-3-8760"
BADA = "bada"
BBCMICRO = "bbcmicro"
BEENA = "beena"
BEOS = "beos"
BIT_90 = "bit-90"
BK = "bk"
BK_01 = "bk-01"
BLACK_POINT = "black-point"
BLACKBERRY = "blackberry"
BLACKNUT = "blacknut"
BLU_RAY_PLAYER = "blu-ray-player"
BREW = "brew"
BROWSER = "browser"
BUBBLE = "bubble"
C_PLUS_4 = "c-plus-4"
C128 = "c128"
C16 = "c16"
C64 = "c64"
CALL_A_COMPUTER = "call-a-computer"
CAMPUTERS_LYNX = "camputers-lynx"
CASIO_CFX_9850 = "casio-cfx-9850"
CASIO_FP_1000 = "casio-fp-1000"
CASIO_LOOPY = "casio-loopy"
CASIO_PB_1000 = "casio-pb-1000"
CASIO_PROGRAMMABLE_CALCULATOR = "casio-programmable-calculator"
CASIO_PV_1000 = "casio-pv-1000"
CASIO_PV_2000 = "casio-pv-2000"
CDCCYBER70 = "cdccyber70"
CHAMPION_2711 = "champion-2711"
CLICKSTART = "clickstart"
COLECOADAM = "colecoadam"
COLECOVISION = "colecovision"
COLOUR_GENIE = "colour-genie"
COMMANDER_X16 = "commander-x16"
COMMODORE_CDTV = "commodore-cdtv"
COMPAL_80 = "compal-80"
COMPUCOLOR_I = "compucolor-i"
COMPUCOLOR_II = "compucolor-ii"
COMPUCORP_PROGRAMMABLE_CALCULATOR = "compucorp-programmable-calculator"
CPET = "cpet"
CPM = "cpm"
CREATIVISION = "creativision"
CYBERVISION = "cybervision"
DANGER_OS = "danger-os"
DAYDREAM = "daydream"
DC = "dc"
DEDICATED_CONSOLE = "dedicated-console"
DEDICATED_HANDHELD = "dedicated-handheld"
DIDJ = "didj"
DIGIBLAST = "digiblast"
DOJA = "doja"
DONNER30 = "donner30"
DOS = "dos"
DRAGON_32_SLASH_64 = "dragon-32-slash-64"
DVD_PLAYER = "dvd-player"
E_READER_SLASH_CARD_E_READER = "e-reader-slash-card-e-reader"
ECD_MICROMIND = "ecd-micromind"
EDSAC = "edsac"
ELEKTOR = "elektor"
ENTERPRISE = "enterprise"
EPOCH_CASSETTE_VISION = "epoch-cassette-vision"
EPOCH_GAME_POCKET_COMPUTER = "epoch-game-pocket-computer"
EPOCH_SUPER_CASSETTE_VISION = "epoch-super-cassette-vision"
EVERCADE = "evercade"
EXCALIBUR_64 = "excalibur-64"
EXELVISION = "exelvision"
EXEN = "exen"
EXIDY_SORCERER = "exidy-sorcerer"
FAIRCHILD_CHANNEL_F = "fairchild-channel-f"
FAMICOM = "famicom"
FDS = "fds"
FM_7 = "fm-7"
FM_TOWNS = "fm-towns"
FRED_COSMAC = "fred-cosmac"
FREEBOX = "freebox"
G_AND_W = "g-and-w"
G_CLUSTER = "g-cluster"
GALAKSIJA = "galaksija"
GAMATE = "gamate"
GAME_DOT_COM = "game-dot-com"
GAME_WAVE = "game-wave"
GAMEGEAR = "gamegear"
GAMESTICK = "gamestick"
GB = "gb"
GBA = "gba"
GBC = "gbc"
GEAR_VR = "gear-vr"
GENESIS = "genesis"
GIMINI = "gimini"
GIZMONDO = "gizmondo"
GLOUD = "gloud"
GLULX = "glulx"
GNEX = "gnex"
GP2X = "gp2x"
GP2X_WIZ = "gp2x-wiz"
GP32 = "gp32"
GT40 = "gt40"
GVM = "gvm"
HANDHELD_ELECTRONIC_LCD = "handheld-electronic-lcd"
HARTUNG = "hartung"
HD_DVD_PLAYER = "hd-dvd-player"
HEATHKIT_H11 = "heathkit-h11"
HEATHZENITH = "heathzenith"
HIKARU = "hikaru"
HITACHI_S1 = "hitachi-s1"
HP_9800 = "hp-9800"
HP_PROGRAMMABLE_CALCULATOR = "hp-programmable-calculator"
HP2100 = "hp2100"
HP3000 = "hp3000"
HRX = "hrx"
HUGO = "hugo"
HYPER_NEO_GEO_64 = "hyper-neo-geo-64"
HYPERSCAN = "hyperscan"
IBM_5100 = "ibm-5100"
IDEAL_COMPUTER = "ideal-computer"
IIRCADE = "iircade"
IMLAC_PDS1 = "imlac-pds1"
INTEL_8008 = "intel-8008"
INTEL_8080 = "intel-8080"
INTEL_8086 = "intel-8086"
INTELLIVISION = "intellivision"
INTELLIVISION_AMICO = "intellivision-amico"
INTERACT_MODEL_ONE = "interact-model-one"
INTERTON_VC_4000 = "interton-vc-4000"
INTERTON_VIDEO_2000 = "interton-video-2000"
IOS = "ios"
IPAD = "ipad"
IPOD_CLASSIC = "ipod-classic"
J2ME = "j2me"
JAGUAR = "jaguar"
JOLT = "jolt"
JUPITER_ACE = "jupiter-ace"
KAIOS = "kaios"
KIM_1 = "kim-1"
KINDLE = "kindle"
LASER200 = "laser200"
LASERACTIVE = "laseractive"
LEAPFROG_EXPLORER = "leapfrog-explorer"
LEAPSTER = "leapster"
LEAPSTER_EXPLORER_SLASH_LEADPAD_EXPLORER = (
"leapster-explorer-slash-leadpad-explorer"
)
LEAPTV = "leaptv"
LEGACY_COMPUTER = "legacy-computer"
LINUX = "linux"
LUNA = "luna"
LYNX = "lynx"
MAC = "mac"
MAEMO = "maemo"
MAINFRAME = "mainframe"
MATSUSHITAPANASONIC_JR = "matsushitapanasonic-jr"
MEEGO = "meego"
MEGA_DUCK_SLASH_COUGAR_BOY = "mega-duck-slash-cougar-boy"
MEMOTECH_MTX = "memotech-mtx"
MERITUM = "meritum"
META_QUEST_2 = "meta-quest-2"
META_QUEST_3 = "meta-quest-3"
MICROBEE = "microbee"
MICROCOMPUTER = "microcomputer"
MICROTAN_65 = "microtan-65"
MICROVISION = "microvision"
MOBILE = "mobile"
MOBILE_CUSTOM = "mobile-custom"
MODEL1 = "model1"
MODEL2 = "model2"
MODEL3 = "model3"
MOPHUN = "mophun"
MOS_TECHNOLOGY_6502 = "mos-technology-6502"
MOTOROLA_6800 = "motorola-6800"
MOTOROLA_68K = "motorola-68k"
MRE = "mre"
MSX = "msx"
MSX_TURBO = "msx-turbo"
MSX2 = "msx2"
MSX2PLUS = "msx2plus"
MTX512 = "mtx512"
MUGEN = "mugen"
MULTIVISION = "multivision"
N3DS = "3ds"
N64 = "n64"
N64DD = "64dd"
NASCOM = "nascom"
NDS = "nds"
NEC_PC_6000_SERIES = "nec-pc-6000-series"
NEO_GEO_CD = "neo-geo-cd"
NEO_GEO_POCKET = "neo-geo-pocket"
NEO_GEO_POCKET_COLOR = "neo-geo-pocket-color"
NEO_GEO_X = "neo-geo-x"
NEOGEOAES = "neogeoaes"
NEOGEOMVS = "neogeomvs"
NES = "nes"
NEW_NINTENDON3DS = "new-nintendo-3ds"
NEWBRAIN = "newbrain"
NEWTON = "newton"
NGAGE = "ngage"
NGAGE2 = "ngage2"
NGC = "ngc"
NIMROD = "nimrod"
NINTENDO_DSI = "nintendo-dsi"
NORTHSTAR = "northstar"
NOVAL_760 = "noval-760"
NUON = "nuon"
OCULUS_GO = "oculus-go"
OCULUS_QUEST = "oculus-quest"
OCULUS_RIFT = "oculus-rift"
OCULUS_VR = "oculus-vr"
ODYSSEY = "odyssey"
ODYSSEY_2 = "odyssey-2"
ODYSSEY_2_SLASH_VIDEOPAC_G7000 = "odyssey-2-slash-videopac-g7000"
OHIO_SCIENTIFIC = "ohio-scientific"
ONLIVE_GAME_SYSTEM = "onlive-game-system"
OOPARTS = "ooparts"
OPENBOR = "openbor"
ORAO = "orao"
ORIC = "oric"
OS2 = "os2"
OUYA = "ouya"
PALM_OS = "palm-os"
PALMTEX = "palmtex"
PANASONIC_JUNGLE = "panasonic-jungle"
PANASONIC_M2 = "panasonic-m2"
PANDORA = "pandora"
PC_50X_FAMILY = "pc-50x-family"
PC_6001 = "pc-6001"
PC_8000 = "pc-8000"
PC_8800_SERIES = "pc-8800-series"
PC_9800_SERIES = "pc-9800-series"
PC_BOOTER = "pc-booter"
PC_FX = "pc-fx"
PC_JR = "pc-jr"
PDP_7 = "pdp-7"
PDP_8 = "pdp-8"
PDP1 = "pdp1"
PDP10 = "pdp10"
PDP11 = "pdp11"
PEBBLE = "pebble"
PEGASUS = "pegasus"
PHILIPS_CD_I = "philips-cd-i"
PHILIPS_VG_5000 = "philips-vg-5000"
PHOTOCD = "photocd"
PICO = "pico"
PINBALL = "pinball"
PIPPIN = "pippin"
PLATO = "plato"
PLAYDATE = "playdate"
PLAYDIA = "playdia"
PLAYSTATION_NOW = "playstation-now"
PLEX_ARCADE = "plex-arcade"
PLUG_AND_PLAY = "plug-and-play"
POCKET_CHALLENGE_V2 = "pocket-challenge-v2"
POCKET_CHALLENGE_W = "pocket-challenge-w"
POCKETSTATION = "pocketstation"
POKEMON_MINI = "pokemon-mini"
POKITTO = "pokitto"
POLY_88 = "poly-88"
POLYMEGA = "polymega"
PS2 = "ps2"
PS3 = "ps3"
PS4 = "ps4"
PS5 = "ps5"
PSP = "psp"
PSP_MINIS = "psp-minis"
PSVITA = "psvita"
PSVR = "psvr"
PSVR2 = "psvr2"
PSX = "psx"
R_ZONE = "r-zone"
RCA_STUDIO_II = "rca-studio-ii"
RESEARCH_MACHINES_380Z = "research-machines-380z"
ROKU = "roku"
SAM_COUPE = "sam-coupe"
SATELLAVIEW = "satellaview"
SATURN = "saturn"
SC3000 = "sc3000"
SCMP = "scmp"
SCUMMVM = "scummvm"
SD_200270290 = "sd-200270290"
SDSSIGMA7 = "sdssigma7"
SEGA_PICO = "sega-pico"
SEGA32 = "sega32"
SEGACD = "segacd"
SEGACD32 = "segacd32"
SERIES_X_S = "series-x-s"
SFAM = "sfam"
SG1000 = "sg1000"
SHARP_MZ_2200 = "sharp-mz-2200"
SHARP_MZ_80B20002500 = "sharp-mz-80b20002500"
SHARP_MZ_80K7008001500 = "sharp-mz-80k7008001500"
SHARP_X68000 = "sharp-x68000"
SHARP_ZAURUS = "sharp-zaurus"
SIGNETICS_2650 = "signetics-2650"
SINCLAIR_QL = "sinclair-ql"
SK_VM = "sk-vm"
SMC_777 = "smc-777"
SMS = "sms"
SNES = "snes"
SOCRATES = "socrates"
SOL_20 = "sol-20"
SORD_M5 = "sord-m5"
SPECTRAVIDEO = "spectravideo"
SRI_5001000 = "sri-5001000"
STADIA = "stadia"
STEAM_VR = "steam-vr"
STV = "stv"
SUFAMI_TURBO = "sufami-turbo"
SUPER_ACAN = "super-acan"
SUPER_NES_CD_ROM_SYSTEM = "super-nes-cd-rom-system"
SUPER_VISION_8000 = "super-vision-8000"
SUPERGRAFX = "supergrafx"
SUPERVISION = "supervision"
SURE_SHOT_HD = "sure-shot-hd"
SWANCRYSTAL = "swancrystal"
SWITCH = "switch"
SWITCH_2 = "switch-2"
SWTPC_6800 = "swtpc-6800"
SYMBIAN = "symbian"
SYSTEM_32 = "system-32"
SYSTEM16 = "system16"
SYSTEM32 = "system32"
TADS = "tads"
TAITO_X_55 = "taito-x-55"
TANDY_VIS = "tandy-vis"
TATUNG_EINSTEIN = "tatung-einstein"
TEKTRONIX_4050 = "tektronix-4050"
TELE_SPIEL = "tele-spiel"
TELSTAR_ARCADE = "telstar-arcade"
TEREBIKKO_SLASH_SEE_N_SAY_VIDEO_PHONE = "terebikko-slash-see-n-say-video-phone"
TERMINAL = "terminal"
TG16 = "tg16"
THOMSON_MO5 = "thomson-mo5"
THOMSON_TO = "thomson-to"
TI_82 = "ti-82"
TI_83 = "ti-83"
TI_99 = "ti-99"
TI_994A = "ti-994a"
TI_PROGRAMMABLE_CALCULATOR = "ti-programmable-calculator"
TIKI_100 = "tiki-100"
TIM = "tim"
TIMEX_SINCLAIR_2068 = "timex-sinclair-2068"
TIZEN = "tizen"
TOMAHAWK_F1 = "tomahawk-f1"
TOMY_TUTOR = "tomy-tutor"
TOMY_TUTOR_SLASH_PYUTA_SLASH_GRANDSTAND_TUTOR = (
"tomy-tutor-slash-pyuta-slash-grandstand-tutor"
)
TRITON = "triton"
TRS_80 = "trs-80"
TRS_80_COLOR_COMPUTER = "trs-80-color-computer"
TRS_80_MC_10 = "trs-80-mc-10"
TRS_80_MODEL_100 = "trs-80-model-100"
TURBOGRAFX_CD = "turbografx-cd"
TVOS = "tvos"
TYPE_X = "type-x"
UZEBOX = "uzebox"
VC = "vc"
VC_4000 = "vc-4000"
VECTOR_06C = "06c"
VECTREX = "vectrex"
VERSATILE = "versatile"
VFLASH = "vflash"
VIC_20 = "vic-20"
VIDEOBRAIN = "videobrain"
VIDEOPAC_G7400 = "videopac-g7400"
VIRTUALBOY = "virtualboy"
VIS = "vis"
VISIONOS = "visionos"
VISUAL_MEMORY_UNIT_SLASH_VISUAL_MEMORY_SYSTEM = (
"visual-memory-unit-slash-visual-memory-system"
)
VMU = "vmu"
VSMILE = "vsmile"
WANG2200 = "wang2200"
WASM_4 = "wasm-4"
WATCHOS = "watchos"
WEBOS = "webos"
WII = "wii"
WIIU = "wiiu"
WIN = "win"
WIN3X = "win3x"
WINDOWS_APPS = "windows-apps"
WINDOWS_MIXED_REALITY = "windows-mixed-reality"
WINDOWS_MOBILE = "windows-mobile"
WINPHONE = "winphone"
WIPI = "wipi"
WONDERSWAN = "wonderswan"
WONDERSWAN_COLOR = "wonderswan-color"
X1 = "x1"
XAVIXPORT = "xavixport"
XBOX = "xbox"
XBOX360 = "xbox360"
XBOXCLOUDGAMING = "xboxcloudgaming"
XBOXONE = "xboxone"
XEROX_ALTO = "xerox-alto"
Z_MACHINE = "z-machine"
Z80 = "z80"
Z88 = "z88"
ZEEBO = "zeebo"
ZILOG_Z8000 = "zilog-z8000"
ZINC = "zinc"
ZOD = "zod"
ZODIAC = "zodiac"
ZUNE = "zune"
ZX_SPECTRUM_NEXT = "zx-spectrum-next"
ZX80 = "zx80"
ZX81 = "zx81"
ZXS = "zxs"