diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 30002411a..e73439826 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -412,14 +412,28 @@ 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 ) ) ): - return await meta_hasheous_handler.lookup_rom( + result = await meta_hasheous_handler.lookup_rom( platform.slug, fs_rom["files"] or rom.files ) + if result.get("hasheous_id"): + return result + + # Hash lookup failed - if rom already has a hasheous_id set (e.g. manually), + # return a partial HasheousRom so downstream handlers can still use the + # existing IDs to fetch metadata. Only do this for existing roms (not newly + # added) to avoid missing required fields like `name` in rom_attrs. + if not newly_added and rom.hasheous_id: + return HasheousRom( + hasheous_id=rom.hasheous_id, + igdb_id=rom.igdb_id, + ra_id=rom.ra_id, + tgdb_id=rom.tgdb_id, + ) return HasheousRom(hasheous_id=None, igdb_id=None, tgdb_id=None, ra_id=None) @@ -463,11 +477,15 @@ 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 ) ) ): + # If the ID is already set (e.g. manually) but metadata is missing, fetch directly + if scan_type == ScanType.UNMATCHED and rom.igdb_id and not rom.igdb_metadata: + return await meta_igdb_handler.get_rom_by_id(rom, rom.igdb_id) + # Use Hasheous match to get the IGDB ID h_igdb_id = hasheous_rom.get("igdb_id") if h_igdb_id: @@ -509,7 +527,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 +548,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( @@ -549,7 +574,10 @@ async def scan_rom( newly_added or scan_type == ScanType.COMPLETE or (scan_type == ScanType.UPDATE and rom.libretro_id) - or (scan_type == ScanType.UNMATCHED and not rom.libretro_id) + or ( + scan_type == ScanType.UNMATCHED + and not rom.libretro_id + ) ) ): return await meta_libretro_handler.get_rom( @@ -566,7 +594,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 +614,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 ) ) @@ -591,6 +622,10 @@ async def scan_rom( if scan_type == ScanType.UPDATE and rom.moby_id: return await meta_moby_handler.get_rom_by_id(rom.moby_id) + # If the ID is already set (e.g. manually) but metadata is missing, fetch directly + if scan_type == ScanType.UNMATCHED and rom.moby_id and not rom.moby_metadata: + return await meta_moby_handler.get_rom_by_id(rom.moby_id) + if playmatch_rom["moby_id"] is not None: log.debug( f"{hl(rom_attrs['fs_name'])} identified by Playmatch as MobyGames " @@ -615,7 +650,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 ) ) @@ -624,6 +659,10 @@ async def scan_rom( if scan_type == ScanType.UPDATE and rom.ss_id: return await meta_ss_handler.get_rom_by_id(rom, rom.ss_id) + # If the ID is already set (e.g. manually) but metadata is missing, fetch directly + if scan_type == ScanType.UNMATCHED and rom.ss_id and not rom.ss_metadata: + return await meta_ss_handler.get_rom_by_id(rom, rom.ss_id) + # Use Playmatch's hash-based id when available if playmatch_rom["ss_id"] is not None: log.debug( @@ -656,7 +695,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 +710,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 +761,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 +776,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 +797,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 ) ) diff --git a/backend/tests/handler/test_fastapi.py b/backend/tests/handler/test_fastapi.py index d333e38fb..5f3357841 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -3,8 +3,10 @@ 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_launchbox_handler, meta_playmatch_handler, meta_ra_handler from handler.metadata.hasheous_handler import HasheousRom +from handler.metadata.launchbox_handler.types import LaunchboxRom +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 +176,219 @@ 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", + ra_metadata={"achievements_count": 60}, + ) + 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 + + +@patch.object(meta_playmatch_handler, "is_enabled", return_value=False) +@patch.object(meta_hasheous_handler, "get_ra_game", new_callable=AsyncMock) +@patch.object(meta_hasheous_handler, "get_igdb_game", new_callable=AsyncMock) +@patch.object(meta_hasheous_handler, "lookup_rom", new_callable=AsyncMock) +async def test_scan_rom_unmatched_hasheous_uses_existing_ids_on_hash_fail( + mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled +): + """UNMATCHED scan: when Hasheous hash lookup fails but hasheous_id is set + manually, the existing sub-IDs (igdb_id, ra_id) are passed to the metadata + proxy calls so enrichment can still happen.""" + # Hash lookup returns no match + mock_lookup.return_value = HasheousRom( + hasheous_id=None, igdb_id=None, tgdb_id=None, ra_id=None + ) + # get_igdb_game returns an enriched rom with name/cover + enriched = HasheousRom( + hasheous_id=262940, + igdb_id=4614, + tgdb_id=None, + ra_id=2774, + name="Jak and Daxter: The Precursor's Legacy", + url_cover="https://example.com/cover.jpg", + ) + mock_get_igdb.return_value = enriched + mock_get_ra.return_value = enriched + + platform = Platform( + id=1, + slug="ps2", + fs_slug="ps2", + name="PlayStation 2", + igdb_id=8, + hasheous_id=87, + ra_id=21, + ) + platform = db_platform_handler.add_platform(platform) + + # ROM has hasheous_id set manually, with sub-IDs also set, but no metadata + 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", + hasheous_id=262940, + hasheous_metadata={}, # empty + igdb_id=4614, + ra_id=2774, + 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.HASHEOUS], + newly_added=False, + ) + + # Hash lookup was attempted + mock_lookup.assert_called_once() + # get_igdb_game was called with the partial HasheousRom built from existing IDs + mock_get_igdb.assert_called_once() + called_arg = mock_get_igdb.call_args[0][0] + assert called_arg.get("hasheous_id") == 262940 + assert called_arg.get("igdb_id") == 4614 + # hasheous_id should be set in the result + assert result.hasheous_id == 262940