Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 123 additions & 64 deletions backend/handler/metadata/igdb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
mark_list_expanded,
)
from config import IGDB_CLIENT_ID, IGDB_CLIENT_SECRET, IS_PYTEST_RUN
from config.config_manager import config_manager as cm
from handler.redis_handler import async_cache
from logger.logger import log
from utils.context import ctx_httpx_client
Expand Down Expand Up @@ -209,6 +210,117 @@ def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMeta
)


# Mapping from scan.priority.region codes to IGDB game_localizations region identifiers
# IGDB's game_localizations provides regional titles and cover art, but NOT localized descriptions
REGION_TO_IGDB_LOCALE: dict[str, str | None] = {
"us": None, # United States - use default (no localization needed)
"wor": None, # World - use default
"eu": "EU", # Europe region
"jp": "ja-JP", # Japan
"kr": "ko-KR", # Korea
"cn": "zh-CN", # China (Simplified Chinese)
"tw": "zh-TW", # Taiwan (Traditional Chinese)
}


def get_igdb_preferred_locale() -> str | None:
"""Get IGDB locale from scan.priority.region configuration.

Maps region priority codes to IGDB's game_localizations region identifiers.
Returns the first matching region from the priority list, or None for default.

Returns:
IGDB region identifier (e.g., "ja-JP", "EU") or None for default
"""
config = cm.get_config()

# Check each region in priority order and return first match
for region in config.SCAN_REGION_PRIORITY:
igdb_locale = REGION_TO_IGDB_LOCALE.get(region.lower())
if igdb_locale is not None:
return igdb_locale

return None


def extract_localized_data(rom: Game, preferred_locale: str | None) -> tuple[str, str]:
"""Extract localized name and cover URL based on preferred locale.

Returns (name, cover_url) - falls back to default if locale not found.
"""
default_name = rom.get("name", "")
default_cover = pydash.get(rom, "cover.url", "")

if not preferred_locale:
return default_name, default_cover

game_localizations = rom.get("game_localizations", [])
if not game_localizations:
return default_name, default_cover

assert mark_list_expanded(game_localizations)

for loc in game_localizations:
region = loc.get("region")
if not region:
continue

assert mark_expanded(region)

# Match locale by region identifier (e.g., "ja-JP", "ko-KR", "EU")
if region.get("identifier") == preferred_locale:
localized_name = loc.get("name") or default_name
localized_cover = loc.get("cover")

if localized_cover:
assert mark_expanded(localized_cover)
cover_url = localized_cover.get("url", "") or default_cover
else:
cover_url = default_cover

return localized_name, cover_url

# Locale not found, fall back to default
log.warning(
f"IGDB locale '{preferred_locale}' not found for '{default_name}', using default"
)
return default_name, default_cover


def build_igdb_rom(
handler: "IGDBHandler", rom: Game, preferred_locale: str | None
) -> "IGDBRom":
"""Build an IGDBRom from IGDB game data with localization support.

Args:
handler: IGDBHandler instance for URL normalization
rom: Game data from IGDB API
preferred_locale: Locale code (e.g., "ja-JP") or None

Returns:
IGDBRom with localized name/cover if available
"""
rom_screenshots = rom.get("screenshots", [])
assert mark_list_expanded(rom_screenshots)

localized_name, localized_cover = extract_localized_data(rom, preferred_locale)

return IGDBRom(
igdb_id=rom["id"],
slug=rom.get("slug", ""),
name=localized_name,
summary=rom.get("summary", ""),
url_cover=handler.normalize_cover_url(localized_cover).replace(
"t_thumb", "t_1080p"
),
url_screenshots=[
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),
)


class IGDBHandler(MetadataHandler):
def __init__(self) -> None:
self.igdb_service = IGDBService(twitch_auth=TwitchAuth())
Expand Down Expand Up @@ -464,23 +576,7 @@ async def get_rom(self, fs_name: str, platform_igdb_id: int) -> IGDBRom:
if not rom:
return fallback_rom

rom_screenshots = rom.get("screenshots", [])
assert mark_list_expanded(rom_screenshots)

return IGDBRom(
igdb_id=rom["id"],
slug=rom.get("slug", ""),
name=rom.get("name", ""),
summary=rom.get("summary", ""),
url_cover=self.normalize_cover_url(
pydash.get(rom, "cover.url", "")
).replace("t_thumb", "t_1080p"),
url_screenshots=[
self.normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom_screenshots
],
igdb_metadata=extract_metadata_from_igdb_rom(self, rom),
)
return build_igdb_rom(self, rom, get_igdb_preferred_locale())

