fix(scan): reassociate a moved ROM by title id when its hashes changed (#5202)

Reassociation matched a moved file to its missing entry by CRC, MD5 and
SHA1, and only consulted the binary title id when the hashes were absent.
Moving a GameCube iso into its own folder next to a dumpinfo.txt makes it
a folder ROM whose hash spans both files, so the hashes no longer matched,
the title id was never tried, and the scan created a new entry that showed
up under Recently added.

Fall back to the title id when the hashes match no missing entry. Hashes
still win when they match, and ambiguous hashes still return no match
rather than letting the title id break the tie.

Fixes #5046

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Georges-Antoine Assi
2026-10-09 13:24:23 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f89f6ef185
commit 989bfb8553
2 changed files with 81 additions and 30 deletions
+29 -25
View File
@@ -3894,39 +3894,43 @@ class DBRomsHandler(DBBaseHandler):
Used during scanning to reassociate a renamed or moved file with its
existing entry (preserving collections, notes, and assets) instead of
creating a duplicate. All three hashes must match; any missing hash
falls through to the binary title id, which non-hashable platforms
like Switch carry instead.
creating a duplicate. All three hashes must match; when they match no
entry, the binary title id decides, since moving a file into a folder
with extra files changes its hashes but not its title id.
Returns:
The single matching ROM, or None when there is no unambiguous one.
"""
identity: tuple[ColumnElement[bool], ...]
identities: list[tuple[ColumnElement[bool], ...]] = []
if crc_hash and md5_hash and sha1_hash:
identity = (
Rom.crc_hash == crc_hash,
Rom.md5_hash == md5_hash,
Rom.sha1_hash == sha1_hash,
)
elif title_id:
identity = (Rom.title_id == title_id,)
else:
return None
matches = session.scalars(
select(Rom)
.where(
and_(
Rom.platform_id == platform_id,
Rom.missing_from_fs.is_(True),
*identity,
identities.append(
(
Rom.crc_hash == crc_hash,
Rom.md5_hash == md5_hash,
Rom.sha1_hash == sha1_hash,
)
)
.limit(2)
).all()
if title_id:
identities.append((Rom.title_id == title_id,))
# Return None when more than one match to avoid ambiguity.
return matches[0] if len(matches) == 1 else None
for identity in identities:
matches = session.scalars(
select(Rom)
.where(
and_(
Rom.platform_id == platform_id,
Rom.missing_from_fs.is_(True),
*identity,
)
)
.limit(2)
).all()
if matches:
# More than one match is ambiguous, and a weaker identity must
# not break the tie.
return matches[0] if len(matches) == 1 else None
return None
def _collect_filter_values(
self,
+52 -5
View File
@@ -983,14 +983,38 @@ def test_get_matching_missing_rom_matches_title_id_without_hashes(platform: Plat
def test_get_matching_missing_rom_hashes_take_precedence_over_title_id(
platform: Platform,
):
"""A hashed file is matched on its hashes, not on a shared title id."""
_add_missing_rom(
"""A hash match wins over another missing entry sharing the title id."""
by_hash = _add_missing_rom(
platform,
"other",
"by_hash",
crc_hash="aabbccdd",
md5_hash="md5val",
sha1_hash="sha1val",
title_id="0100ABCD12340000",
)
_add_missing_rom(platform, "by_title_id", title_id="GALE01")
match = db_rom_handler.get_matching_missing_rom(
platform_id=platform.id,
crc_hash="aabbccdd",
md5_hash="md5val",
sha1_hash="sha1val",
title_id="GALE01",
)
assert match is not None
assert match.id == by_hash.id
def test_get_matching_missing_rom_falls_back_to_title_id_when_hashes_differ(
platform: Platform,
):
"""A file moved into a folder with extra files keeps its title id."""
missing = _add_missing_rom(
platform,
"moved",
crc_hash="aabbccdd",
md5_hash="md5val",
sha1_hash="sha1val",
title_id="GALE01",
)
match = db_rom_handler.get_matching_missing_rom(
@@ -998,7 +1022,30 @@ def test_get_matching_missing_rom_hashes_take_precedence_over_title_id(
crc_hash="different",
md5_hash="different",
sha1_hash="different",
title_id="0100ABCD12340000",
title_id="GALE01",
)
assert match is not None
assert match.id == missing.id
def test_get_matching_missing_rom_ambiguous_hashes_skip_title_id(
platform: Platform,
):
"""Ambiguous hashes return None rather than letting the title id pick."""
_add_missing_rom(
platform, "dup_a", crc_hash="aabbccdd", md5_hash="md5val", sha1_hash="sha1val"
)
_add_missing_rom(
platform, "dup_b", crc_hash="aabbccdd", md5_hash="md5val", sha1_hash="sha1val"
)
_add_missing_rom(platform, "by_title_id", title_id="GALE01")
match = db_rom_handler.get_matching_missing_rom(
platform_id=platform.id,
crc_hash="aabbccdd",
md5_hash="md5val",
sha1_hash="sha1val",
title_id="GALE01",
)
assert match is None