Merge branch 'master' into copilot/fix-m3u-disc-selection-issue

This commit is contained in:
Georges-Antoine Assi
2026-05-21 18:39:33 -04:00
8 changed files with 405 additions and 28 deletions

View File

@@ -374,13 +374,25 @@ class FSRomsHandler(FSHandler):
def exclude_multi_roms(self, roms: list[str]) -> list[str]:
excluded_names = cm.get_config().EXCLUDED_MULTI_FILES
filtered_files: list = []
normalized_patterns = [
excluded_name.lower().strip() for excluded_name in excluded_names
]
kept_roms: list[str] = []
for rom in roms:
if rom in excluded_names:
filtered_files.append(rom)
normalized_rom_name = rom.strip().lower()
if normalized_rom_name in normalized_patterns:
continue
return [f for f in roms if f not in filtered_files]
if any(
fnmatch.fnmatch(normalized_rom_name, pattern)
for pattern in normalized_patterns
):
continue
kept_roms.append(rom)
return kept_roms
def _iter_m3u_referenced_paths(self, abs_fs_path: Path, m3u_file_name: str) -> Iterator[Path]:
m3u_path = Path(abs_fs_path, m3u_file_name)

View File

@@ -462,6 +462,7 @@ class IGDBHandler(MetadataHandler):
GameType.PORT,
GameType.REMAKE,
GameType.REMASTER,
GameType.STANDALONE_EXPANSION,
)
game_type_filter = f"& game_type=({','.join(map(str, categories))})"
else:
@@ -509,14 +510,26 @@ class IGDBHandler(MetadataHandler):
limit=self.pagination_limit,
)
if roms_expanded:
log.debug(
"Searching expanded in games endpoint for expanded game %s",
roms_expanded[0]["game"],
# Collect all unique game IDs from the expanded search results,
# skipping entries without a valid game id.
unique_game_ids = list(
dict.fromkeys(
game_id
for r in roms_expanded
if (g := r.get("game")) and (game_id := g.get("id")) is not None
)
)
if unique_game_ids:
log.debug(
"Searching expanded in games endpoint for %d candidate game(s): %s",
len(unique_game_ids),
unique_game_ids,
)
id_filter = " | ".join(f"id={gid}" for gid in unique_game_ids)
extra_roms = await self.igdb_service.list_games(
fields=GAMES_FIELDS,
where=f"id={roms_expanded[0]['game']['id']}",
where=f"({id_filter})",
limit=self.pagination_limit,
)

View File

@@ -412,7 +412,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.hasheous_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.hasheous_id
and (not rom.hasheous_id or not rom.hasheous_metadata)
and rom.platform_slug in HASHEOUS_PLATFORM_LIST
)
)
@@ -463,7 +463,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.igdb_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.igdb_id
and (not rom.igdb_id or not rom.igdb_metadata)
and rom.platform_slug in IGDB_PLATFORM_LIST
)
)
@@ -509,7 +509,10 @@ async def scan_rom(
newly_added
or scan_type == ScanType.COMPLETE
or (scan_type == ScanType.UPDATE and rom.gamelist_id)
or (scan_type == ScanType.UNMATCHED and not rom.gamelist_id)
or (
scan_type == ScanType.UNMATCHED
and (not rom.gamelist_id or not rom.gamelist_metadata)
)
):
return await meta_gamelist_handler.get_rom(
rom_attrs["fs_name"], platform, rom
@@ -527,12 +530,16 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.flashpoint_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.flashpoint_id
and (not rom.flashpoint_id or not rom.flashpoint_metadata)
and platform.slug in FLASHPOINT_PLATFORM_LIST
)
)
):
if scan_type == ScanType.UPDATE and rom.flashpoint_id:
if (scan_type == ScanType.UPDATE and rom.flashpoint_id) or (
scan_type == ScanType.UNMATCHED
and rom.flashpoint_id
and not rom.flashpoint_metadata
):
return await meta_flashpoint_handler.get_rom_by_id(rom.flashpoint_id)
else:
return await meta_flashpoint_handler.get_rom(
@@ -566,7 +573,10 @@ async def scan_rom(
newly_added
or scan_type == ScanType.COMPLETE
or (scan_type == ScanType.UPDATE and rom.hltb_id)
or (scan_type == ScanType.UNMATCHED and not rom.hltb_id)
or (
scan_type == ScanType.UNMATCHED
and (not rom.hltb_id or not rom.hltb_metadata)
)
)
):
return await meta_hltb_handler.get_rom(rom_attrs["fs_name"], platform.slug)
@@ -583,7 +593,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.moby_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.moby_id
and (not rom.moby_id or not rom.moby_metadata)
and rom.platform_slug in MOBYGAMES_PLATFORM_LIST
)
)
@@ -615,7 +625,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.ss_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.ss_id
and (not rom.ss_id or not rom.ss_metadata)
and rom.platform_slug in SCREENSAVER_PLATFORM_LIST
)
)
@@ -656,7 +666,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.launchbox_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.launchbox_id
and (not rom.launchbox_id or not rom.launchbox_metadata)
and rom.platform_slug in LAUNCHBOX_PLATFORM_LIST
)
):
@@ -671,6 +681,19 @@ async def scan_rom(
fs_name=rom_attrs["fs_name"],
platform_slug=platform_slug,
)
elif (
scan_type == ScanType.UNMATCHED
and rom.launchbox_id
and not rom.launchbox_metadata
and launchbox_remote_enabled
):
# ID was set manually but metadata was never fetched
launchbox_rom = await meta_launchbox_handler.get_rom_by_id(
rom.launchbox_id,
remote_enabled=True,
fs_name=rom_attrs["fs_name"],
platform_slug=platform_slug,
)
elif playmatch_rom["launchbox_id"] is not None and launchbox_remote_enabled:
log.debug(
f"{hl(rom_attrs['fs_name'])} identified by Playmatch as LaunchBox "
@@ -709,7 +732,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.ra_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.ra_id
and (not rom.ra_id or not rom.ra_metadata)
and rom.platform_slug in RA_PLATFORM_LIST
)
)
@@ -724,7 +747,9 @@ async def scan_rom(
)
return await meta_ra_handler.get_rom_by_id(rom=rom, ra_id=h_ra_id)
if scan_type == ScanType.UPDATE and rom.ra_id:
if (scan_type == ScanType.UPDATE and rom.ra_id) or (
scan_type == ScanType.UNMATCHED and rom.ra_id and not rom.ra_metadata
):
return await meta_ra_handler.get_rom_by_id(rom=rom, ra_id=rom.ra_id)
else:
return await meta_ra_handler.get_rom(
@@ -743,7 +768,7 @@ async def scan_rom(
or (scan_type == ScanType.UPDATE and rom.hasheous_id)
or (
scan_type == ScanType.UNMATCHED
and not rom.hasheous_id
and (not rom.hasheous_id or not rom.hasheous_metadata)
and rom.platform_slug in HASHEOUS_PLATFORM_LIST
)
)

View File

@@ -249,6 +249,41 @@ class TestFSRomsHandler:
result = handler.exclude_multi_roms(roms)
assert result == roms
def test_exclude_multi_roms_case_insensitive(self, handler: FSRomsHandler, config):
"""Test exclude_multi_roms ignores case in excluded names"""
roms = ["Game1", "Manuals", "Game2"]
config.EXCLUDED_MULTI_FILES = ["manuals"]
with pytest.MonkeyPatch.context() as m:
m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config)
result = handler.exclude_multi_roms(roms)
assert result == ["Game1", "Game2"]
def test_exclude_multi_roms_ignores_whitespace(
self, handler: FSRomsHandler, config
):
"""Test exclude_multi_roms trims accidental surrounding whitespace"""
roms = ["Game1", "covers", "Game2"]
config.EXCLUDED_MULTI_FILES = [" covers "]
with pytest.MonkeyPatch.context() as m:
m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config)
result = handler.exclude_multi_roms(roms)
assert result == ["Game1", "Game2"]
def test_exclude_multi_roms_wildcard_patterns(self, handler: FSRomsHandler, config):
"""Test exclude_multi_roms keeps wildcard matching with normalized config"""
roms = ["Game1", "Manuals", "manuals-fr", "Game2"]
config.EXCLUDED_MULTI_FILES = [" manuals* "]
with pytest.MonkeyPatch.context() as m:
m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config)
result = handler.exclude_multi_roms(roms)
assert result == ["Game1", "Game2"]
def test_build_rom_file_single_file(self, rom_single: Rom, handler: FSRomsHandler):
"""Test _build_rom_file with actual single ROM file"""
rom_path = Path(rom_single.fs_path)

