diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 282d7c70f..78689fa80 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -394,13 +394,17 @@ class FSRomsHandler(FSHandler): return kept_roms - def _iter_m3u_referenced_paths(self, abs_fs_path: Path, m3u_file_name: str) -> Iterator[Path]: + def _iter_m3u_referenced_paths( + self, abs_fs_path: Path, m3u_file_name: str + ) -> Iterator[Path]: m3u_path = Path(abs_fs_path, m3u_file_name) if not m3u_path.is_file(): return try: - lines = m3u_path.read_text(encoding="utf-8-sig", errors="ignore").splitlines() + lines = m3u_path.read_text( + encoding="utf-8-sig", errors="ignore" + ).splitlines() except OSError: return @@ -420,7 +424,7 @@ class FSRomsHandler(FSHandler): except ValueError: continue - if resolved_path.exists(): + if resolved_path.is_file(): yield resolved_path def _get_m3u_exclusions( @@ -689,6 +693,62 @@ class FSRomsHandler(FSHandler): ) ) + # When the ROM is a flat .m3u playlist, the referenced disc files are + # hidden from the library listing (see _get_m3u_exclusions) but the + # player needs them as selectable disc options. Append them regardless + # of hashing; per-file hashes are only computed for hashable platforms. + if rom.fs_name.lower().endswith(".m3u"): + base_resolved = abs_fs_path.resolve() + for ref_path in self._iter_m3u_referenced_paths(abs_fs_path, rom.fs_name): + rel_within = ref_path.relative_to(base_resolved) + + if hashable_platform: + try: + crc_c, _, md5_h, _, sha1_h, _ = await asyncio.to_thread( + self._calculate_rom_hashes, + ref_path, + 0, + hashlib.md5(usedforsecurity=False), + hashlib.sha1(usedforsecurity=False), + ) + except zlib.error: + crc_c = 0 + md5_h = hashlib.md5(usedforsecurity=False) + sha1_h = hashlib.sha1(usedforsecurity=False) + + file_hash = FileHash( + crc_hash=crc32_to_hex(crc_c) if crc_c != DEFAULT_CRC_C else "", + md5_hash=( + md5_h.hexdigest() + if md5_h.digest() != DEFAULT_MD5_H_DIGEST + else "" + ), + sha1_hash=( + sha1_h.hexdigest() + if sha1_h.digest() != DEFAULT_SHA1_H_DIGEST + else "" + ), + chd_sha1_hash=( + extract_chd_hash(ref_path) if is_chd_file(ref_path) else "" + ), + ) + else: + file_hash = FileHash( + crc_hash="", + md5_hash="", + sha1_hash="", + chd_sha1_hash="", + ) + + rom_files.append( + self._build_rom_file( + rom=rom, + rom_path=Path(rel_roms_path, *rel_within.parts[:-1]), + file_name=rel_within.name, + file_hash=file_hash, + ) + ) + return ParsedRomFiles( rom_files=rom_files, crc_hash=crc32_to_hex(rom_crc_c) if rom_crc_c != DEFAULT_CRC_C else "", @@ -788,14 +848,15 @@ class FSRomsHandler(FSHandler): excluded_single_roms, excluded_multi_roms = self._get_m3u_exclusions( abs_fs_path, fs_single_roms, fs_multi_roms ) - return len( - [ - rom - for rom in self.exclude_single_files(fs_single_roms) - if rom not in excluded_single_roms - ] - ) + len( - [rom for rom in self.exclude_multi_roms(fs_multi_roms) if rom not in excluded_multi_roms] + + return sum( + 1 + for rom in self.exclude_single_files(fs_single_roms) + if rom not in excluded_single_roms + ) + sum( + 1 + for rom in self.exclude_multi_roms(fs_multi_roms) + if rom not in excluded_multi_roms ) async def get_roms(self, platform: Platform) -> list[FSRom]: diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index eacff5501..2525e4b57 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -446,7 +446,9 @@ class TestFSRomsHandler: try: with pytest.MonkeyPatch.context() as m: - m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) + m.setattr( + "handler.filesystem.roms_handler.cm.get_config", lambda: config + ) roms = await handler.get_roms(platform) rom_count = await handler.count_roms(platform) @@ -461,6 +463,66 @@ class TestFSRomsHandler: if disc_dir.exists(): shutil.rmtree(disc_dir) + @pytest.mark.asyncio + async def test_get_rom_files_m3u_includes_referenced_discs_without_hashing( + self, handler: FSRomsHandler, platform: Platform + ): + """A flat .m3u ROM must expose its referenced disc files in rom_files + even when hashing is disabled, so the player can offer disc selection.""" + m3u_name = "Final Fantasy IX.m3u" + m3u_path = handler.base_path / "n64/roms" / m3u_name + disc_1 = handler.base_path / "n64/roms" / "Final Fantasy IX (Disc 1).chd" + disc_2 = handler.base_path / "n64/roms" / "Final Fantasy IX (Disc 2).chd" + + m3u_path.write_text( + "Final Fantasy IX (Disc 1).chd\nFinal Fantasy IX (Disc 2).chd\n" + ) + disc_1.write_bytes(b"disc-1") + disc_2.write_bytes(b"disc-2") + + rom = Rom( + id=42, + fs_name=m3u_name, + fs_path="n64/roms", + fs_extension="m3u", + platform=platform, + full_path=f"n64/roms/{m3u_name}", + ) + + config = Config( + EXCLUDED_PLATFORMS=[], + EXCLUDED_SINGLE_EXT=[], + EXCLUDED_SINGLE_FILES=[], + EXCLUDED_MULTI_FILES=[], + EXCLUDED_MULTI_PARTS_EXT=[], + EXCLUDED_MULTI_PARTS_FILES=[], + PLATFORMS_BINDING={}, + PLATFORMS_VERSIONS={}, + ROMS_FOLDER_NAME="roms", + FIRMWARE_FOLDER_NAME="bios", + ) + + try: + with pytest.MonkeyPatch.context() as m: + m.setattr( + "handler.filesystem.roms_handler.cm.get_config", lambda: config + ) + parsed = await handler.get_rom_files(rom, calculate_hashes=False) + + file_names = [f.file_name for f in parsed.rom_files] + assert m3u_name in file_names + assert "Final Fantasy IX (Disc 1).chd" in file_names + assert "Final Fantasy IX (Disc 2).chd" in file_names + # Hashing disabled: no per-file hashes populated + for f in parsed.rom_files: + assert f.crc_hash == "" + assert f.md5_hash == "" + assert f.sha1_hash == "" + finally: + m3u_path.unlink(missing_ok=True) + disc_1.unlink(missing_ok=True) + disc_2.unlink(missing_ok=True) + @pytest.mark.asyncio async def test_get_rom_files_multi_rom_multi_dot_exclusion( self, handler: FSRomsHandler, rom_multi