Merge pull request #4854 from rommapp/fix/rom-file-category-anchor

fix(scan): anchor rom file category to the folder below the rom root
This commit is contained in:
Georges-Antoine Assi
2026-09-28 08:12:17 -04:00
committed by GitHub
3 changed files with 88 additions and 8 deletions
+19 -6
View File
@@ -149,12 +149,15 @@ def category_matches(category: str, path_parts: list[str]) -> bool:
def category_for_path_parts(path_parts_lower: list[str]) -> RomFileCategory | None:
"""The file category a folder path implies, from its lowercased parts."""
"""The file category of a folder, from its lowercased parts below the ROM root."""
# Only the top folder counts, so a dump's inner `content/game` never matches.
if not path_parts_lower:
return None
return next(
(
category
for category in RomFileCategory
if category_matches(category.value, path_parts_lower)
if category_matches(category.value, path_parts_lower[:1])
),
None,
)
@@ -515,6 +518,17 @@ class FSRomsHandler(FSHandler):
return kept_roms
@staticmethod
def _folder_category(rom: Rom, rom_path: Path) -> RomFileCategory | None:
"""The category a file's folder gives it, relative to the ROM's own root."""
rom_root = Path(rom.full_path)
rom_relative_parts = (
rom_path.relative_to(rom_root).parts
if rom_path.is_relative_to(rom_root)
else ()
)
return category_for_path_parts(list(map(str.lower, rom_relative_parts)))
def _build_rom_file(
self,
rom: Rom,
@@ -526,10 +540,7 @@ class FSRomsHandler(FSHandler):
archive_members: list[dict[str, Any]] | None = None,
) -> RomFile:
abs_file_path = Path(self.base_path, rom_path, file_name)
matching_category = category_for_path_parts(
list(map(str.lower, rom_path.parts))
)
matching_category = self._folder_category(rom, rom_path)
track_meta = None
if matching_category == RomFileCategory.SOUNDTRACK:
@@ -732,6 +743,8 @@ class FSRomsHandler(FSHandler):
hashable=hashable_platform,
)
):
# Title id extraction below may settle a content category.
row.category = self._folder_category(rom, rel_dir)
rom_files.append(row)
_record_title_id_source(abs_file_path, row)
continue
@@ -24,6 +24,7 @@ from handler.filesystem.roms_handler import (
FileHash,
FSRomsHandler,
_TitleIdSource,
category_for_path_parts,
category_matches,
mtime_matches,
)
@@ -561,7 +562,7 @@ class TestFSRomsHandler:
def test_build_rom_file_with_category(self, rom_multi: Rom, handler: FSRomsHandler):
"""Test _build_rom_file with category detection"""
# Test with DLC category
rom_path = Path(rom_multi.fs_path, "dlc")
rom_path = Path(rom_multi.full_path, "dlc")
file_name = "test_dlc.n64"
file_hash = FileHash(
{
@@ -3482,6 +3483,69 @@ class TestIncrementalRomFiles:
assert by_name["part1 (Hack).bin"].category == RomFileCategory.HACK
assert by_name["fix.ips"].category == RomFileCategory.PATCH
async def test_category_comes_from_the_folder_below_the_rom_root(
self, handler: FSRomsHandler, platform: Platform
):
rom = self._rom(platform)
self._write(handler, f"{self.ROM_DIR}/dlc/foo.bin", b"dlc")
self._write(handler, f"{self.ROM_DIR}/update/content/game/x.bin", b"update")
self._write(
handler,
f"{self.ROM_DIR}/Sonic [Update]/content/Game/Stats/archive.bin",
b"stats",
)
self._write(handler, f"{self.ROM_DIR}/content/CMCmn/demo/effect.szs", b"fx")
parsed = await handler.get_rom_files(rom)
by_name = {f.file_name: f for f in parsed.rom_files}
assert by_name["foo.bin"].category == RomFileCategory.DLC
assert by_name["x.bin"].category == RomFileCategory.UPDATE
assert by_name["archive.bin"].category is None
assert by_name["effect.szs"].category is None
async def test_reused_row_takes_its_folder_category(
self, handler: FSRomsHandler, platform: Platform
):
rom = self._rom(platform)
rel_dir = f"{self.ROM_DIR}/Sonic [Update]/content/Game"
st = self._write(handler, f"{rel_dir}/archive.bin", b"stats")
row = self._row(rom, rel_dir, "archive.bin", st)
row.category = RomFileCategory.GAME
parsed = await handler.get_rom_files(rom, existing_files=[row])
assert parsed.rom_files == [row]
assert row.category is None
async def test_rom_folder_named_like_a_category_is_not_a_category(
self, handler: FSRomsHandler
):
platform = Platform(name="Nintendo 64", slug="n64", fs_slug="n64")
rom = self._rom(platform, fs_name="Demo")
self._write(handler, "n64/roms/Demo/game.n64", b"game")
parsed = await handler.get_rom_files(rom)
assert parsed.rom_files[0].category is None
@pytest.mark.parametrize(
"parts, expected",
[
([], None),
(["dlc"], RomFileCategory.DLC),
(["update", "x"], RomFileCategory.UPDATE),
(["patches", "v2"], RomFileCategory.PATCH),
(["update", "content", "game"], RomFileCategory.UPDATE),
(["some game [update]", "content", "game", "stats"], None),
(["content", "cmcmn", "demo"], None),
],
)
def test_category_for_path_parts(
self, parts: list[str], expected: RomFileCategory | None
):
assert category_for_path_parts(parts) == expected
def test_category_matches_plural_forms(self):
assert category_matches("patch", ["patches"])
assert category_matches("hack", ["hacks"])
+4 -1
View File
@@ -7,7 +7,7 @@ import pytest
from handler.database import db_rom_handler
from handler.filesystem import fs_rom_handler
from handler.filesystem.roms_handler import ParsedRomFiles
from handler.filesystem.roms_handler import ParsedRomFiles, category_for_path_parts
from handler.rom_files import refresh_rom_files
from models.platform import Platform
from models.rom import (
@@ -64,6 +64,9 @@ def _folder_rom(
rom_id=rom.id,
file_name=Path(rel).name,
file_path=str(Path(rom.fs_path, FOLDER, rel).parent),
category=category_for_path_parts(
[part.lower() for part in Path(rel).parent.parts]
),
file_size_bytes=st.st_size,
last_modified=st.st_mtime,
crc_hash="row-crc",