mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
Adds an RBox3D primitive that builds a rotatable, fake-3D game box from three flat ScreenScraper scans (front, back, spine) using CSS 3D transforms. Box proportions derive from the images themselves; it rotates via pointer drag, arrow keys / gamepad D-pad, and the right analog stick, drifts gently when idle, and honours prefers-reduced-motion. The game detail hero (CoverColumn) upgrades to the spinning box when the "3D box" boxart style is selected and the rom has the full set of faces, falling back to the flat cover otherwise. Backend: persist the box-2D-side (spine) scan locally, mirroring the existing box-2D-back handling — new BOX2D_SIDE media type + box2d_side_path on ss_metadata, opt-in via scan.media. - RBox3D primitive + Storybook story (controls + keyboard-rotation play()) - useBoxFaces composable resolving the three faces + a `complete` gate - box3d-alt i18n key across all locales - backend BOX2D_SIDE persistence + tests Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019itLXRfJXGGbhPY3JyqnuN
1098 lines
42 KiB
Python
1098 lines
42 KiB
Python
import html
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Final, NotRequired, TypedDict
|
|
from urllib.parse import 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
|
|
box2d_side_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,
|
|
box2d_side_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
|
|
)
|
|
if MetadataMediaType.BOX2D_SIDE in preferred_media_types:
|
|
ss_media["box2d_side_path"] = (
|
|
f"{fs_resource_handler.get_media_resources_path(rom.platform_id, rom.id, MetadataMediaType.BOX2D_SIDE)}/box2d_side.png"
|
|
)
|
|
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=uc(search_term),
|
|
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
|
|
rom_name = (
|
|
first_file.archive_members[0]["name"].split("/")[-1]
|
|
if first_file.archive_members is not None
|
|
and len(first_file.archive_members) == 1
|
|
else first_file.file_name
|
|
)
|
|
|
|
# Files on NON_HASHABLE_PLATFORMS (or any file when SKIP_HASH_CALCULATION
|
|
# is enabled) have no hashes. jeuInfos can still identify the game from the
|
|
# filename (romnom) + platform (systemeid) — a stronger matcher than the
|
|
# jeuRecherche name search the get_rom fallback uses — so only bail out when
|
|
# we have neither a hash nor a filename to match on.
|
|
if not (md5_hash or sha1_hash or crc_hash or rom_name):
|
|
log.info(
|
|
"No hashes or filename provided for ScreenScraper lookup. "
|
|
"At least one of md5_hash, sha1_hash, crc_hash, or a filename "
|
|
"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=rom_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=uc(search_term),
|
|
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.NEW_NINTENDON3DS: {"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()}
|