diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 14517e45ff..357ab2baaf 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -21,7 +21,10 @@ RomAlreadyExistsException, RomsNotFoundException, ) -from handler.metadata.base_handler import UniversalPlatformSlug as UPS +from handler.metadata.base_handler import ( + SWITCH_PRODUCT_ID_REGEX, + UniversalPlatformSlug as UPS, +) from models.platform import Platform from models.rom import Rom, RomFile, RomFileCategory, TrackMeta from utils.archives import ( @@ -108,6 +111,30 @@ def category_matches(category: str, path_parts: list[str]): return category in path_parts or f"{category}s" in path_parts +# Platforms whose files carry a Nintendo title ID we can categorize by. +SWITCH_PLATFORMS = frozenset((UPS.SWITCH, UPS.SWITCH_2)) + + +def switch_title_id_category(file_name: str) -> RomFileCategory | None: + """Classify a Switch file as base game, update, or DLC from its title ID. + + Nintendo title IDs are 16 hex digits. Relative to the base application, + updates set the low 12 bits to 0x800 and DLC increments the 4th-to-last + nibble (making it odd); base games leave the low 12 bits cleared with an + even 4th-to-last nibble. See https://switchbrew.org/wiki/Title_list. + """ + match = SWITCH_PRODUCT_ID_REGEX.search(file_name.upper()) + if not match: + return None + + title_id = int(match.group(1), 16) + if (title_id >> 12) & 1: + return RomFileCategory.DLC + if title_id & 0xFFF == 0x800: + return RomFileCategory.UPDATE + return RomFileCategory.GAME + + DEFAULT_CRC_C = 0 DEFAULT_MD5_H_DIGEST = hashlib.md5(usedforsecurity=False).digest() DEFAULT_SHA1_H_DIGEST = hashlib.sha1(usedforsecurity=False).digest() @@ -279,6 +306,12 @@ def _build_rom_file( None, ) + # Fall back to the Switch title ID in the file name when the directory + # layout doesn't categorize it, so base/update/DLC files sharing a game + # folder are still tagged individually. + if matching_category is None and rom.platform_slug in SWITCH_PLATFORMS: + matching_category = switch_title_id_category(file_name) + track_meta = None if matching_category == RomFileCategory.SOUNDTRACK: from utils.audio_tags import ( diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index b58bd46b39..1b79455bfb 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -17,6 +17,7 @@ from handler.filesystem.roms_handler import ( FileHash, FSRomsHandler, + switch_title_id_category, ) from models.platform import Platform from models.rom import Rom, RomFile, RomFileCategory @@ -407,6 +408,56 @@ def test_build_rom_file_with_category(self, rom_multi: Rom, handler: FSRomsHandl if test_file.exists(): test_file.unlink() + @pytest.mark.parametrize( + ("file_name", "expected"), + [ + # Breath of the Wild base / update / DLC from issue #2526 + ("Zelda BOTW [01007EF00011E000][v0].nsp", RomFileCategory.GAME), + ("Zelda BOTW [01007EF00011E800][v196608].nsp", RomFileCategory.UPDATE), + ("Zelda BOTW DLC1 [01007EF00011F001][v0].nsp", RomFileCategory.DLC), + ("Zelda BOTW DLC2 [01007EF00011F002][v0].nsp", RomFileCategory.DLC), + # No title ID in the name -> no classification + ("Some Homebrew.nro", None), + ], + ) + def test_switch_title_id_category(self, file_name, expected): + assert switch_title_id_category(file_name) == expected + + def test_build_rom_file_switch_title_id_category(self, handler: FSRomsHandler): + """Switch files sharing a game folder are categorized by title ID.""" + switch = Platform(name="Nintendo Switch", slug="switch", fs_slug="switch") + rom = Rom( + id=10, + fs_name="Zelda BOTW", + fs_path="switch/roms", + fs_extension="", + platform=switch, + full_path="switch/roms/Zelda BOTW", + ) + rom_path = Path("switch/roms/Zelda BOTW") + file_hash = FileHash( + {"crc_hash": "", "md5_hash": "", "sha1_hash": "", "chd_sha1_hash": ""} + ) + + os.makedirs(handler.base_path / rom_path, exist_ok=True) + cases = { + "Zelda BOTW [01007EF00011E800][v196608].nsp": RomFileCategory.UPDATE, + "Zelda BOTW DLC1 [01007EF00011F001][v0].nsp": RomFileCategory.DLC, + } + created = [] + try: + for file_name, expected in cases.items(): + test_file = handler.base_path / rom_path / file_name + test_file.write_text("content") + created.append(test_file) + + rom_file = handler._build_rom_file(rom, rom_path, file_name, file_hash) + assert rom_file.category == expected + finally: + for test_file in created: + if test_file.exists(): + test_file.unlink() + @pytest.mark.asyncio async def test_get_roms(self, handler: FSRomsHandler, platform, config): """Test get_roms with actual files in the filesystem"""