View File

@@ -0,0 +1,157 @@
"""Tests for the IGDB metadata handler."""
from unittest.mock import AsyncMock, patch
import pytest
from adapters.services.igdb_types import GameType
from handler.metadata.igdb_handler import IGDBHandler
GENESIS_IGDB_ID = 29
def _make_game(game_id: int, name: str) -> dict:
"""Build a minimal IGDB Game dict for testing."""
return {
"id": game_id,
"name": name,
"slug": name.lower().replace(" ", "-"),
"summary": "",
"total_rating": 0.0,
"aggregated_rating": 0.0,
"first_release_date": None,
"artworks": [],
"cover": None,
"screenshots": [],
"platforms": [{"id": GENESIS_IGDB_ID, "name": "Sega Mega Drive/Genesis"}],
"alternative_names": [],
"genres": [],
"franchise": None,
"franchises": [],
"collections": [],
"game_modes": [],
"involved_companies": [],
"expansions": [],
"dlcs": [],
"remasters": [],
"remakes": [],
"expanded_games": [],
"ports": [],
"similar_games": [],
"videos": [],
"age_ratings": [],
"multiplayer_modes": [],
"game_localizations": [],
}
class TestSearchRomGameTypeFilter:
"""Tests for _search_rom game_type filtering."""
@pytest.mark.asyncio
async def test_standalone_expansion_included_in_game_type_filter(self):
"""Searching with game_type filter must include STANDALONE_EXPANSION
so that games like 'Ecco: The Tides of Time' are found on the first
search pass and not confused with their parent game."""
handler = IGDBHandler()
ecco_dolphin = _make_game(1799, "Ecco the Dolphin")
ecco_tides = _make_game(5379, "Ecco: The Tides of Time")
async def mock_list_games(
search_term=None, fields=None, where=None, limit=None
):
# First call (with game_type filter): return both games
if where and "game_type" in where:
# Verify STANDALONE_EXPANSION (4) is in the filter
assert (
str(int(GameType.STANDALONE_EXPANSION)) in where
), f"STANDALONE_EXPANSION should be in game_type filter, got: {where}"
# Simulate IGDB returning both games when the search includes
# standalone expansions
if search_term and "tides of time" in search_term.lower():
return [ecco_dolphin, ecco_tides]
return [ecco_dolphin]
return []
with (
patch(
"handler.metadata.igdb_handler.IGDBHandler.is_enabled",
return_value=True,
),
patch.object(
handler.igdb_service,
"list_games",
side_effect=mock_list_games,
),
patch.object(
handler.igdb_service,
"search",
new_callable=AsyncMock,
return_value=[],
),
):
result = await handler._search_rom(
"ecco the tides of time", GENESIS_IGDB_ID, with_game_type=True
)
assert result is not None
assert (
result["id"] == 5379
), f"Expected Ecco: The Tides of Time (id=5379), got {result.get('name')} (id={result.get('id')})"
@pytest.mark.asyncio
async def test_expanded_search_uses_all_results_not_just_first(self):
"""When the primary search fails and the expanded IGDB search endpoint
is used, all unique game IDs from the results must be fetched and
the best match selected — not just the first result."""
handler = IGDBHandler()
ecco_dolphin = _make_game(1799, "Ecco the Dolphin")
ecco_tides = _make_game(5379, "Ecco: The Tides of Time")
# Primary search returns nothing useful
async def mock_list_games(
search_term=None, fields=None, where=None, limit=None
):
if where and "game_type" not in where and not where.startswith("("):
# Primary search pass — return no results so we fall through to
# the expanded search
return []
if where and where.startswith("("):
# Expanded game details lookup — return both candidates
return [ecco_dolphin, ecco_tides]
return []
# Expanded search returns two results — wrong game FIRST, correct game second
expanded_results = [
{"game": {"id": 1799}, "name": "Ecco the Dolphin"},
{"game": {"id": 5379}, "name": "Ecco: The Tides of Time"},
]
with (
patch(
"handler.metadata.igdb_handler.IGDBHandler.is_enabled",
return_value=True,
),
patch.object(
handler.igdb_service,
"list_games",
side_effect=mock_list_games,
),
patch.object(
handler.igdb_service,
"search",
new_callable=AsyncMock,
return_value=expanded_results,
),
):
result = await handler._search_rom(
"ecco the tides of time", GENESIS_IGDB_ID, with_game_type=False
)
assert result is not None
assert result["id"] == 5379, (
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."
)

