diff --git a/backend/handler/filesystem/base_handler.py b/backend/handler/filesystem/base_handler.py index 61a1fe13f..61b1c122c 100644 --- a/backend/handler/filesystem/base_handler.py +++ b/backend/handler/filesystem/base_handler.py @@ -205,6 +205,19 @@ class FSHandler: match = EXTENSION_REGEX.search(file_name) return match.group(1) if match else "" + def iter_file_extensions(self, file_name: str) -> list[str]: + """Return all right-anchored sub-extensions for a filename. + + For "game.nds.enc.hash.txt" this yields: + ["nds.enc.hash.txt", "enc.hash.txt", "hash.txt", "txt"] + This allows exclusion rules like "hash.txt" to match multi-dot filenames. + """ + ext = self.parse_file_extension(file_name) + if not ext: + return [] + parts = ext.split(".") + return [".".join(parts[i:]) for i in range(len(parts))] + def extract_uuid_v4_from_filename(self, file_name: str) -> str: match = UUID_V4_REGEX.search(file_name) return match.group(0) if match else "" @@ -215,16 +228,11 @@ class FSHandler: excluded_files: list[str] = [] for file_name in files: - # Get the compound extension (e.g. "nds.hash.txt" for "game.nds.hash.txt") - # and the last single extension (e.g. "txt") for multi-dot filenames. - ext = self.parse_file_extension(file_name) - suffix = Path(file_name).suffix.lstrip(".") - - # Exclude the file if the compound extension or the last single extension - # is in the excluded list. Checking both handles files with multiple dots - # (e.g. "game.nds.hash.txt" should be excluded when "txt" is excluded). - if (ext and ext.lower() in excluded_extensions) or ( - suffix and suffix.lower() in excluded_extensions + # Check all right-anchored sub-extensions so that rules like "hash.txt" + # match multi-dot filenames such as "game.nds.enc.hash.txt". + if any( + e.lower() in excluded_extensions + for e in self.iter_file_extensions(file_name) ): excluded_files.append(file_name) diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 6b6d36c74..90f0aa0c8 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -456,12 +456,11 @@ class FSRomsHandler(FSHandler): f"{abs_fs_path}/{rom.fs_name}", recursive=True ): # Check if file is excluded by extension. - # Also check the last single extension for multi-dot filenames - # (e.g. "game.nds.hash.txt" should be excluded when "txt" is excluded). - ext = self.parse_file_extension(file_name) - suffix = Path(file_name).suffix.lstrip(".") - if (ext and ext.lower() in excluded_file_exts) or ( - suffix and suffix.lower() in excluded_file_exts + # Check all right-anchored sub-extensions so that rules like "hash.txt" + # match multi-dot filenames such as "game.nds.enc.hash.txt". + if any( + e.lower() in excluded_file_exts + for e in self.iter_file_extensions(file_name) ): continue diff --git a/backend/tests/handler/filesystem/test_base_handler.py b/backend/tests/handler/filesystem/test_base_handler.py index 3dd59dd96..5d9e329d4 100644 --- a/backend/tests/handler/filesystem/test_base_handler.py +++ b/backend/tests/handler/filesystem/test_base_handler.py @@ -145,6 +145,22 @@ class TestFSHandler: assert handler.parse_file_extension("no_extension") == "" assert handler.parse_file_extension("file.with.dots.txt") == "with.dots.txt" + def test_iter_file_extensions(self, handler: FSHandler): + """Test that all right-anchored sub-extensions are returned""" + assert handler.iter_file_extensions("game.nds") == ["nds"] + assert handler.iter_file_extensions("game.nds.hash.txt") == [ + "nds.hash.txt", + "hash.txt", + "txt", + ] + assert handler.iter_file_extensions("game.nds.enc.hash.txt") == [ + "nds.enc.hash.txt", + "enc.hash.txt", + "hash.txt", + "txt", + ] + assert handler.iter_file_extensions("no_extension") == [] + def test_exclude_single_files(self, handler: FSHandler): """Test file exclusion functionality""" files = ["test.txt", "game.rom", "excluded.tmp", "data.json"] @@ -162,7 +178,7 @@ class TestFSHandler: assert "data.json" in result def test_exclude_single_files_multi_dot(self, handler: FSHandler): - """Test that files with multiple dots are excluded based on their last extension""" + """Test that files with multiple dots are excluded by last or compound extension""" files = [ "game.nds", "game.nds.hash.txt", @@ -172,17 +188,29 @@ class TestFSHandler: ] with patch("handler.filesystem.base_handler.cm.get_config") as mock_config: + # Exclude by last single extension mock_config.return_value.EXCLUDED_SINGLE_EXT = ["txt"] mock_config.return_value.EXCLUDED_SINGLE_FILES = [] result = handler.exclude_single_files(files) - # Files with .txt as the last extension should be excluded regardless of - # how many dots are in the filename assert "game.nds.hash.txt" not in result assert "game.nds.enc.hash.txt" not in result assert "readme.txt" not in result - # Non-txt files should not be excluded + assert "game.nds" in result + assert "game.rom" in result + + with patch("handler.filesystem.base_handler.cm.get_config") as mock_config: + # Exclude by compound sub-extension "hash.txt" + mock_config.return_value.EXCLUDED_SINGLE_EXT = ["hash.txt"] + mock_config.return_value.EXCLUDED_SINGLE_FILES = [] + + result = handler.exclude_single_files(files) + + assert "game.nds.hash.txt" not in result + assert "game.nds.enc.hash.txt" not in result + # "readme.txt" does NOT end in "hash.txt" — should remain + assert "readme.txt" in result assert "game.nds" in result assert "game.rom" in result