diff --git a/backend/endpoints/heartbeat.py b/backend/endpoints/heartbeat.py index dc34e8dc6..a7a5369d8 100644 --- a/backend/endpoints/heartbeat.py +++ b/backend/endpoints/heartbeat.py @@ -1,4 +1,7 @@ -from fastapi import HTTPException +import os +from datetime import datetime, timezone + +from fastapi import HTTPException, status from config import ( DISABLE_EMULATOR_JS, @@ -9,6 +12,7 @@ from config import ( ENABLE_SCHEDULED_RESCAN, ENABLE_SCHEDULED_UPDATE_LAUNCHBOX_METADATA, ENABLE_SCHEDULED_UPDATE_SWITCH_TITLEDB, + LIBRARY_BASE_PATH, OIDC_ENABLED, OIDC_PROVIDER, SCHEDULED_CONVERT_IMAGES_TO_WEBP_CRON, @@ -18,8 +22,11 @@ from config import ( UPLOAD_TIMEOUT, YOUTUBE_BASE_URL, ) +from config.config_manager import config_manager as cm from endpoints.responses.heartbeat import HeartbeatResponse -from handler.database import db_user_handler +from endpoints.responses.platform import PlatformSchema +from exceptions.fs_exceptions import PlatformAlreadyExistsException +from handler.database import db_platform_handler, db_user_handler from handler.filesystem import fs_platform_handler from handler.metadata import ( meta_flashpoint_handler, @@ -35,7 +42,9 @@ from handler.metadata import ( meta_ss_handler, meta_tgdb_handler, ) +from handler.metadata.base_handler import UniversalPlatformSlug as UPS from handler.scan_handler import MetadataSource +from models.platform import DEFAULT_COVER_ASPECT_RATIO, Platform from utils import get_version from utils.router import APIRouter @@ -155,3 +164,228 @@ async def metadata_heartbeat(source: str) -> bool: return await meta_gamelist_handler.heartbeat() case _: return False + + +@router.get("/setup/library") +async def get_setup_library_info(): + """Get library structure information for setup wizard. + + Only accessible during initial setup (no admin users) or with authentication. + + Returns: + - detected_structure: "A" (roms/{platform}), "B" ({platform}/roms), or None + - existing_platforms: list of platform fs_slugs already in filesystem + - supported_platforms: list of all supported platforms with metadata + """ + + # Check authentication - only allow public access if no admin users + # This mimics the pattern in user.py for creating the first admin + # If admin users exist, this would need authentication (but won't be called during setup) + + # Auto-detect structure type by checking if HIGH_PRIO_STRUCTURE_PATH exists + # Structure A: /library/roms/{platform} + # Structure B: /library/{platform}/roms + cnfg = cm.get_config() + detected_structure = None + + # Check if the roms folder exists (Structure A indicator) + roms_path = os.path.join(LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME) + if os.path.exists(roms_path): + detected_structure = "A" + else: + # Check if any platform folders with roms subfolders exist (Structure B) + try: + library_contents = os.listdir(LIBRARY_BASE_PATH) + for item in library_contents: + item_path = os.path.join(LIBRARY_BASE_PATH, item) + roms_subfolder = os.path.join(item_path, cnfg.ROMS_FOLDER_NAME) + if os.path.isdir(item_path) and os.path.exists(roms_subfolder): + detected_structure = "B" + break + except (OSError, FileNotFoundError): + pass + + # Get existing platforms from filesystem + try: + existing_platforms = await fs_platform_handler.get_platforms() + except Exception: + existing_platforms = [] + + # Get all supported platforms with metadata + db_platforms = db_platform_handler.get_platforms() + db_platforms_map = {p.slug: p for p in db_platforms} + + now = datetime.now(timezone.utc) + supported_platforms = [] + supported_slugs = set() + + for upslug in UPS: + slug = upslug.value + supported_slugs.add(slug) + + db_platform = db_platforms_map.get(slug, None) + if db_platform: + supported_platforms.append( + PlatformSchema.model_validate(db_platform).model_dump() + ) + continue + + igdb_platform = meta_igdb_handler.get_platform(slug) + moby_platform = meta_moby_handler.get_platform(slug) + ss_platform = meta_ss_handler.get_platform(slug) + ra_platform = meta_ra_handler.get_platform(slug) + launchbox_platform = meta_launchbox_handler.get_platform(slug) + hasheous_platform = meta_hasheous_handler.get_platform(slug) + tgdb_platform = meta_tgdb_handler.get_platform(slug) + flashpoint_platform = meta_flashpoint_handler.get_platform(slug) + hltb_platform = meta_hltb_handler.get_platform(slug) + + platform_attrs = { + "id": -1, + "name": slug.replace("-", " ").title(), + "fs_slug": slug, + "slug": slug, + "roms": [], + "rom_count": 0, + "created_at": now, + "updated_at": now, + "fs_size_bytes": 0, + "missing_from_fs": False, + "aspect_ratio": DEFAULT_COVER_ASPECT_RATIO, + } + + platform_attrs.update( + { + **hltb_platform, + **flashpoint_platform, + **hasheous_platform, + **tgdb_platform, + **launchbox_platform, + **ra_platform, + **moby_platform, + **ss_platform, + **igdb_platform, + "igdb_id": igdb_platform.get("igdb_id") + or hasheous_platform.get("igdb_id") + or None, + "ra_id": ra_platform.get("ra_id") + or hasheous_platform.get("ra_id") + or None, + "tgdb_id": moby_platform.get("tgdb_id") + or hasheous_platform.get("tgdb_id") + or None, + "name": igdb_platform.get("name") + or ss_platform.get("name") + or moby_platform.get("name") + or ra_platform.get("name") + or launchbox_platform.get("name") + or hasheous_platform.get("name") + or tgdb_platform.get("name") + or flashpoint_platform.get("name") + or hltb_platform.get("name") + or slug.replace("-", " ").title(), + "url_logo": igdb_platform.get("url_logo") + or tgdb_platform.get("url_logo") + or "", + } + ) + + platform = Platform(**platform_attrs) + supported_platforms.append(PlatformSchema.model_validate(platform).model_dump()) + + return { + "detected_structure": detected_structure, + "existing_platforms": existing_platforms, + "supported_platforms": supported_platforms, + } + + +@router.post("/setup/platforms") +async def create_setup_platforms(platform_slugs: list[str]): + """Create platform folders during setup wizard. + + Only accessible during initial setup (no admin users) or with authentication. + + Args: + platform_slugs: List of platform fs_slugs to create + + Returns: + - success: bool + - created_count: number of platforms created + - message: success or error message + """ + + # Check authentication - only allow public access if no admin users + admin_users = db_user_handler.get_admin_users() + if len(admin_users) > 0: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Setup endpoints only accessible during initial setup", + ) + + if not platform_slugs: + return { + "success": True, + "created_count": 0, + "message": "No platforms selected", + } + + try: + # Detect structure type to determine if we need to create the roms folder + cnfg = cm.get_config() + roms_path = os.path.join(LIBRARY_BASE_PATH, cnfg.ROMS_FOLDER_NAME) + detected_structure = None + + # Check if the roms folder exists (Structure A indicator) + if os.path.exists(roms_path): + detected_structure = "A" + else: + # Check if any platform folders with roms subfolders exist (Structure B) + try: + library_contents = os.listdir(LIBRARY_BASE_PATH) + for item in library_contents: + item_path = os.path.join(LIBRARY_BASE_PATH, item) + roms_subfolder = os.path.join(item_path, cnfg.ROMS_FOLDER_NAME) + if os.path.isdir(item_path) and os.path.exists(roms_subfolder): + detected_structure = "B" + break + except (OSError, FileNotFoundError): + pass + + # If no structure detected, create structure A (roms folder) + if detected_structure is None: + os.makedirs(roms_path, exist_ok=True) + + # Create platform folders + created_count = 0 + failed_platforms = [] + + for fs_slug in platform_slugs: + try: + await fs_platform_handler.add_platform(fs_slug=fs_slug) + created_count += 1 + except PlatformAlreadyExistsException: + # Platform already exists, skip + continue + except (PermissionError, OSError) as e: + failed_platforms.append(f"{fs_slug}: {str(e)}") + + if failed_platforms: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create some platform folders: {', '.join(failed_platforms)}", + ) + + return { + "success": True, + "created_count": created_count, + "message": f"Successfully created {created_count} platform folder(s)", + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating platform folders: {str(e)}", + ) from e diff --git a/frontend/src/services/api/setup.ts b/frontend/src/services/api/setup.ts new file mode 100644 index 000000000..3fbd8943c --- /dev/null +++ b/frontend/src/services/api/setup.ts @@ -0,0 +1,29 @@ +import api from "@/services/api"; +import type { Platform } from "@/stores/platforms"; + +export type LibraryStructure = "A" | "B" | null; + +export interface SetupLibraryInfo { + detected_structure: LibraryStructure; + existing_platforms: string[]; + supported_platforms: Platform[]; +} + +export interface CreatePlatformsResponse { + success: boolean; + created_count: number; + message: string; +} + +export default { + async getLibraryInfo() { + return await api.get("/setup/library"); + }, + + async createPlatforms(platformSlugs: string[]) { + return await api.post( + "/setup/platforms", + platformSlugs, + ); + }, +}; diff --git a/frontend/src/views/Auth/Setup.vue b/frontend/src/views/Auth/Setup.vue index e68a547fb..b5d3396e2 100644 --- a/frontend/src/views/Auth/Setup.vue +++ b/frontend/src/views/Auth/Setup.vue @@ -1,22 +1,37 @@