View File

@@ -3,8 +3,13 @@ from unittest.mock import AsyncMock, patch
import pytest
from handler.database import db_platform_handler, db_rom_handler
from handler.metadata import meta_hasheous_handler, meta_playmatch_handler
from handler.metadata import (
meta_hasheous_handler,
meta_playmatch_handler,
meta_ra_handler,
)
from handler.metadata.hasheous_handler import HasheousRom
from handler.metadata.ra_handler import RAGameRom
from handler.scan_handler import MetadataSource, ScanType, scan_platform, scan_rom
from models.platform import Platform
from models.rom import Rom, RomFile
@@ -174,3 +179,133 @@ async def test_scan_rom_complete_clears_unselected_metadata(
assert result.ra_metadata == {}
# Hasheous is still selected and should remain populated.
assert result.hasheous_id == 999
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
async def test_scan_rom_unmatched_fetches_ra_when_id_set_but_no_metadata(
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
):
"""UNMATCHED scan must fetch RA metadata when ra_id is set manually but
ra_metadata is empty (the user manually set the ID)."""
ra_result = RAGameRom(
ra_id=2774,
name="Jak and Daxter: The Precursor's Legacy",
url_cover="https://media.retroachievements.org/Images/jpg",
)
mock_get_rom_by_id.return_value = ra_result
mock_get_rom.return_value = RAGameRom(ra_id=None)
platform = Platform(
id=1,
slug="ps2",
fs_slug="ps2",
name="PlayStation 2",
igdb_id=8,
ra_id=21,
)
platform = db_platform_handler.add_platform(platform)
# ROM has ra_id set manually but no ra_metadata (never fetched before)
rom = Rom(
platform_id=platform.id,
fs_name="Jak and Daxter.chd",
fs_name_no_tags="Jak and Daxter",
fs_name_no_ext="Jak and Daxter",
fs_extension="chd",
fs_path="ps2",
name="Jak and Daxter",
ra_id=2774,
ra_metadata={}, # empty - never fetched
fs_size_bytes=1024,
tags=[],
)
rom = db_rom_handler.add_rom(rom)
async with initialize_context():
result = await scan_rom(
platform=platform,
scan_type=ScanType.UNMATCHED,
rom=rom,
fs_rom={
"fs_name": "Jak and Daxter.chd",
"flat": True,
"nested": False,
"files": [],
"crc_hash": "",
"md5_hash": "",
"sha1_hash": "",
"ra_hash": "",
},
metadata_sources=[MetadataSource.RA],
newly_added=False,
)
# ra_id was set manually - get_rom_by_id should be called, not get_rom
mock_get_rom_by_id.assert_called_once()
mock_get_rom.assert_not_called()
assert result.ra_id == 2774
@patch.object(meta_playmatch_handler, "is_enabled", return_value=False)
@patch.object(meta_ra_handler, "get_rom_by_id", new_callable=AsyncMock)
@patch.object(meta_ra_handler, "get_rom", new_callable=AsyncMock)
async def test_scan_rom_unmatched_skips_ra_when_id_and_metadata_exist(
mock_get_rom, mock_get_rom_by_id, mock_playmatch_enabled
):
"""UNMATCHED scan must NOT re-fetch RA metadata when both ra_id and
ra_metadata are already populated."""
mock_get_rom_by_id.return_value = RAGameRom(ra_id=None)
mock_get_rom.return_value = RAGameRom(ra_id=None)
platform = Platform(
id=1,
slug="ps2",
fs_slug="ps2",
name="PlayStation 2",
igdb_id=8,
ra_id=21,
)
platform = db_platform_handler.add_platform(platform)
# ROM has both ra_id and ra_metadata populated
rom = Rom(
platform_id=platform.id,
fs_name="Jak and Daxter.chd",
fs_name_no_tags="Jak and Daxter",
fs_name_no_ext="Jak and Daxter",
fs_extension="chd",
fs_path="ps2",
name="Jak and Daxter",
ra_id=2774,
ra_metadata={"achievements_count": 60}, # already populated
fs_size_bytes=1024,
tags=[],
)
rom = db_rom_handler.add_rom(rom)
async with initialize_context():
result = await scan_rom(
platform=platform,
scan_type=ScanType.UNMATCHED,
rom=rom,
fs_rom={
"fs_name": "Jak and Daxter.chd",
"flat": True,
"nested": False,
"files": [],
"crc_hash": "",
"md5_hash": "",
"sha1_hash": "",
"ra_hash": "",
},
metadata_sources=[MetadataSource.RA],
newly_added=False,
)
# Both ID and metadata exist - should not re-fetch
mock_get_rom_by_id.assert_not_called()
mock_get_rom.assert_not_called()
# Existing ra_id should be preserved
assert result.ra_id == 2774

View File

@@ -15,7 +15,7 @@
"bowser": "^2.14.1",
"cronstrue": "^2.57.0",
"date-fns": "^4.1.0",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"lodash": "^4.18.1",
"md-editor-v3": "^5.8.4",
"mitt": "^3.0.1",
@@ -7015,12 +7015,12 @@
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
"integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
"license": "MIT",
"engines": {
"node": ">=14"
"node": ">=20"
}
},
"node_modules/js-tokens": {

View File

@@ -34,7 +34,7 @@
"bowser": "^2.14.1",
"cronstrue": "^2.57.0",
"date-fns": "^4.1.0",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"lodash": "^4.18.1",
"md-editor-v3": "^5.8.4",
"mitt": "^3.0.1",