diff --git a/backend/handler/filesystem/platforms_handler.py b/backend/handler/filesystem/platforms_handler.py index 7e204e4d7..e88a7936a 100644 --- a/backend/handler/filesystem/platforms_handler.py +++ b/backend/handler/filesystem/platforms_handler.py @@ -4,10 +4,8 @@ from anyio import Path as AnyioPath from config import LIBRARY_BASE_PATH from config.config_manager import config_manager as cm -from exceptions.fs_exceptions import ( - FolderStructureNotMatchException, - PlatformAlreadyExistsException, -) +from exceptions.fs_exceptions import PlatformAlreadyExistsException +from logger.logger import log from .base_handler import FSHandler, LibraryStructure @@ -59,9 +57,9 @@ class FSPlatformsHandler(FSHandler): cnfg = cm.get_config() # Fallback to config hint when detection is inconclusive: default to - # Structure A (roms/{platform}) so a malformed library fails loudly - # (FolderStructureNotMatchException) rather than treating the bare - # library root as a flat list of platforms. + # Structure A (roms/{platform}) so the bare library root is not treated + # as a flat list of platforms. When the roms folder is missing entirely, + # get_platforms() bootstraps it instead of failing. return "" if cnfg.has_structure_path_b else cnfg.ROMS_FOLDER_NAME def get_platform_fs_structure(self, fs_slug: str) -> str: @@ -90,6 +88,11 @@ class FSPlatformsHandler(FSHandler): async def get_platforms(self) -> list[str]: """Retrieves all platforms from the filesystem. + If no library structure exists yet (neither Structure A's top-level roms + folder nor a Structure B {platform}/roms folder), defaults to Structure A + by creating the roms folder and returns an empty list, so RomM starts + cleanly with an empty library instead of failing. + Returns: List of platform slugs. """ @@ -97,8 +100,19 @@ class FSPlatformsHandler(FSHandler): try: platforms = await self.list_directories(path=self.get_platforms_directory()) - except FileNotFoundError as e: - raise FolderStructureNotMatchException() from e + except FileNotFoundError: + # The platforms directory does not exist, which means no library + # structure has been set up yet. Bootstrap Structure A so the + # filesystem is in a valid state and report an empty library. + log.warning( + "No library structure found; creating default Structure A " + "(roms folder) and starting with an empty library." + ) + try: + self.create_library_structure() + except OSError: + log.error("Failed to create default library structure", exc_info=True) + return [] # For Structure B, only include directories that have a roms subfolder structure = self.detect_library_structure() diff --git a/backend/tests/handler/filesystem/test_platforms_handler.py b/backend/tests/handler/filesystem/test_platforms_handler.py index 5a6615c73..4440e8518 100644 --- a/backend/tests/handler/filesystem/test_platforms_handler.py +++ b/backend/tests/handler/filesystem/test_platforms_handler.py @@ -229,6 +229,48 @@ class TestFSPlatformsHandler: await handler.get_platforms() mock_list.assert_called_once_with(path="") + async def test_get_platforms_bootstraps_structure_a_when_none_detected( + self, handler: FSPlatformsHandler, config + ): + """When no structure exists, get_platforms creates Structure A (roms folder) + and returns an empty list instead of raising.""" + config.has_structure_path_a = False + config.has_structure_path_b = False + with patch( + "handler.filesystem.platforms_handler.cm.get_config", return_value=config + ): + with patch.object( + handler, "list_directories", side_effect=FileNotFoundError + ): + with patch.object(handler, "create_library_structure") as mock_create: + result = await handler.get_platforms() + + assert result == [] + mock_create.assert_called_once() + + async def test_get_platforms_returns_empty_when_bootstrap_fails( + self, handler: FSPlatformsHandler, config + ): + """If creating the default structure fails, get_platforms still returns an + empty list rather than propagating the error (so the heartbeat stays healthy). + """ + config.has_structure_path_a = False + config.has_structure_path_b = False + with patch( + "handler.filesystem.platforms_handler.cm.get_config", return_value=config + ): + with patch.object( + handler, "list_directories", side_effect=FileNotFoundError + ): + with patch.object( + handler, + "create_library_structure", + side_effect=PermissionError("read-only filesystem"), + ): + result = await handler.get_platforms() + + assert result == [] + def test_integration_with_base_handler_methods(self, handler: FSPlatformsHandler): """Test that FSPlatformsHandler properly inherits from FSHandler""" # Test that handler has base methods diff --git a/uv.lock b/uv.lock index dee754a3b..a141940b8 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for exclude-newer-span = "P7D" [options.exclude-newer-package] -starlette = "2026-05-23T00:00:00Z" +starlette = "2026-05-22T22:00:00Z" [[package]] name = "aiohappyeyeballs"