async def get_rom_by_id(self, igdb_id: int) -> IGDBRom:
if not self.is_enabled():
Expand All @@ -494,24 +590,7 @@ async def get_rom_by_id(self, igdb_id: int) -> IGDBRom:
if not roms:
return IGDBRom(igdb_id=None)

rom = roms[0]
rom_screenshots = rom.get("screenshots", [])
assert mark_list_expanded(rom_screenshots)

return IGDBRom(
igdb_id=rom["id"],
slug=rom.get("slug", ""),
name=rom.get("name", ""),
summary=rom.get("summary", ""),
url_cover=self.normalize_cover_url(
pydash.get(rom, "cover.url", "")
).replace("t_thumb", "t_1080p"),
url_screenshots=[
self.normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p")
for s in rom_screenshots
],
igdb_metadata=extract_metadata_from_igdb_rom(self, rom),
)
return build_igdb_rom(self, roms[0], get_igdb_preferred_locale())

async def get_matched_rom_by_id(self, igdb_id: int) -> IGDBRom | None:
if not self.is_enabled():
Expand Down Expand Up @@ -572,33 +651,8 @@ async def get_matched_roms_by_name(
if rom["id"] not in unique_ids
]

return [
IGDBRom(
{ # type: ignore[misc]
k: v
for k, v in {
"igdb_id": rom["id"],
"slug": rom.get("slug", ""),
"name": rom.get("name", ""),
"summary": rom.get("summary", ""),
"url_cover": self.normalize_cover_url(
pydash.get(rom, "cover.url", "").replace(
"t_thumb", "t_1080p"
)
),
"url_screenshots": [
self.normalize_cover_url(s.get("url", "")).replace( # type: ignore[attr-defined]
"t_thumb", "t_720p"
)
for s in rom.get("screenshots", [])
],
"igdb_metadata": extract_metadata_from_igdb_rom(self, rom),
}.items()
if v
}
)
for rom in matched_roms
]
preferred_locale = get_igdb_preferred_locale()
return [build_igdb_rom(self, rom, preferred_locale) for rom in matched_roms]


class TwitchAuth(MetadataHandler):
Expand Down Expand Up @@ -675,6 +729,8 @@ async def get_oauth_token(self) -> str:
return token


SEARCH_FIELDS = ("game.id", "name")

GAMES_FIELDS = (
"id",
"name",
Expand Down Expand Up @@ -725,10 +781,13 @@ async def get_oauth_token(self) -> str:
"similar_games.cover.url",
"age_ratings.rating_category",
"videos.video_id",
"game_localizations.id",
"game_localizations.name",
"game_localizations.cover.url",
"game_localizations.region.identifier",
"game_localizations.region.category",
)

SEARCH_FIELDS = ("game.id", "name")


IGDB_PLATFORM_CATEGORIES: dict[int, str] = {
0: "Unknown",
Expand Down
16 changes: 14 additions & 2 deletions backend/handler/metadata/ss_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def get_preferred_regions() -> list[str]:


def get_preferred_languages() -> list[str]:
"""Get preferred languages from config"""
"""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"]))

Expand Down Expand Up @@ -393,7 +396,9 @@ def build_ss_game(rom: Rom, game: SSGame) -> SSRom:
break

res_summary = ""
for lang in get_preferred_languages():
preferred_languages = get_preferred_languages()
used_lang = None
for lang in preferred_languages:
res_summary = next(
(
synopsis["text"]
Expand All @@ -403,8 +408,15 @@ def build_ss_game(rom: Rom, game: SSGame) -> SSRom:
"",
)
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"]
Expand Down
4 changes: 2 additions & 2 deletions examples/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is
# - "hasheous" # Hasheous
# - "flashpoint" # Flashpoint Project
# - "hltb" # HowLongToBeat
# region: # Cover art and game title (only used by Screenscraper)
# region: # Used by IGDB and ScreenScraper for regional variants
# - "us"
# - "wor"
# - "ss"
# - "eu"
# - "jp"
# language: # Cover art and game title (only used by Screenscraper)
# language: # Used by ScreenScraper for descriptions
# - "en"
# - "fr"
# # Media assets to download
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/views/Settings/MetadataSources.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import RSection from "@/components/common/RSection.vue";
import storeConfig from "@/stores/config";
import storeHeartbeat from "@/stores/heartbeat";

const { t } = useI18n();
const heartbeat = storeHeartbeat();
const configStore = storeConfig();

const heartbeatStatus = ref<Record<string, boolean | undefined>>({
igdb: undefined,
Expand Down Expand Up @@ -131,6 +133,7 @@ function getConnectionStatusTooltip(source: {
}

onMounted(() => {
configStore.fetchConfig();
fetchAllHeartbeats();
});
</script>
Expand Down
Loading