diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index a991f74c5..b6161e9b7 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -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 diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index 22847d748..a84e3698b 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -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"]) diff --git a/backend/tests/handler/test_rom_files.py b/backend/tests/handler/test_rom_files.py index c75a2721a..7d4805d00 100644 --- a/backend/tests/handler/test_rom_files.py +++ b/backend/tests/handler/test_rom_files.py @@ -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",