"""Startup migration runner for data file to database migration. This module provides functions to run the data file migration automatically during application startup. The migration checks for existing data files in the anime directory and migrates them to the database. Usage: This module is intended to be called from the FastAPI lifespan context. Example: @asynccontextmanager async def lifespan(app: FastAPI): # ... initialization ... await ensure_migration_on_startup() yield # ... cleanup ... """ from __future__ import annotations import logging from pathlib import Path from typing import Optional from src.server.database.connection import get_db_session from src.server.services.auth_service import auth_service from src.server.services.config_service import ConfigService from src.server.services.data_migration_service import ( MigrationResult, get_data_migration_service, ) logger = logging.getLogger(__name__) async def run_startup_migration(anime_directory: str) -> MigrationResult: """Run data file migration for the given anime directory. Checks if there are data files to migrate and runs the migration if needed. This function is idempotent - running it multiple times will only migrate files that haven't been migrated yet. Args: anime_directory: Path to the anime directory containing series folders with data files Returns: MigrationResult: Results of the migration operation, including counts of migrated, skipped, and failed items Note: This function creates its own database session and commits the transaction at the end of the migration. """ service = get_data_migration_service() # Check if migration is needed if not service.is_migration_needed(anime_directory): logger.info( "No data files found to migrate in: %s", anime_directory ) return MigrationResult(total_found=0) logger.info( "Starting data file migration from: %s", anime_directory ) # Get database session and run migration async with get_db_session() as db: result = await service.migrate_all(anime_directory, db) # Log results if result.migrated > 0 or result.failed > 0: logger.info( "Migration complete: %d migrated, %d skipped, %d failed", result.migrated, result.skipped, result.failed ) if result.errors: for error in result.errors: logger.warning("Migration error: %s", error) return result def _get_anime_directory_from_config() -> Optional[str]: """Get anime directory from application configuration. Attempts to load the configuration file and extract the anime_directory setting from the 'other' config section. Returns: Anime directory path if configured, None otherwise """ try: config_service = ConfigService() config = config_service.load_config() # anime_directory is stored in the 'other' dict anime_dir = config.other.get("anime_directory") if anime_dir: anime_dir = str(anime_dir).strip() if anime_dir: return anime_dir return None except Exception as e: logger.warning( "Could not load anime directory from config: %s", e ) return None def _is_setup_complete() -> bool: """Check if the application setup is complete. Setup is complete when: 1. Master password is configured 2. Configuration file exists and is valid Returns: True if setup is complete, False otherwise """ # Check if master password is configured if not auth_service.is_configured(): return False # Check if config exists and is valid try: config_service = ConfigService() config = config_service.load_config() # Validate the loaded config validation = config.validate() if not validation.valid: return False except Exception: # If we can't load or validate config, setup is not complete return False return True async def ensure_migration_on_startup() -> Optional[MigrationResult]: """Ensure data file migration runs during application startup. This function should be called during FastAPI application startup. It loads the anime directory from configuration and runs the migration if the directory is configured and contains data files. Migration will only run if setup is complete (master password configured and valid configuration exists). Returns: MigrationResult if migration was run, None if skipped (e.g., when no anime directory is configured) Behavior: - Returns None if anime_directory is not configured (first run) - Returns None if anime_directory does not exist - Returns MigrationResult with total_found=0 if no data files exist - Returns MigrationResult with migration counts if migration ran Note: This function catches and logs all exceptions without re-raising, ensuring that startup migration failures don't block application startup. Check the logs for any migration errors. Example: @asynccontextmanager async def lifespan(app: FastAPI): await init_db() try: result = await ensure_migration_on_startup() if result: logger.info( "Migration: %d migrated, %d failed", result.migrated, result.failed ) except Exception as e: logger.error("Migration failed: %s", e) yield await close_db() """ # Check if setup is complete before running migration if not _is_setup_complete(): logger.debug( "Setup not complete, skipping startup migration" ) return None # Get anime directory from config anime_directory = _get_anime_directory_from_config() if not anime_directory: logger.debug( "No anime directory configured, skipping migration" ) return None # Validate directory exists anime_path = Path(anime_directory) if not anime_path.exists(): logger.warning( "Anime directory does not exist: %s, skipping migration", anime_directory ) return None if not anime_path.is_dir(): logger.warning( "Anime directory path is not a directory: %s, skipping migration", anime_directory ) return None logger.info( "Checking for data files to migrate in: %s", anime_directory ) try: result = await run_startup_migration(anime_directory) return result except Exception as e: logger.error( "Data file migration failed: %s", e, exc_info=True ) # Return empty result rather than None to indicate we attempted return MigrationResult( total_found=0, failed=1, errors=[f"Migration failed: {str(e)}"] ) async def run_migration_for_directory( anime_directory: str ) -> Optional[MigrationResult]: """Run data file migration for a specific directory. This function can be called after setup is complete to migrate data files from the specified anime directory to the database. Unlike ensure_migration_on_startup, this does not check setup status as it's intended to be called after setup is complete. Args: anime_directory: Path to the anime directory containing series folders with data files Returns: MigrationResult if migration was run, None if directory invalid """ if not anime_directory or not anime_directory.strip(): logger.debug("Empty anime directory provided, skipping migration") return None anime_directory = anime_directory.strip() # Validate directory exists anime_path = Path(anime_directory) if not anime_path.exists(): logger.warning( "Anime directory does not exist: %s, skipping migration", anime_directory ) return None if not anime_path.is_dir(): logger.warning( "Anime directory path is not a directory: %s", anime_directory ) return None logger.info( "Running migration for directory: %s", anime_directory ) try: result = await run_startup_migration(anime_directory) return result except Exception as e: logger.error( "Data file migration failed for %s: %s", anime_directory, e, exc_info=True ) return MigrationResult( total_found=0, failed=1, errors=[f"Migration failed: {str(e)}"] )