From 80886957d44d774669605678c1c275e294f8e69f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 19:07:35 +0000 Subject: [PATCH 1/6] Initial plan From 440f55ba8941479a7303008a32b9128f798dd2c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 19:15:56 +0000 Subject: [PATCH 2/6] Fix Complete Rescan not clearing unselected metadata sources Agent-Logs-Url: https://github.com/rommapp/romm/sessions/e76abf0d-7039-4dae-ad88-5f1f1c4f422f Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com> --- backend/handler/scan_handler.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 097147f40..2682fc111 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -804,6 +804,32 @@ async def fetch_hasheous_rom(hasheous_rom: HasheousRom) -> HasheousRom: MetadataSource.LIBRETRO: libretro_handler_rom, } + # For COMPLETE rescans, explicitly clear metadata IDs and metadata for unselected sources + # This ensures that when a source is no longer selected, its data is removed from the ROM + if not newly_added and scan_type == ScanType.COMPLETE: + # Map of metadata source to (id_field, metadata_field) tuples + metadata_fields_map = { + MetadataSource.IGDB: ("igdb_id", "igdb_metadata"), + MetadataSource.MOBY: ("moby_id", "moby_metadata"), + MetadataSource.SS: ("ss_id", "ss_metadata"), + MetadataSource.RA: ("ra_id", "ra_metadata"), + MetadataSource.LAUNCHBOX: ("launchbox_id", "launchbox_metadata"), + MetadataSource.HASHEOUS: ("hasheous_id", "hasheous_metadata"), + MetadataSource.FLASHPOINT: ("flashpoint_id", "flashpoint_metadata"), + MetadataSource.HLTB: ("hltb_id", "hltb_metadata"), + MetadataSource.GAMELIST: ("gamelist_id", "gamelist_metadata"), + MetadataSource.LIBRETRO: ("libretro_id", None), # libretro has no separate metadata field + MetadataSource.SGDB: ("sgdb_id", None), # sgdb has no separate metadata field + MetadataSource.TGDB: ("tgdb_id", None), # tgdb has no separate metadata field + } + + # Clear fields for sources not in the current metadata_sources list + for source, (id_field, metadata_field) in metadata_fields_map.items(): + if source not in metadata_sources: + rom_attrs[id_field] = None + if metadata_field: + rom_attrs[metadata_field] = {} + # Determine which metadata sources are available available_sources = [ name for name, handler in metadata_handlers.items() if handler.get(f"{name}_id") From 6c84241ef58003bdb86db444869e251e8957bb06 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 20 May 2026 17:37:20 -0400 Subject: [PATCH 3/6] Extract METADATA_SOURCE_FIELDS constant in scan_handler Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/handler/scan_handler.py | 137 +++++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 36 deletions(-) diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index 2682fc111..ccd172a8c 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -80,6 +80,34 @@ class MetadataSource(enum.StrEnum): LIBRETRO = "libretro" # Libretro thumbnails +METADATA_SOURCE_FIELDS: dict[MetadataSource, dict[str, str | None]] = { + MetadataSource.IGDB: {"id_field": "igdb_id", "metadata_field": "igdb_metadata"}, + MetadataSource.MOBY: {"id_field": "moby_id", "metadata_field": "moby_metadata"}, + MetadataSource.SS: {"id_field": "ss_id", "metadata_field": "ss_metadata"}, + MetadataSource.RA: {"id_field": "ra_id", "metadata_field": "ra_metadata"}, + MetadataSource.LAUNCHBOX: { + "id_field": "launchbox_id", + "metadata_field": "launchbox_metadata", + }, + MetadataSource.HASHEOUS: { + "id_field": "hasheous_id", + "metadata_field": "hasheous_metadata", + }, + MetadataSource.FLASHPOINT: { + "id_field": "flashpoint_id", + "metadata_field": "flashpoint_metadata", + }, + MetadataSource.HLTB: {"id_field": "hltb_id", "metadata_field": "hltb_metadata"}, + MetadataSource.GAMELIST: { + "id_field": "gamelist_id", + "metadata_field": "gamelist_metadata", + }, + MetadataSource.LIBRETRO: {"id_field": "libretro_id", "metadata_field": None}, + MetadataSource.SGDB: {"id_field": "sgdb_id", "metadata_field": None}, + MetadataSource.TGDB: {"id_field": "tgdb_id", "metadata_field": None}, +} + + def get_main_platform_igdb_id(platform: Platform): cnfg = cm.get_config() @@ -791,48 +819,83 @@ async def fetch_hasheous_rom(hasheous_rom: HasheousRom) -> HasheousRom: fetch_libretro_rom(), ) - metadata_handlers = { - MetadataSource.IGDB: igdb_handler_rom, - MetadataSource.MOBY: moby_handler_rom, - MetadataSource.SS: ss_handler_rom, - MetadataSource.RA: ra_handler_rom, - MetadataSource.LAUNCHBOX: launchbox_handler_rom, - MetadataSource.HASHEOUS: hasheous_handler_rom, - MetadataSource.FLASHPOINT: flashpoint_handler_rom, - MetadataSource.HLTB: hltb_handler_rom, - MetadataSource.GAMELIST: gamelist_handler_rom, - MetadataSource.LIBRETRO: libretro_handler_rom, + metadata_handlers: dict[MetadataSource, dict] = { + MetadataSource.IGDB: { + "handler": igdb_handler_rom, + "id_field": "igdb_id", + "metadata_field": "igdb_metadata", + }, + MetadataSource.MOBY: { + "handler": moby_handler_rom, + "id_field": "moby_id", + "metadata_field": "moby_metadata", + }, + MetadataSource.SS: { + "handler": ss_handler_rom, + "id_field": "ss_id", + "metadata_field": "ss_metadata", + }, + MetadataSource.RA: { + "handler": ra_handler_rom, + "id_field": "ra_id", + "metadata_field": "ra_metadata", + }, + MetadataSource.LAUNCHBOX: { + "handler": launchbox_handler_rom, + "id_field": "launchbox_id", + "metadata_field": "launchbox_metadata", + }, + MetadataSource.HASHEOUS: { + "handler": hasheous_handler_rom, + "id_field": "hasheous_id", + "metadata_field": "hasheous_metadata", + }, + MetadataSource.FLASHPOINT: { + "handler": flashpoint_handler_rom, + "id_field": "flashpoint_id", + "metadata_field": "flashpoint_metadata", + }, + MetadataSource.HLTB: { + "handler": hltb_handler_rom, + "id_field": "hltb_id", + "metadata_field": "hltb_metadata", + }, + MetadataSource.GAMELIST: { + "handler": gamelist_handler_rom, + "id_field": "gamelist_id", + "metadata_field": "gamelist_metadata", + }, + MetadataSource.LIBRETRO: { + "handler": libretro_handler_rom, + "id_field": "libretro_id", + "metadata_field": None, + }, + MetadataSource.SGDB: { + "handler": {}, + "id_field": "sgdb_id", + "metadata_field": None, + }, + MetadataSource.TGDB: { + "handler": {}, + "id_field": "tgdb_id", + "metadata_field": None, + }, } # For COMPLETE rescans, explicitly clear metadata IDs and metadata for unselected sources # This ensures that when a source is no longer selected, its data is removed from the ROM if not newly_added and scan_type == ScanType.COMPLETE: - # Map of metadata source to (id_field, metadata_field) tuples - metadata_fields_map = { - MetadataSource.IGDB: ("igdb_id", "igdb_metadata"), - MetadataSource.MOBY: ("moby_id", "moby_metadata"), - MetadataSource.SS: ("ss_id", "ss_metadata"), - MetadataSource.RA: ("ra_id", "ra_metadata"), - MetadataSource.LAUNCHBOX: ("launchbox_id", "launchbox_metadata"), - MetadataSource.HASHEOUS: ("hasheous_id", "hasheous_metadata"), - MetadataSource.FLASHPOINT: ("flashpoint_id", "flashpoint_metadata"), - MetadataSource.HLTB: ("hltb_id", "hltb_metadata"), - MetadataSource.GAMELIST: ("gamelist_id", "gamelist_metadata"), - MetadataSource.LIBRETRO: ("libretro_id", None), # libretro has no separate metadata field - MetadataSource.SGDB: ("sgdb_id", None), # sgdb has no separate metadata field - MetadataSource.TGDB: ("tgdb_id", None), # tgdb has no separate metadata field - } - - # Clear fields for sources not in the current metadata_sources list - for source, (id_field, metadata_field) in metadata_fields_map.items(): + for source, fields in metadata_handlers.items(): if source not in metadata_sources: - rom_attrs[id_field] = None - if metadata_field: - rom_attrs[metadata_field] = {} + rom_attrs[fields["id_field"]] = None + if fields["metadata_field"]: + rom_attrs[fields["metadata_field"]] = {} # Determine which metadata sources are available available_sources = [ - name for name, handler in metadata_handlers.items() if handler.get(f"{name}_id") + name + for name, fields in metadata_handlers.items() + if fields["handler"].get(fields["id_field"]) ] # Apply metadata priority order @@ -841,7 +904,7 @@ async def fetch_hasheous_rom(hasheous_rom: HasheousRom) -> HasheousRom: ) # Reverse priority order to apply highest priority last for source_name in reversed(priority_ordered): - handler_data = metadata_handlers[source_name] + handler_data = metadata_handlers[source_name]["handler"] # Only update fields that have valid values for key, field_value in handler_data.items(): if field_value: @@ -853,7 +916,7 @@ async def fetch_hasheous_rom(hasheous_rom: HasheousRom) -> HasheousRom: ) # Reverse priority order to apply highest priority last for source_name in reversed(priority_ordered_artwork): - handler_data = metadata_handlers[source_name] + handler_data = metadata_handlers[source_name]["handler"] for field in ["url_cover", "url_screenshots", "url_manual"]: # Only update fields that have valid values field_value = handler_data.get(field) @@ -957,7 +1020,9 @@ async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom: ) if sgdb_cover and not manual_cover_preserved: cover_sources = [ - name for name, h in metadata_handlers.items() if h.get("url_cover") + name + for name, fields in metadata_handlers.items() + if fields["handler"].get("url_cover") ] ranked = get_priority_ordered_metadata_sources( cover_sources + [MetadataSource.SGDB], "artwork" From 2e7beeec5b476f2fb1b14778bdaf631670bfaa0b Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 20 May 2026 17:43:50 -0400 Subject: [PATCH 4/6] remove meta source fields --- backend/handler/scan_handler.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index ccd172a8c..30002411a 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -80,34 +80,6 @@ class MetadataSource(enum.StrEnum): LIBRETRO = "libretro" # Libretro thumbnails -METADATA_SOURCE_FIELDS: dict[MetadataSource, dict[str, str | None]] = { - MetadataSource.IGDB: {"id_field": "igdb_id", "metadata_field": "igdb_metadata"}, - MetadataSource.MOBY: {"id_field": "moby_id", "metadata_field": "moby_metadata"}, - MetadataSource.SS: {"id_field": "ss_id", "metadata_field": "ss_metadata"}, - MetadataSource.RA: {"id_field": "ra_id", "metadata_field": "ra_metadata"}, - MetadataSource.LAUNCHBOX: { - "id_field": "launchbox_id", - "metadata_field": "launchbox_metadata", - }, - MetadataSource.HASHEOUS: { - "id_field": "hasheous_id", - "metadata_field": "hasheous_metadata", - }, - MetadataSource.FLASHPOINT: { - "id_field": "flashpoint_id", - "metadata_field": "flashpoint_metadata", - }, - MetadataSource.HLTB: {"id_field": "hltb_id", "metadata_field": "hltb_metadata"}, - MetadataSource.GAMELIST: { - "id_field": "gamelist_id", - "metadata_field": "gamelist_metadata", - }, - MetadataSource.LIBRETRO: {"id_field": "libretro_id", "metadata_field": None}, - MetadataSource.SGDB: {"id_field": "sgdb_id", "metadata_field": None}, - MetadataSource.TGDB: {"id_field": "tgdb_id", "metadata_field": None}, -} - - def get_main_platform_igdb_id(platform: Platform): cnfg = cm.get_config() From aeb17b95a820a369f0c75e87b4bbf3c95a3c5c02 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 20 May 2026 18:10:25 -0400 Subject: [PATCH 5/6] add tests --- backend/tests/handler/test_fastapi.py | 84 ++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/backend/tests/handler/test_fastapi.py b/backend/tests/handler/test_fastapi.py index 62b2218f7..dd7a6d0a2 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -1,6 +1,10 @@ +from unittest.mock import AsyncMock, patch + import pytest -from handler.database import db_platform_handler +from handler.database import db_platform_handler, db_rom_handler +from handler.metadata import meta_hasheous_handler, meta_playmatch_handler +from handler.metadata.hasheous_handler import HasheousRom from handler.scan_handler import MetadataSource, ScanType, scan_platform, scan_rom from models.platform import Platform from models.rom import Rom, RomFile @@ -93,3 +97,81 @@ async def test_scan_rom(): # assert rom.hasheous_id == 4872 # assert rom.fs_size_bytes == 23175094 # assert rom.tags == [] + + +@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_complete_clears_unselected_metadata( + mock_lookup, mock_get_igdb, mock_get_ra, mock_playmatch_enabled +): + """COMPLETE rescan with newly_added=False must clear id and *_metadata + fields for sources that are no longer in metadata_sources.""" + hasheous_result = HasheousRom( + hasheous_id=999, + igdb_id=None, + tgdb_id=None, + ra_id=None, + name="Mock Hasheous Game", + hasheous_metadata={"name": "Mock Hasheous Game"}, + ) + mock_lookup.return_value = hasheous_result + mock_get_igdb.return_value = hasheous_result + mock_get_ra.return_value = hasheous_result + + platform = Platform( + id=1, + slug="n64", + fs_slug="n64", + name="Nintendo 64", + igdb_id=4, + ra_id=2, + hasheous_id=64, + ) + platform = db_platform_handler.add_platform(platform) + + rom = Rom( + platform_id=platform.id, + fs_name="Paper Mario (USA).z64", + fs_name_no_tags="Paper Mario", + fs_name_no_ext="Paper Mario", + fs_extension="z64", + fs_path="n64/Paper Mario (USA)", + name="Paper Mario", + igdb_id=3340, + igdb_metadata={"summary": "stale IGDB metadata"}, + ra_id=1234, + ra_metadata={"name": "stale RA metadata"}, + hasheous_id=4872, + 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.COMPLETE, + rom=rom, + fs_rom={ + "fs_name": "Paper Mario (USA).z64", + "flat": True, + "nested": False, + "files": [], + "crc_hash": "", + "md5_hash": "", + "sha1_hash": "", + "ra_hash": "", + }, + metadata_sources=[MetadataSource.HASHEOUS], + newly_added=False, + ) + + # IGDB and RA were unselected — their id and metadata must be cleared. + assert result.igdb_id is None + assert result.igdb_metadata == {} + assert result.ra_id is None + assert result.ra_metadata == {} + # Hasheous is still selected and should remain populated. + assert result.hasheous_id == 999 From a245ac7330af5611162332f438c368cd93a04a08 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Wed, 20 May 2026 18:13:09 -0400 Subject: [PATCH 6/6] fix lint --- backend/tests/handler/test_fastapi.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/handler/test_fastapi.py b/backend/tests/handler/test_fastapi.py index dd7a6d0a2..d333e38fb 100644 --- a/backend/tests/handler/test_fastapi.py +++ b/backend/tests/handler/test_fastapi.py @@ -114,7 +114,6 @@ async def test_scan_rom_complete_clears_unselected_metadata( tgdb_id=None, ra_id=None, name="Mock Hasheous Game", - hasheous_metadata={"name": "Mock Hasheous Game"}, ) mock_lookup.return_value = hasheous_result mock_get_igdb.return_value = hasheous_result