Files
romm/backend/handler/metadata/ra_handler.py
T
Georges-Antoine Assi f2df03361e fix: cast spread-dict back to RAUserGameProgression to preserve typing
Pylance infers dict[str, Unknown] from the {**old, "highest_award_kind": ...}
spread, which then fails to assign to list[RAUserGameProgression]. Wrap in
typing.cast so the TypedDict type survives the reassignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:08:18 -04:00

477 lines
18 KiB
Python

import json
import os
import re
import time
from datetime import datetime
from typing import NotRequired, TypedDict, cast
import pydash
from anyio import Path as AnyioPath
from adapters.services.retroachievements import RetroAchievementsService
from adapters.services.retroachievements_types import (
RAGameExtendedDetails,
RAGameListItem,
)
from config import (
REFRESH_RETROACHIEVEMENTS_CACHE_DAYS,
RETROACHIEVEMENTS_API_KEY,
)
from handler.filesystem import fs_resource_handler
from logger.logger import log
from models.rom import Rom
from .base_handler import BaseRom, MetadataHandler
from .base_handler import UniversalPlatformSlug as UPS
# Regex to detect RetroAchievements ID tags in filenames like (ra-12345)
RA_TAG_REGEX = re.compile(r"\(ra-(\d+)\)", re.IGNORECASE)
class RAGamesPlatform(TypedDict):
slug: str
ra_id: int | None
name: NotRequired[str]
class RAGameRomAchievement(TypedDict):
ra_id: int | None
title: str | None
description: str | None
points: int | None
num_awarded: int | None
num_awarded_hardcore: int | None
badge_id: str | None
badge_url_lock: str | None
badge_path_lock: str | None
badge_url: str | None
badge_path: str | None
display_order: int | None
type: str | None
class RAMetadata(TypedDict):
first_release_date: int | None
genres: list[str]
companies: list[str]
achievements: list[RAGameRomAchievement]
class RAGameRom(BaseRom):
ra_id: int | None
ra_metadata: NotRequired[RAMetadata]
class EarnedAchievement(TypedDict):
id: str
date: str
date_hardcore: NotRequired[str]
class RAUserGameProgression(TypedDict):
rom_ra_id: int | None
max_possible: int | None
num_awarded: int | None
num_awarded_hardcore: int | None
most_recent_awarded_date: NotRequired[str | None]
highest_award_kind: NotRequired[str | None]
earned_achievements: list[EarnedAchievement]
class RAUserProgression(TypedDict):
total: int
results: list[RAUserGameProgression]
def extract_metadata_from_rom_details(
rom: Rom, rom_details: RAGameExtendedDetails
) -> RAMetadata:
def parse_release_timestamp():
release_date_str = rom_details.get("Released")
if not release_date_str:
return None
try:
# Extract date part (assuming format: "YYYY-MM-DD [additional info]")
parsed_date = datetime.strptime(release_date_str.split()[0], "%Y-%m-%d")
return int(parsed_date.timestamp())
except (AttributeError, ValueError, IndexError):
return None
return RAMetadata(
first_release_date=parse_release_timestamp(),
genres=pydash.compact([rom_details.get("Genre", None)]),
companies=pydash.compact(
[rom_details.get("Publisher", None), rom_details.get("Developer", None)]
),
achievements=[
RAGameRomAchievement(
ra_id=achievement.get("ID", None),
title=achievement.get("Title", ""),
description=achievement.get("Description", ""),
points=achievement.get("Points", None),
num_awarded=achievement.get("NumAwarded", None),
num_awarded_hardcore=achievement.get("NumAwardedHardcore", None),
badge_id=achievement.get("BadgeName", ""),
badge_url_lock=f"https://media.retroachievements.org/Badge/{achievement.get('BadgeName', '')}_lock.png",
badge_path_lock=f"{fs_resource_handler.get_ra_badges_path(rom.platform.id, rom.id)}/{achievement.get('BadgeName', '')}_lock.png",
badge_url=f"https://media.retroachievements.org/Badge/{achievement.get('BadgeName', '')}.png",
badge_path=f"{fs_resource_handler.get_ra_badges_path(rom.platform.id, rom.id)}/{achievement.get('BadgeName', '')}.png",
display_order=achievement.get("DisplayOrder", None),
type=achievement.get("type", ""),
)
for achievement in rom_details.get("Achievements", {}).values()
],
)
class RAHandler(MetadataHandler):
def __init__(self) -> None:
self.ra_service = RetroAchievementsService()
self.HASHES_FILE_NAME = "ra_hashes.json"
@classmethod
def is_enabled(cls) -> bool:
return bool(RETROACHIEVEMENTS_API_KEY)
async def heartbeat(self) -> bool:
if not self.is_enabled():
return False
try:
response = await self.ra_service.get_achievement_of_the_week()
except Exception as e:
log.error("Error checking RetroAchievements API: %s", e)
return False
return bool(response)
@staticmethod
def extract_ra_id_from_filename(fs_name: str) -> int | None:
"""Extract RetroAchievements ID from filename tag like (ra-12345)."""
match = RA_TAG_REGEX.search(fs_name)
if match:
return int(match.group(1))
return None
def _get_hashes_file_path(self, platform_id: int) -> str:
platform_resources_path = fs_resource_handler.get_platform_resources_path(
platform_id
)
return os.path.join(platform_resources_path, self.HASHES_FILE_NAME)
async def _exists_cache_file(self, platform_id: int) -> bool:
return await fs_resource_handler.file_exists(
self._get_hashes_file_path(platform_id)
)
async def _days_since_last_cache_file_update(self, platform_id: int) -> int:
file_path = self._get_hashes_file_path(platform_id)
if not await fs_resource_handler.file_exists(file_path):
return REFRESH_RETROACHIEVEMENTS_CACHE_DAYS + 1
full_path = fs_resource_handler.validate_path(file_path)
file_stat = await AnyioPath(str(full_path)).stat()
return int((time.time() - file_stat.st_mtime) / (24 * 3600))
async def _search_rom(self, rom: Rom, ra_hash: str) -> RAGameListItem | None:
if not rom.platform.ra_id:
return None
# Fetch all hashes for specific platform
roms: list[RAGameListItem]
if (
REFRESH_RETROACHIEVEMENTS_CACHE_DAYS
<= await self._days_since_last_cache_file_update(rom.platform.id)
or not await self._exists_cache_file(rom.platform.id)
):
# Write the roms result to a JSON file if older than REFRESH_RETROACHIEVEMENTS_CACHE_DAYS days
roms = await self.ra_service.get_game_list(
system_id=rom.platform.ra_id,
only_games_with_achievements=True,
include_hashes=True,
)
platform_resources_path = fs_resource_handler.get_platform_resources_path(
rom.platform.id
)
json_file = json.dumps(roms, indent=4)
await fs_resource_handler.write_file(
json_file.encode("utf-8"),
platform_resources_path,
self.HASHES_FILE_NAME,
)
else:
# Read the roms result from the JSON file
json_file_bytes = await fs_resource_handler.read_file(
self._get_hashes_file_path(rom.platform.id)
)
roms = json.loads(json_file_bytes.decode("utf-8"))
ra_hash_lower = ra_hash.lower()
for r in roms:
if any(ra_hash_lower == h.lower() for h in r.get("Hashes", ())):
return r
return None
def get_platform(self, slug: str) -> RAGamesPlatform:
if slug not in RA_PLATFORM_LIST:
return RAGamesPlatform(ra_id=None, slug=slug)
platform = RA_PLATFORM_LIST[UPS(slug)]
return RAGamesPlatform(
ra_id=platform["id"],
slug=slug,
name=platform["name"],
)
async def get_rom(self, rom: Rom, ra_hash: str) -> RAGameRom:
if not rom.platform.ra_id:
return RAGameRom(ra_id=None)
# Check for RetroAchievements ID tag in filename first
ra_id_from_tag = self.extract_ra_id_from_filename(rom.fs_name)
if ra_id_from_tag:
log.debug(f"Found RetroAchievements ID tag in filename: {ra_id_from_tag}")
rom_by_id = await self.get_rom_by_id(rom=rom, ra_id=ra_id_from_tag)
if rom_by_id["ra_id"]:
log.debug(
f"Successfully matched ROM by RetroAchievements ID tag: {rom.fs_name} -> {ra_id_from_tag}"
)
return rom_by_id
else:
log.warning(
f"RetroAchievements ID {ra_id_from_tag} from filename tag not found in RetroAchievements"
)
if not ra_hash:
return RAGameRom(ra_id=None)
ra_game_list_item = await self._search_rom(rom, ra_hash)
if not ra_game_list_item:
return RAGameRom(ra_id=None)
try:
rom_details = await self.ra_service.get_game_extended_details(
ra_game_list_item["ID"]
)
return RAGameRom(
ra_id=rom_details["ID"],
name=rom_details.get("Title", ""),
url_cover=(
f"https://retroachievements.org{rom_details['ImageTitle']}"
if rom_details.get("ImageTitle")
else ""
),
url_screenshots=pydash.compact(
[
(
f"https://retroachievements.org{rom_details['ImageIngame']}"
if rom_details.get("ImageIngame")
else None
)
]
),
ra_metadata=extract_metadata_from_rom_details(rom, rom_details),
)
except KeyError:
return RAGameRom(ra_id=None)
async def get_rom_by_id(self, rom: Rom, ra_id: int) -> RAGameRom:
if not ra_id:
return RAGameRom(ra_id=None)
try:
rom_details = await self.ra_service.get_game_extended_details(ra_id)
return RAGameRom(
ra_id=rom_details["ID"],
name=rom_details.get("Title", ""),
url_cover=(
f"https://media.retroachievements.org{rom_details['ImageTitle']}"
if rom_details.get("ImageTitle")
else ""
),
url_screenshots=pydash.compact(
[
(
f"https://media.retroachievements.org{rom_details['ImageIngame']}"
if rom_details.get("ImageIngame")
else None
)
]
),
ra_metadata=extract_metadata_from_rom_details(rom, rom_details),
)
except KeyError:
return RAGameRom(ra_id=None)
async def get_user_progression(
self,
username: str,
current_progression: RAUserProgression | None = None,
) -> RAUserProgression:
"""Retrieves the user's RetroAchievements progression.
If `current_progression` is provided, it will only incrementally update the
progression based on new achievements since the last check.
"""
game_progressions: list[RAUserGameProgression] = []
current_progression_by_game_id: dict[int | None, RAUserGameProgression] = {}
if current_progression:
current_progression_by_game_id = {
p["rom_ra_id"]: p for p in current_progression.get("results", [])
}
async for rom in self.ra_service.iter_user_completion_progress(username):
rom_game_id = rom.get("GameID")
highest_award_kind = rom.get("HighestAwardKind", None)
# If we have current progression data, and number of awarded achievements and most
# recent awarded date match, then we can skip fetching progression details.
game_current_progression = current_progression_by_game_id.get(rom_game_id)
if (
game_current_progression
and rom["NumAwarded"] == game_current_progression.get("num_awarded")
and rom["NumAwardedHardcore"]
== game_current_progression.get("num_awarded_hardcore")
and rom["MostRecentAwardedDate"]
== game_current_progression.get("most_recent_awarded_date")
):
# Always keep highest_award_kind up-to-date even for cached progressions
if (
game_current_progression.get("highest_award_kind")
!= highest_award_kind
):
game_current_progression = cast(
RAUserGameProgression,
{
**game_current_progression,
"highest_award_kind": highest_award_kind,
},
)
game_progressions.append(game_current_progression)
continue
earned_achievements: list[EarnedAchievement] = []
if rom_game_id:
result = await self.ra_service.get_user_game_progress(
username=username,
game_id=rom_game_id,
)
for achievement in result.get("Achievements", {}).values():
badge_name = achievement.get("BadgeName")
date_earned = achievement.get("DateEarned")
date_earned_hardcore = achievement.get("DateEarnedHardcore")
if badge_name and date_earned:
earned_achievement = EarnedAchievement(
id=badge_name,
date=date_earned,
)
if date_earned_hardcore:
earned_achievement["date_hardcore"] = date_earned_hardcore
earned_achievements.append(earned_achievement)
game_progressions.append(
RAUserGameProgression(
rom_ra_id=rom_game_id,
max_possible=rom.get("MaxPossible", None),
num_awarded=rom.get("NumAwarded", None),
num_awarded_hardcore=rom.get("NumAwardedHardcore", None),
most_recent_awarded_date=rom.get("MostRecentAwardedDate", None),
highest_award_kind=highest_award_kind,
earned_achievements=earned_achievements,
)
)
return RAUserProgression(
total=len(game_progressions),
results=game_progressions,
)
class SlugToRAId(TypedDict):
id: int
name: str
RA_PLATFORM_LIST: dict[UPS, SlugToRAId] = {
UPS._3DO: {"id": 43, "name": "3DO"},
UPS.ACPC: {"id": 37, "name": "Amstrad CPC"},
UPS.APPLEII: {"id": 38, "name": "Apple II"},
UPS.ARCADE: {"id": 27, "name": "Arcade"},
UPS.ARCADIA_2001: {"id": 73, "name": "Arcadia 2001"},
UPS.ARDUBOY: {"id": 71, "name": "Arduboy"},
UPS.ATARI2600: {"id": 25, "name": "Atari 2600"},
UPS.ATARI7800: {"id": 51, "name": "Atari 7800"},
UPS.ATARI_JAGUAR_CD: {"id": 77, "name": "Atari Jaguar CD"},
UPS.COLECOVISION: {"id": 44, "name": "ColecoVision"},
UPS.DC: {"id": 40, "name": "Dreamcast"},
UPS.ELEKTOR: {"id": 75, "name": "Elektor"},
UPS.FAIRCHILD_CHANNEL_F: {
"id": 57,
"name": "Fairchild Channel F",
},
UPS.GB: {"id": 4, "name": "Game Boy"},
UPS.GBA: {"id": 5, "name": "Game Boy Advance"},
UPS.GBC: {"id": 6, "name": "Game Boy Color"},
UPS.GAMEGEAR: {"id": 15, "name": "Game Gear"},
UPS.NGC: {"id": 16, "name": "GameCube"},
UPS.GENESIS: {"id": 1, "name": "Genesis/Mega Drive"},
UPS.INTELLIVISION: {"id": 45, "name": "Intellivision"},
UPS.INTERTON_VC_4000: {"id": 74, "name": "Interton VC 4000"},
UPS.JAGUAR: {"id": 17, "name": "Jaguar"},
UPS.LYNX: {"id": 13, "name": "Lynx"},
UPS.MSX: {"id": 29, "name": "MSX"},
UPS.MEGA_DUCK_SLASH_COUGAR_BOY: {
"id": 69,
"name": "Mega Duck/Cougar Boy",
},
UPS.NES: {"id": 7, "name": "NES"},
UPS.FAMICOM: {"id": 7, "name": "Family Computer"},
UPS.NEO_GEO_CD: {"id": 56, "name": "Neo Geo CD"},
UPS.NEO_GEO_POCKET: {"id": 14, "name": "Neo Geo Pocket"},
UPS.NEO_GEO_POCKET_COLOR: {
"id": 14,
"name": "Neo Geo Pocket Color",
},
UPS.N64: {"id": 2, "name": "Nintendo 64"},
UPS.NDS: {"id": 18, "name": "Nintendo DS"},
UPS.NINTENDO_DSI: {"id": 78, "name": "Nintendo DSi"},
UPS.ODYSSEY_2: {"id": 23, "name": "Odyssey 2"},
UPS.PC_8800_SERIES: {"id": 47, "name": "PC-8800 Series"},
UPS.PC_FX: {"id": 49, "name": "PC-FX"},
UPS.PSP: {"id": 41, "name": "PSP"},
UPS.PSX: {"id": 12, "name": "PlayStation"},
UPS.PS2: {"id": 21, "name": "PlayStation 2"},
UPS.POKEMON_MINI: {"id": 24, "name": "Pokémon Mini"},
UPS.SATURN: {"id": 39, "name": "Sega Saturn"},
UPS.SEGA32: {"id": 10, "name": "SEGA 32X"},
UPS.SEGACD: {"id": 9, "name": "SEGA CD"},
UPS.SMS: {"id": 11, "name": "SEGA Master System"},
UPS.SG1000: {"id": 33, "name": "SG-1000"},
UPS.SNES: {"id": 3, "name": "SNES"},
UPS.SFAM: {"id": 3, "name": "Super Famicom"},
UPS.TURBOGRAFX_CD: {"id": 76, "name": "TurboGrafx CD"},
UPS.TG16: {"id": 8, "name": "TurboGrafx-16"},
UPS.UZEBOX: {"id": 80, "name": "Uzebox"},
UPS.VECTREX: {"id": 46, "name": "Vectrex"},
UPS.VIRTUALBOY: {"id": 28, "name": "Virtual Boy"},
UPS.WASM_4: {"id": 72, "name": "WASM-4"},
UPS.SUPERVISION: {
"id": 63,
"name": "Watara/QuickShot Supervision",
},
UPS.WIN: {"id": 102, "name": "Windows"},
UPS.WII: {"id": 19, "name": "Wii"},
UPS.WONDERSWAN: {"id": 53, "name": "WonderSwan"},
UPS.WONDERSWAN_COLOR: {"id": 53, "name": "WonderSwan Color"},
}
# Reverse lookup
RA_ID_TO_SLUG = {v["id"]: k for k, v in RA_PLATFORM_LIST.items()}