Skip to content

Commit a932507

Browse files
authored
Merge pull request #3861 from rommapp/feat/reassociate-missing-roms-by-hash
feat(scan): reassociate renamed/moved ROMs with missing entries by file hash
2 parents 5ff58c1 + 953ce94 commit a932507

7 files changed

Lines changed: 561 additions & 32 deletions

File tree

backend/endpoints/roms/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,11 @@ async def head_rom_content(
12101210
if len(files) == 1:
12111211
file = files[0]
12121212
rom_path = f"{LIBRARY_BASE_PATH}/{file.full_path}"
1213+
if not await Path(rom_path).is_file():
1214+
raise HTTPException(
1215+
status_code=status.HTTP_404_NOT_FOUND,
1216+
detail=f"File {file.file_name} not found on disk for ROM {id}",
1217+
)
12131218
return FileResponse(
12141219
path=rom_path,
12151220
filename=file.file_name,
@@ -1319,6 +1324,11 @@ async def get_rom_content(
13191324
if len(files) == 1:
13201325
file = files[0]
13211326
rom_path = f"{LIBRARY_BASE_PATH}/{file.full_path}"
1327+
if not await Path(rom_path).is_file():
1328+
raise HTTPException(
1329+
status_code=status.HTTP_404_NOT_FOUND,
1330+
detail=f"File {file.file_name} not found on disk for ROM {id}",
1331+
)
13221332
return FileResponse(
13231333
path=rom_path,
13241334
filename=file.file_name,

backend/endpoints/sockets/scan.py

Lines changed: 87 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -272,43 +272,97 @@ async def _identify_rom(
272272
parsed_tags = fs_rom_handler.parse_tags(fs_rom["fs_name"])
273273
roms_path = fs_rom_handler.get_roms_fs_structure(platform.fs_slug)
274274

275-
# Create the entry early so we have the ID
275+
rom_attrs = {
276+
"fs_name": fs_rom["fs_name"],
277+
"fs_path": roms_path,
278+
"regions": parsed_tags.regions,
279+
"revision": parsed_tags.revision,
280+
"version": parsed_tags.version,
281+
"languages": parsed_tags.languages,
282+
"tags": parsed_tags.other_tags,
283+
"platform_id": platform.id,
284+
"name": fs_rom_handler.get_file_name_with_no_tags(fs_rom["fs_name"]),
285+
"url_cover": "",
286+
"url_manual": "",
287+
"url_screenshots": [],
288+
}
289+
290+
calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION
291+
276292
newly_added: bool = rom is None
277-
if not rom:
278-
try:
279-
rom = db_rom_handler.add_rom(
280-
Rom(
281-
fs_name=fs_rom["fs_name"],
282-
fs_path=roms_path,
283-
regions=parsed_tags.regions,
284-
revision=parsed_tags.revision,
285-
version=parsed_tags.version,
286-
languages=parsed_tags.languages,
287-
tags=parsed_tags.other_tags,
288-
platform_id=platform.id,
289-
name=fs_rom_handler.get_file_name_with_no_tags(fs_rom["fs_name"]),
290-
url_cover="",
291-
url_manual="",
292-
url_screenshots=[],
293-
)
293+
reassociated: bool = False
294+
files_built: bool = False
295+
296+
if rom is None:
297+
# No entry matches this filename. Before treating it as new, hash
298+
# the files and check whether they belong to an existing entry that went
299+
# missing (a renamed or moved ROM), so its collections, notes, and
300+
# uploaded assets carry over instead of being orphaned on a duplicate.
301+
parsed_rom_files = await fs_rom_handler.get_rom_files(
302+
Rom(
303+
**rom_attrs,
304+
platform=platform,
305+
),
306+
calculate_hashes=calculate_hashes,
307+
)
308+
fs_rom.update(
309+
{
310+
"files": parsed_rom_files.rom_files,
311+
"crc_hash": parsed_rom_files.crc_hash,
312+
"md5_hash": parsed_rom_files.md5_hash,
313+
"sha1_hash": parsed_rom_files.sha1_hash,
314+
"ra_hash": parsed_rom_files.ra_hash,
315+
}
316+
)
317+
files_built = True
318+
319+
missing_match = db_rom_handler.get_matching_missing_rom(
320+
platform_id=platform.id,
321+
crc_hash=parsed_rom_files.crc_hash,
322+
md5_hash=parsed_rom_files.md5_hash,
323+
sha1_hash=parsed_rom_files.sha1_hash,
324+
)
325+
if missing_match is not None:
326+
# Move the existing entry onto the new file, clearing its missing state.
327+
rom = db_rom_handler.update_rom(
328+
missing_match.id,
329+
{
330+
"fs_name": fs_rom["fs_name"],
331+
"fs_path": roms_path,
332+
"regions": parsed_tags.regions,
333+
"revision": parsed_tags.revision,
334+
"version": parsed_tags.version,
335+
"languages": parsed_tags.languages,
336+
"tags": parsed_tags.other_tags,
337+
"missing_from_fs": False,
338+
},
294339
)
295-
except IntegrityError:
296-
# A concurrent scan already created this ROM, so skip it here.
297-
log.debug(
298-
f"Skipping {hl(fs_rom['fs_name'])}: already created by a concurrent scan"
340+
reassociated = True
341+
newly_added = False
342+
log.info(
343+
f"Reassociated {hl(fs_rom['fs_name'])} with existing entry "
344+
f"{hl(rom.name or rom.fs_name, color=BLUE)} by file hash"
299345
)
300-
return
346+
else:
347+
try:
348+
rom = db_rom_handler.add_rom(Rom(**rom_attrs))
349+
except IntegrityError:
350+
# A concurrent scan already created this ROM, so skip it here.
351+
log.debug(
352+
f"Skipping {hl(fs_rom['fs_name'])}: already created by a concurrent scan"
353+
)
354+
return
301355

302-
# Build rom files object before scanning
303-
should_update_files = _should_get_rom_files(
356+
# Build rom files object before scanning. A reassociated ROM always rebuilds
357+
# its files so the stale paths from the old filename are replaced.
358+
should_update_files = reassociated or _should_get_rom_files(
304359
scan_type=scan_type,
305360
rom=rom,
306361
newly_added=newly_added,
307362
roms_ids=roms_ids,
308363
)
309-
if should_update_files:
364+
if should_update_files and not files_built:
310365
# Get hash calculation setting from config
311-
calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION
312366
if calculate_hashes:
313367
log.debug(f"Calculating file hashes for {rom.fs_name}...")
314368

@@ -612,6 +666,12 @@ async def _identify_platform(
612666
else:
613667
log.info(f"{hl(str(len(fs_roms)))} roms found in the file system")
614668

669+
# Flag entries whose file is gone before identifying files, so a renamed or
670+
# moved ROM (a new file with no fs_name match) can be reassociated by hash
671+
# with its now-missing entry instead of spawning a duplicate. The end-of-scan
672+
# call below re-syncs and logs, unmarking any entry that got reassociated.
673+
db_rom_handler.mark_missing_roms(platform.id, [rom["fs_name"] for rom in fs_roms])
674+
615675
# Create semaphore to limit concurrent ROM scanning
616676
scan_semaphore = asyncio.Semaphore(SCAN_WORKERS)
617677

backend/handler/database/roms_handler.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,6 +2221,43 @@ def get_rom_by_hash(
22212221
# Return the first ROM matching any of the provided hash values
22222222
return session.scalar(query.outerjoin(Rom.files).filter(or_(*filters)).limit(1))
22232223

2224+
@begin_session
2225+
def get_matching_missing_rom(
2226+
self,
2227+
platform_id: int,
2228+
crc_hash: str | None = None,
2229+
md5_hash: str | None = None,
2230+
sha1_hash: str | None = None,
2231+
session: Session = None, # type: ignore
2232+
) -> Rom | None:
2233+
"""Find a ROM marked missing on a platform whose hashes match the file.
2234+
2235+
Used during scanning to reassociate a renamed or moved file with its
2236+
existing entry (preserving collections, notes, and assets) instead of
2237+
creating a duplicate. Requires the CRC, MD5, and SHA1 hashes to all
2238+
match. Any missing hash yields no match, so non-hashable platforms and
2239+
pre-hash entries safely fall back to creating a new entry.
2240+
"""
2241+
if not (crc_hash and md5_hash and sha1_hash):
2242+
return None
2243+
2244+
matches = session.scalars(
2245+
select(Rom)
2246+
.where(
2247+
and_(
2248+
Rom.platform_id == platform_id,
2249+
Rom.missing_from_fs.is_(True),
2250+
Rom.crc_hash == crc_hash,
2251+
Rom.md5_hash == md5_hash,
2252+
Rom.sha1_hash == sha1_hash,
2253+
)
2254+
)
2255+
.limit(2)
2256+
).all()
2257+
2258+
# Return None when more than one match to avoid ambiguity.
2259+
return matches[0] if len(matches) == 1 else None
2260+
22242261
def _collect_filter_values(
22252262
self,
22262263
session: Session,

backend/tests/endpoints/roms/test_rom.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,22 @@ def test_get_rom_content_single_file(
333333
assert "X-Accel-Redirect" in response.headers
334334

335335

336+
def test_get_rom_content_single_file_missing_on_disk_returns_404(
337+
client: TestClient, access_token: str, rom: Rom, rom_file, mocker
338+
):
339+
# In DEV_MODE the endpoint serves the file directly. If the file is gone
340+
# from disk (e.g. a renamed/moved ROM whose old entry is now missing), it
341+
# must return a clean 404 instead of raising a RuntimeError from
342+
# FileResponse when starlette fails to stat the path.
343+
mocker.patch("endpoints.roms.DEV_MODE", True)
344+
response = client.get(
345+
f"/api/roms/{rom.id}/content/test_rom.zip",
346+
headers={"Authorization": f"Bearer {access_token}"},
347+
follow_redirects=False,
348+
)
349+
assert response.status_code == status.HTTP_404_NOT_FOUND
350+
351+
336352
def test_get_rom_content_valid_file_id(
337353
client: TestClient, access_token: str, rom: Rom, rom_file
338354
):

0 commit comments

Comments
 (0)