Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion backend/handler/filesystem/roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 (
Expand Down
51 changes: 51 additions & 0 deletions backend/tests/handler/filesystem/test_roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down