Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 30 additions & 0 deletions backend/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ class MetadataMediaType(enum.StrEnum):
MANUAL = "manual"


# User-facing scan.priority.* keys that each override SCAN_ARTWORK_PRIORITY for a
# single artwork field. Maps the config key to the Rom field it controls.
ARTWORK_PRIORITY_KEYS = {
"cover": "url_cover",
"screenshot": "url_screenshots",
"manual": "url_manual",
}


class EjsControls(TypedDict):
_0: dict[int, EjsControlsButton] # button_number -> EjsControlsButton
_1: dict[int, EjsControlsButton]
Expand Down Expand Up @@ -138,6 +147,9 @@ class Config:
EJS_CONTROLS: dict[str, EjsControls] # core_name -> EjsControls
SCAN_METADATA_PRIORITY: list[str]
SCAN_ARTWORK_PRIORITY: list[str]
# Per-field overrides for SCAN_ARTWORK_PRIORITY, keyed by Rom field name
# (e.g. "url_cover"). Absent fields fall back to SCAN_ARTWORK_PRIORITY.
SCAN_ARTWORK_PRIORITY_OVERRIDES: dict[str, list[str]]
SCAN_REGION_PRIORITY: list[str]
SCAN_LANGUAGE_PRIORITY: list[str]
SCAN_MEDIA: list[str]
Expand Down Expand Up @@ -430,6 +442,12 @@ def _parse_config(self):
"hltb",
],
),
SCAN_ARTWORK_PRIORITY_OVERRIDES={
field: override
for key, field in ARTWORK_PRIORITY_KEYS.items()
if (override := pydash.get(self._raw_config, f"scan.priority.{key}"))
is not None
},
SCAN_REGION_PRIORITY=pydash.get(
self._raw_config,
"scan.priority.region",
Expand Down Expand Up @@ -671,6 +689,13 @@ def _validate_config(self):
log.critical("Invalid config.yml: scan.priority.artwork must be a list")
sys.exit(3)

for key, field in ARTWORK_PRIORITY_KEYS.items():
if field in self.config.SCAN_ARTWORK_PRIORITY_OVERRIDES and not isinstance(
self.config.SCAN_ARTWORK_PRIORITY_OVERRIDES[field], list
):
log.critical(f"Invalid config.yml: scan.priority.{key} must be a list")
sys.exit(3)

if not isinstance(self.config.SCAN_REGION_PRIORITY, list):
log.critical("Invalid config.yml: scan.priority.region must be a list")
sys.exit(3)
Expand Down Expand Up @@ -808,6 +833,11 @@ def _update_config_file(self) -> None:
"priority": {
"metadata": self.config.SCAN_METADATA_PRIORITY,
"artwork": self.config.SCAN_ARTWORK_PRIORITY,
**{
key: self.config.SCAN_ARTWORK_PRIORITY_OVERRIDES[field]
for key, field in ARTWORK_PRIORITY_KEYS.items()
if field in self.config.SCAN_ARTWORK_PRIORITY_OVERRIDES
},
"region": self.config.SCAN_REGION_PRIORITY,
"language": self.config.SCAN_LANGUAGE_PRIORITY,
},
Expand Down
1 change: 1 addition & 0 deletions backend/endpoints/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def get_config(request: Request) -> ConfigResponse:
EJS_SETTINGS=cfg.EJS_SETTINGS,
SCAN_METADATA_PRIORITY=cfg.SCAN_METADATA_PRIORITY,
SCAN_ARTWORK_PRIORITY=cfg.SCAN_ARTWORK_PRIORITY,
SCAN_ARTWORK_PRIORITY_OVERRIDES=cfg.SCAN_ARTWORK_PRIORITY_OVERRIDES,
SCAN_REGION_PRIORITY=cfg.SCAN_REGION_PRIORITY,
SCAN_LANGUAGE_PRIORITY=cfg.SCAN_LANGUAGE_PRIORITY,
SCAN_MEDIA=cfg.SCAN_MEDIA,
Expand Down
1 change: 1 addition & 0 deletions backend/endpoints/responses/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class ConfigResponse(TypedDict):
EJS_CONTROLS: dict[str, EjsControls]
SCAN_METADATA_PRIORITY: list[str]
SCAN_ARTWORK_PRIORITY: list[str]
SCAN_ARTWORK_PRIORITY_OVERRIDES: dict[str, list[str]]
SCAN_REGION_PRIORITY: list[str]
SCAN_LANGUAGE_PRIORITY: list[str]
SCAN_MEDIA: list[str]
Expand Down
31 changes: 18 additions & 13 deletions backend/handler/scan_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ def get_priority_ordered_metadata_sources(

Args:
metadata_sources: List of available metadata sources
priority_type: Type of priority to use ("metadata" or "artwork")
priority_type: Priority list to use: "metadata", "artwork", or an artwork
field name (e.g. "url_cover") that can carry a per-field override.

Returns:
List of metadata sources ordered by priority
Expand All @@ -118,7 +119,11 @@ def get_priority_ordered_metadata_sources(
if priority_type == "metadata":
priority_order = cnfg.SCAN_METADATA_PRIORITY
else:
priority_order = cnfg.SCAN_ARTWORK_PRIORITY
# Per-field artwork overrides win, otherwise fall back to the shared
# artwork priority list.
priority_order = cnfg.SCAN_ARTWORK_PRIORITY_OVERRIDES.get(
priority_type, cnfg.SCAN_ARTWORK_PRIORITY
)
Comment thread
gantoine marked this conversation as resolved.

# Filter priority order to only include sources that are available
ordered_sources = [
Expand Down Expand Up @@ -978,16 +983,16 @@ async def fetch_hasheous_rom(hasheous_rom: HasheousRom) -> HasheousRom:
if field_value:
rom_attrs[key] = field_value

# Artwork sources are prioritized separately
priority_ordered_artwork = get_priority_ordered_metadata_sources(
available_sources, "artwork"
)
# Reverse priority order to apply highest priority last
for source_name in reversed(priority_ordered_artwork):
handler_data = metadata_handlers[source_name]["handler"]
for field in ["url_cover", "url_screenshots", "url_manual"]:
# Artwork sources are prioritized separately, and each field can carry its
# own override on top of the shared artwork priority.
for field in ["url_cover", "url_screenshots", "url_manual"]:
priority_ordered_artwork = get_priority_ordered_metadata_sources(
available_sources, field
)
# Reverse priority order to apply highest priority last
for source_name in reversed(priority_ordered_artwork):
# Only update fields that have valid values
field_value = handler_data.get(field)
field_value = metadata_handlers[source_name]["handler"].get(field)
if field_value:
rom_attrs[field] = field_value

Expand Down Expand Up @@ -1094,7 +1099,7 @@ async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom:
rom_attrs["sgdb_id"] = sgdb_hander_rom["sgdb_id"]

# Apply SGDB's cover only when it outranks every other source that
# already produced one under SCAN_ARTWORK_PRIORITY, and never over a
# already produced one under the cover priority, and never over a
# manually uploaded cover preserved by the UNMATCHED/UPDATE block above.
sgdb_cover = sgdb_hander_rom.get("url_cover")
manual_cover_preserved = (
Expand All @@ -1109,7 +1114,7 @@ async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom:
if fields["handler"].get("url_cover")
]
ranked = get_priority_ordered_metadata_sources(
cover_sources + [MetadataSource.SGDB], "artwork"
cover_sources + [MetadataSource.SGDB], "url_cover"
)
if ranked[0] == MetadataSource.SGDB:
rom_attrs["url_cover"] = sgdb_cover
Expand Down
5 changes: 5 additions & 0 deletions backend/tests/config/fixtures/config/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ scan:
artwork:
- "igdb" # IGDB
- "ss" # Screenscraper
cover:
- "ss"
- "tgdb"
screenshot:
- "igdb"
region:
- jp
- eu
Expand Down
5 changes: 5 additions & 0 deletions backend/tests/config/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ def test_config_loader():
}
assert loader.config.SCAN_METADATA_PRIORITY == ["ss", "launchbox"]
assert loader.config.SCAN_ARTWORK_PRIORITY == ["igdb", "ss"]
assert loader.config.SCAN_ARTWORK_PRIORITY_OVERRIDES == {
"url_cover": ["ss", "tgdb"],
"url_screenshots": ["igdb"],
}
assert loader.config.SCAN_REGION_PRIORITY == ["jp", "eu", "wor"]
assert loader.config.SCAN_LANGUAGE_PRIORITY == ["jp", "es"]
assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box3d"
Expand Down Expand Up @@ -111,6 +115,7 @@ def test_empty_config_loader():
assert loader.config.EJS_NETPLAY_ICE_SERVERS == []
assert loader.config.EJS_SETTINGS == {}
assert loader.config.EJS_CONTROLS == {}
assert loader.config.SCAN_ARTWORK_PRIORITY_OVERRIDES == {}
assert loader.config.GAMELIST_MEDIA_THUMBNAIL == "box2d"
assert loader.config.GAMELIST_MEDIA_IMAGE == "screenshot"

Expand Down
61 changes: 61 additions & 0 deletions backend/tests/handler/test_scan_priority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from types import SimpleNamespace
from unittest.mock import patch

from handler.scan_handler import (
MetadataSource,
get_priority_ordered_metadata_sources,
)


def _fake_config(**overrides):
return SimpleNamespace(
SCAN_METADATA_PRIORITY=["igdb", "moby", "ss"],
SCAN_ARTWORK_PRIORITY=["igdb", "moby", "ss"],
SCAN_ARTWORK_PRIORITY_OVERRIDES=overrides,
)


def test_artwork_field_falls_back_to_shared_priority():
"""A field with no override uses SCAN_ARTWORK_PRIORITY."""
available = [MetadataSource.SS, MetadataSource.IGDB]
with patch("handler.scan_handler.cm.get_config", return_value=_fake_config()):
ordered = get_priority_ordered_metadata_sources(available, "url_cover")

assert ordered == [MetadataSource.IGDB, MetadataSource.SS]


def test_per_field_override_reorders_only_that_field():
"""A cover override wins for url_cover but not for url_screenshots."""
available = [MetadataSource.IGDB, MetadataSource.SS]
config = _fake_config(url_cover=["ss", "igdb"])
with patch("handler.scan_handler.cm.get_config", return_value=config):
cover = get_priority_ordered_metadata_sources(available, "url_cover")
screenshots = get_priority_ordered_metadata_sources(
available, "url_screenshots"
)

# Cover honors the override (ss first)...
assert cover == [MetadataSource.SS, MetadataSource.IGDB]
# ...while screenshots keep the shared artwork order (igdb first).
assert screenshots == [MetadataSource.IGDB, MetadataSource.SS]


def test_sources_absent_from_priority_are_appended():
"""Available sources not named in the priority list still appear, last."""
available = [MetadataSource.SS, MetadataSource.MOBY, MetadataSource.LAUNCHBOX]
config = _fake_config(url_cover=["ss"])
with patch("handler.scan_handler.cm.get_config", return_value=config):
ordered = get_priority_ordered_metadata_sources(available, "url_cover")

assert ordered[0] == MetadataSource.SS
assert set(ordered) == set(available)


def test_metadata_priority_is_unaffected_by_artwork_overrides():
"""Artwork overrides must never leak into the metadata priority pass."""
available = [MetadataSource.SS, MetadataSource.IGDB]
config = _fake_config(url_cover=["ss", "igdb"])
with patch("handler.scan_handler.cm.get_config", return_value=config):
ordered = get_priority_ordered_metadata_sources(available, "metadata")

assert ordered == [MetadataSource.IGDB, MetadataSource.SS]
13 changes: 12 additions & 1 deletion examples/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
# - hasheous # Hasheous
# - flashpoint # Flashpoint Project
# - hltb # HowLongToBeat (lowest priority)
# artwork: # Cover art and screenshots
# artwork: # Cover art and screenshots (fallback for the fields below)
# - igdb # IGDB
# - moby # MobyGames
# - ss # Screenscraper
Expand All @@ -102,6 +102,17 @@
# - hasheous # Hasheous
# - flashpoint # Flashpoint Project
# - hltb # HowLongToBeat
# # Optional per-field overrides. Each falls back to "artwork" when omitted,
# # so you can, for example, keep original box art while pulling screenshots
# # from a different source.
# cover: # Cover art only
# - igdb
# - ss
# screenshot: # Screenshots only
# - ss
# - igdb
# manual: # Game manuals only
# - launchbox
# region: # Used by IGDB and ScreenScraper for regional variants
# - us
# - wor
Expand Down
1 change: 1 addition & 0 deletions frontend/src/__generated__/models/ConfigResponse.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const defaultConfig = {
EJS_CONTROLS: {},
SCAN_METADATA_PRIORITY: [],
SCAN_ARTWORK_PRIORITY: [],
SCAN_ARTWORK_PRIORITY_OVERRIDES: {},
SCAN_REGION_PRIORITY: [],
SCAN_LANGUAGE_PRIORITY: [],
SCAN_MEDIA: [],
Expand Down
Loading