diff --git a/backend/handler/metadata/igdb_handler.py b/backend/handler/metadata/igdb_handler.py index c679baf59..69a566cd9 100644 --- a/backend/handler/metadata/igdb_handler.py +++ b/backend/handler/metadata/igdb_handler.py @@ -133,8 +133,48 @@ def build_related_game( ) +def select_preferred_release_date( + rom: Game, + preferred_regions: list[int], + platform_igdb_id: int | None, +) -> int | None: + release_dates = rom.get("release_dates", []) + assert mark_list_expanded(release_dates) + + candidates: list[tuple[int | None, int]] = [] + for release_date in release_dates: + date = release_date.get("date") + if not isinstance(date, int): + continue + + platform = release_date.get("platform") + if platform_igdb_id is not None: + if isinstance(platform, dict): + if platform.get("id") != platform_igdb_id: + continue + elif isinstance(platform, int) and platform != platform_igdb_id: + continue + + region = release_date.get("region") + region_id = region if isinstance(region, int) else None + candidates.append((region_id, date)) + + if not candidates: + return rom.get("first_release_date", None) + + for preferred_region in preferred_regions: + preferred_dates = [date for region_id, date in candidates if region_id == preferred_region] + if preferred_dates: + return min(preferred_dates) + + return min(date for _, date in candidates) + + def extract_metadata_from_igdb_rom( - self: MetadataHandler, rom: Game, platform_igdb_id: int | None + self: MetadataHandler, + rom: Game, + platform_igdb_id: int | None, + preferred_release_regions: list[int], ) -> IGDBMetadata: age_ratings = rom.get("age_ratings", []) alternative_names = rom.get("alternative_names", []) @@ -210,7 +250,9 @@ def extract_metadata_from_igdb_rom( "youtube_video_id": videos[0].get("video_id") if videos else None, "total_rating": str(round(rom.get("total_rating", 0.0), 2)), "aggregated_rating": str(round(rom.get("aggregated_rating", 0.0), 2)), - "first_release_date": rom.get("first_release_date", None), + "first_release_date": select_preferred_release_date( + rom, preferred_release_regions, platform_igdb_id + ), "genres": [g.get("name", "") for g in genres if g.get("name")], "franchises": pydash.compact( [franchise.get("name") if franchise else None] @@ -326,6 +368,17 @@ REGION_TO_IGDB_LOCALE: dict[str, str | None] = { "tw": "zh-TW", # Taiwan (Traditional Chinese) } +REGION_TO_IGDB_RELEASE_DATE: dict[str, int] = { + "eu": 1, # Europe + "us": 2, # North America + "jp": 5, # Japan + "cn": 6, # China + "tw": 7, # Asia + "wor": 8, # Worldwide + "kr": 9, # Korea + "br": 10, # Brazil +} + def get_igdb_preferred_locale(rom: Rom | None = None) -> str | None: """Get IGDB locale, preferring the rom's own region tag when available. @@ -350,6 +403,30 @@ def get_igdb_preferred_locale(rom: Rom | None = None) -> str | None: return None +def get_igdb_preferred_release_regions(rom: Rom | None = None) -> list[int]: + 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) + ) + + ordered_codes = list( + dict.fromkeys(rom_codes + priority + ["us", "wor", "eu", "jp", "kr", "cn", "tw"]) + ) + return [ + REGION_TO_IGDB_RELEASE_DATE[code] + for code in ordered_codes + if code in REGION_TO_IGDB_RELEASE_DATE + ] + + def extract_localized_data(rom: Game, preferred_locale: str | None) -> tuple[str, str]: """Extract localized name and cover URL based on preferred locale. @@ -398,6 +475,7 @@ def build_igdb_rom( handler: "IGDBHandler", rom: Game, preferred_locale: str | None, + preferred_release_regions: list[int], platform_igdb_id: int | None, ) -> "IGDBRom": """Build an IGDBRom from IGDB game data with localization support. @@ -428,7 +506,9 @@ def build_igdb_rom( handler.normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p") for s in rom_screenshots ], - igdb_metadata=extract_metadata_from_igdb_rom(handler, rom, platform_igdb_id), + igdb_metadata=extract_metadata_from_igdb_rom( + handler, rom, platform_igdb_id, preferred_release_regions + ), ) @@ -707,7 +787,11 @@ class IGDBHandler(MetadataHandler): return fallback_rom return build_igdb_rom( - self, res, get_igdb_preferred_locale(rom=rom), platform_igdb_id + self, + res, + get_igdb_preferred_locale(rom=rom), + get_igdb_preferred_release_regions(rom=rom), + platform_igdb_id, ) async def get_rom_by_id(self, rom: Rom, igdb_id: int) -> IGDBRom: @@ -722,7 +806,13 @@ class IGDBHandler(MetadataHandler): if not roms: return IGDBRom(igdb_id=None) - return build_igdb_rom(self, roms[0], get_igdb_preferred_locale(rom=rom), None) + return build_igdb_rom( + self, + roms[0], + get_igdb_preferred_locale(rom=rom), + get_igdb_preferred_release_regions(rom=rom), + None, + ) async def get_matched_rom_by_id(self, rom: Rom, igdb_id: int) -> IGDBRom | None: if not self.is_enabled(): @@ -784,8 +874,11 @@ class IGDBHandler(MetadataHandler): ] preferred_locale = get_igdb_preferred_locale(rom=rom) + preferred_release_regions = get_igdb_preferred_release_regions(rom=rom) return [ - build_igdb_rom(self, rom, preferred_locale, platform_igdb_id) + build_igdb_rom( + self, rom, preferred_locale, preferred_release_regions, platform_igdb_id + ) for rom in matched_roms ] @@ -874,6 +967,9 @@ GAMES_FIELDS = ( "total_rating", "aggregated_rating", "first_release_date", + "release_dates.date", + "release_dates.region", + "release_dates.platform.id", "artworks.url", "cover.url", "screenshots.url", diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py index 2ab2b3797..8ea0279c3 100644 --- a/backend/handler/metadata/ss_handler.py +++ b/backend/handler/metadata/ss_handler.py @@ -374,18 +374,32 @@ def extract_metadata_from_ss_rom(rom: Rom, game: SSGame) -> SSMetadata: 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 = [d for d in dates if d.get("region", "unk") == region] + lowest_region_date = min( + region_dates, default=None, key=lambda v: v.get("text", "") + ) + if lowest_region_date: + return _parse_date(lowest_region_date.get("text", "")) + lowest_date = min(dates, default=None, key=lambda v: v.get("text", "")) if not lowest_date: return None - try: - return int(datetime.strptime(lowest_date["text"], "%Y-%m-%d").timestamp()) - except ValueError: - try: - return int(datetime.strptime(lowest_date["text"], "%Y").timestamp()) - except ValueError: - return None + return _parse_date(lowest_date.get("text", "")) def _get_genres(game: SSGame) -> list[str]: return [ diff --git a/backend/tests/handler/metadata/test_igdb_handler.py b/backend/tests/handler/metadata/test_igdb_handler.py index a50ff54c3..50b492a5b 100644 --- a/backend/tests/handler/metadata/test_igdb_handler.py +++ b/backend/tests/handler/metadata/test_igdb_handler.py @@ -1,11 +1,15 @@ """Tests for the IGDB metadata handler.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from adapters.services.igdb_types import GameType -from handler.metadata.igdb_handler import IGDBHandler +from handler.metadata.igdb_handler import ( + IGDBHandler, + build_igdb_rom, + get_igdb_preferred_release_regions, +) GENESIS_IGDB_ID = 29 @@ -42,6 +46,7 @@ def _make_game(game_id: int, name: str) -> dict: "age_ratings": [], "multiplayer_modes": [], "game_localizations": [], + "release_dates": [], } @@ -155,3 +160,37 @@ class TestSearchRomGameTypeFilter: f"Expected Ecco: The Tides of Time (id=5379), got {result.get('name')} (id={result.get('id')}). " "The expanded search must consider ALL results, not just the first." ) + + +class TestIgdbReleaseDates: + def test_build_igdb_rom_prefers_region_specific_release_date(self): + handler = IGDBHandler() + game = _make_game(5379, "Ecco: The Tides of Time") + game["first_release_date"] = 672537600 # 1991-04-16 + game["release_dates"] = [ + {"date": 632448000, "region": 2, "platform": {"id": GENESIS_IGDB_ID}}, # US + {"date": 593568000, "region": 5, "platform": {"id": GENESIS_IGDB_ID}}, # JP + ] + + rom = build_igdb_rom( + handler=handler, + rom=game, + preferred_locale="ja-JP", + preferred_release_regions=[5, 2, 1], + platform_igdb_id=GENESIS_IGDB_ID, + ) + + assert rom["igdb_metadata"]["first_release_date"] == 593568000 + + def test_get_igdb_preferred_release_regions_prefers_rom_tags(self): + rom = MagicMock() + rom.regions = ["Japan", "USA"] + + with patch( + "handler.metadata.igdb_handler.cm.get_config", + return_value=MagicMock(SCAN_REGION_PRIORITY=["us", "eu"]), + ): + regions = get_igdb_preferred_release_regions(rom=rom) + + assert regions[0] == 2 # North America (US) + assert regions[1] == 5 # Japan diff --git a/backend/tests/handler/metadata/test_ss_handler.py b/backend/tests/handler/metadata/test_ss_handler.py index 66851179d..00279dee4 100644 --- a/backend/tests/handler/metadata/test_ss_handler.py +++ b/backend/tests/handler/metadata/test_ss_handler.py @@ -14,6 +14,7 @@ from handler.metadata.ss_handler import ( _is_notgame, add_ss_auth_to_url, extract_media_from_ss_game, + extract_metadata_from_ss_rom, get_preferred_regions, ) @@ -203,6 +204,35 @@ class TestExtractMediaFromSsGame: assert "box-2D(us)" in result["box2d_url"] +class TestExtractMetadataFromSsRom: + def _make_rom(self, regions: list[str] | None = None) -> MagicMock: + rom = MagicMock() + rom.platform_id = 1 + rom.id = 100 + rom.regions = regions + return rom + + def test_release_date_prefers_tagged_region(self): + config = _make_config(region_priority=[]) + rom = self._make_rom(regions=["Japan", "USA"]) + game = cast( + SSGame, + { + "dates": [ + {"region": "us", "text": "1990-02-12"}, + {"region": "jp", "text": "1988-10-23"}, + {"region": "eu", "text": "1991-08-29"}, + ], + "medias": [], + }, + ) + + with patch("handler.metadata.ss_handler.cm.get_config", return_value=config): + metadata = extract_metadata_from_ss_rom(rom, game) + + assert metadata["first_release_date"] == 593568000 + + class TestIsNotgame: def _game(self, notgame: str = "false", names: list[str] | None = None) -> SSGame: return cast(