fix: handle lifespan errors gracefully

- Add error tracking in lifespan context manager
- Only cleanup services that were successfully initialized
- Properly handle startup errors without breaking async context
- Fixes RuntimeError: generator didn't stop after athrow()
This commit is contained in:
2026-01-23 17:13:30 +01:00
parent 314f535446
commit 611798b786
4 changed files with 409 additions and 349 deletions

View File

@@ -1,5 +1,5 @@
"""Centralized initialization service for application startup and setup."""
from typing import Optional
from typing import Callable
import structlog
@@ -9,6 +9,174 @@ from src.server.services.anime_service import sync_series_from_data_files
logger = structlog.get_logger(__name__)
async def _check_scan_status(
check_method: Callable,
scan_type: str,
log_completed_msg: str = None,
log_not_completed_msg: str = None
) -> bool:
"""Generic function to check if a scan has been completed.
Args:
check_method: SystemSettingsService method to check scan status
scan_type: Type of scan (e.g., "initial", "NFO", "media")
log_completed_msg: Optional custom message when scan is completed
log_not_completed_msg: Optional custom message when scan not completed
Returns:
bool: True if scan was completed, False otherwise
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
try:
async with get_db_session() as db:
is_completed = await check_method(SystemSettingsService, db)
if is_completed and log_completed_msg:
logger.info(log_completed_msg)
elif not is_completed and log_not_completed_msg:
logger.info(log_not_completed_msg)
return is_completed
except Exception as e:
logger.warning(
"Failed to check %s scan status: %s, assuming not done",
scan_type,
e
)
return False
async def _mark_scan_completed(
mark_method: Callable,
scan_type: str
) -> None:
"""Generic function to mark a scan as completed.
Args:
mark_method: SystemSettingsService method to mark scan as completed
scan_type: Type of scan (e.g., "initial", "NFO", "media")
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
try:
async with get_db_session() as db:
await mark_method(SystemSettingsService, db)
logger.info("Marked %s scan as completed", scan_type)
except Exception as e:
logger.warning("Failed to mark %s scan as completed: %s", scan_type, e)
async def _check_initial_scan_status() -> bool:
"""Check if initial scan has been completed.
Returns:
bool: True if scan was completed, False otherwise
"""
is_completed = await _check_scan_status(
check_method=lambda svc, db: svc.is_initial_scan_completed(db),
scan_type="initial",
log_completed_msg=(
"Initial scan already completed, skipping data file sync"
),
log_not_completed_msg=(
"Initial scan not completed, performing first-time setup"
)
)
return is_completed
async def _mark_initial_scan_completed() -> None:
"""Mark the initial scan as completed in system settings."""
await _mark_scan_completed(
mark_method=lambda svc, db: svc.mark_initial_scan_completed(db),
scan_type="initial"
)
async def _sync_anime_folders(progress_service=None) -> int:
"""Scan anime folders and sync series to database.
Args:
progress_service: Optional ProgressService for progress updates
Returns:
int: Number of series synced
"""
logger.info("Performing initial anime folder scan...")
if progress_service:
await progress_service.update_progress(
progress_id="series_sync",
current=25,
message="Scanning anime folders...",
metadata={"step_id": "series_sync"}
)
sync_count = await sync_series_from_data_files(settings.anime_directory)
logger.info("Data file sync complete. Added %d series.", sync_count)
if progress_service:
await progress_service.update_progress(
progress_id="series_sync",
current=75,
message=f"Synced {sync_count} series from data files",
metadata={"step_id": "series_sync"}
)
return sync_count
async def _load_series_into_memory(progress_service=None) -> None:
"""Load series from database into SeriesApp's in-memory cache.
Args:
progress_service: Optional ProgressService for progress updates
"""
from src.server.utils.dependencies import get_anime_service
anime_service = get_anime_service()
await anime_service._load_series_from_db()
logger.info("Series loaded from database into memory")
if progress_service:
await progress_service.complete_progress(
progress_id="series_sync",
message="Series loaded into memory",
metadata={"step_id": "series_sync"}
)
async def _validate_anime_directory(progress_service=None) -> bool:
"""Validate that anime directory is configured.
Args:
progress_service: Optional ProgressService for progress updates
Returns:
bool: True if directory is configured, False otherwise
"""
logger.info(
"Checking anime_directory setting: '%s'",
settings.anime_directory
)
if not settings.anime_directory:
logger.info("Initialization skipped - anime directory not configured")
if progress_service:
await progress_service.complete_progress(
progress_id="series_sync",
message="No anime directory configured",
metadata={"step_id": "series_sync"}
)
return False
return True
async def perform_initial_setup(progress_service=None):
"""Perform initial setup including series sync and scan completion marking.
@@ -21,14 +189,11 @@ async def perform_initial_setup(progress_service=None):
5. Media scan is performed
Args:
progress_service: Optional ProgressService instance for emitting updates
progress_service: Optional ProgressService for emitting updates
Returns:
bool: True if initialization was performed, False if skipped
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
# Send initial progress update
if progress_service:
from src.server.services.progress_service import ProgressType
@@ -41,110 +206,31 @@ async def perform_initial_setup(progress_service=None):
metadata={"step_id": "series_sync"}
)
# Check if initial setup has been completed
try:
async with get_db_session() as db:
is_initial_scan_done = (
await SystemSettingsService.is_initial_scan_completed(db)
)
if is_initial_scan_done:
logger.info(
"Initial scan already completed, skipping data file sync"
)
if progress_service:
await progress_service.complete_progress(
progress_id="series_sync",
message="Already completed",
metadata={"step_id": "series_sync"}
)
return False
else:
logger.info(
"Initial scan not completed, "
"performing first-time setup"
)
except Exception as e:
logger.warning(
"Failed to check system settings: %s, assuming first run", e
)
is_initial_scan_done = False
# Sync series from data files to database (only on first run)
try:
logger.info(
"Checking anime_directory setting: '%s'",
settings.anime_directory
)
if not settings.anime_directory:
logger.info(
"Initialization skipped - anime directory not configured"
)
if progress_service:
await progress_service.complete_progress(
progress_id="series_sync",
message="No anime directory configured",
metadata={"step_id": "series_sync"}
)
return False
# Only sync from data files on first run
if not is_initial_scan_done:
logger.info("Performing initial anime folder scan...")
if progress_service:
await progress_service.update_progress(
progress_id="series_sync",
current=25,
message="Scanning anime folders...",
metadata={"step_id": "series_sync"}
)
sync_count = await sync_series_from_data_files(
settings.anime_directory
)
logger.info(
"Data file sync complete. Added %d series.", sync_count
)
if progress_service:
await progress_service.update_progress(
progress_id="series_sync",
current=75,
message=f"Synced {sync_count} series from data files",
metadata={"step_id": "series_sync"}
)
# Mark initial scan as completed
try:
async with get_db_session() as db:
await (
SystemSettingsService
.mark_initial_scan_completed(db)
)
logger.info("Marked initial scan as completed")
except Exception as e:
logger.warning(
"Failed to mark initial scan as completed: %s", e
)
else:
logger.info(
"Skipping initial scan - "
"already completed on previous run"
)
# Load series from database into SeriesApp's in-memory cache
from src.server.utils.dependencies import get_anime_service
anime_service = get_anime_service()
await anime_service._load_series_from_db()
logger.info("Series loaded from database into memory")
# Check if initial setup has already been completed
is_initial_scan_done = await _check_initial_scan_status()
if is_initial_scan_done:
if progress_service:
await progress_service.complete_progress(
progress_id="series_sync",
message="Series loaded into memory",
message="Already completed",
metadata={"step_id": "series_sync"}
)
return False
# Validate that anime directory is configured
if not await _validate_anime_directory(progress_service):
return False
# Perform the actual initialization
try:
# Sync series from anime folders to database
await _sync_anime_folders(progress_service)
# Mark the initial scan as completed
await _mark_initial_scan_completed()
# Load series into memory from database
await _load_series_into_memory(progress_service)
return True
@@ -153,15 +239,86 @@ async def perform_initial_setup(progress_service=None):
return False
async def _check_nfo_scan_status() -> bool:
"""Check if initial NFO scan has been completed.
Returns:
bool: True if NFO scan was completed, False otherwise
"""
return await _check_scan_status(
check_method=lambda svc, db: svc.is_initial_nfo_scan_completed(db),
scan_type="NFO"
)
async def _mark_nfo_scan_completed() -> None:
"""Mark the initial NFO scan as completed in system settings."""
await _mark_scan_completed(
mark_method=lambda svc, db: svc.mark_initial_nfo_scan_completed(db),
scan_type="NFO"
)
async def _is_nfo_scan_configured() -> bool:
"""Check if NFO scan features are properly configured.
Returns:
bool: True if TMDB API key and NFO features are configured
"""
return settings.tmdb_api_key and (
settings.nfo_auto_create or settings.nfo_update_on_scan
)
async def _execute_nfo_scan(progress_service=None) -> None:
"""Execute the actual NFO scan with TMDB data.
Args:
progress_service: Optional ProgressService for progress updates
Raises:
Exception: If NFO scan fails
"""
from src.core.services.series_manager_service import SeriesManagerService
logger.info("Performing initial NFO scan...")
if progress_service:
await progress_service.update_progress(
progress_id="nfo_scan",
current=25,
message="Scanning series for NFO files...",
metadata={"step_id": "nfo_scan"}
)
manager = SeriesManagerService.from_settings()
if progress_service:
await progress_service.update_progress(
progress_id="nfo_scan",
current=50,
message="Processing NFO files with TMDB data...",
metadata={"step_id": "nfo_scan"}
)
await manager.scan_and_process_nfo()
await manager.close()
logger.info("Initial NFO scan completed")
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="NFO scan completed successfully",
metadata={"step_id": "nfo_scan"}
)
async def perform_nfo_scan_if_needed(progress_service=None):
"""Perform initial NFO scan if not yet completed and configured.
Args:
progress_service: Optional ProgressService instance for emitting updates
progress_service: Optional ProgressService for emitting updates
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
if progress_service:
from src.server.services.progress_service import ProgressType
await progress_service.start_progress(
@@ -173,104 +330,17 @@ async def perform_nfo_scan_if_needed(progress_service=None):
metadata={"step_id": "nfo_scan"}
)
# Check if initial NFO scan has been completed
try:
async with get_db_session() as db:
is_nfo_scan_done = (
await SystemSettingsService
.is_initial_nfo_scan_completed(db)
)
except Exception as e:
logger.warning(
"Failed to check NFO scan status: %s, assuming not done",
e
)
is_nfo_scan_done = False
# Check if NFO scan was already completed
is_nfo_scan_done = await _check_nfo_scan_status()
# Run NFO scan only on first run (if configured)
if settings.tmdb_api_key and (
settings.nfo_auto_create or settings.nfo_update_on_scan
):
if not is_nfo_scan_done:
logger.info("Performing initial NFO scan...")
if progress_service:
await progress_service.update_progress(
progress_id="nfo_scan",
current=25,
message="Scanning series for NFO files...",
metadata={"step_id": "nfo_scan"}
)
try:
from src.core.services.series_manager_service import (
SeriesManagerService,
)
manager = SeriesManagerService.from_settings()
if progress_service:
await progress_service.update_progress(
progress_id="nfo_scan",
current=50,
message="Processing NFO files with TMDB data...",
metadata={"step_id": "nfo_scan"}
)
await manager.scan_and_process_nfo()
await manager.close()
logger.info("Initial NFO scan completed")
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="NFO scan completed successfully",
metadata={"step_id": "nfo_scan"}
)
# Mark NFO scan as completed
try:
async with get_db_session() as db:
await (
SystemSettingsService
.mark_initial_nfo_scan_completed(db)
)
logger.info("Marked NFO scan as completed")
except Exception as e:
logger.warning(
"Failed to mark NFO scan as completed: %s",
e
)
except Exception as e:
logger.error(
"Failed to complete NFO scan: %s",
e,
exc_info=True
)
if progress_service:
await progress_service.fail_progress(
progress_id="nfo_scan",
error_message=f"NFO scan failed: {str(e)}",
metadata={"step_id": "nfo_scan"}
)
else:
logger.info(
"Skipping NFO scan - already completed on previous run"
)
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="Already completed",
metadata={"step_id": "nfo_scan"}
)
else:
if not settings.tmdb_api_key:
logger.info(
"NFO scan skipped - TMDB API key not configured"
)
message = "Skipped - TMDB API key not configured"
else:
message = "Skipped - NFO features disabled"
# Check if NFO features are configured
if not await _is_nfo_scan_configured():
message = (
"Skipped - TMDB API key not configured"
if not settings.tmdb_api_key
else "Skipped - NFO features disabled"
)
logger.info(f"NFO scan skipped: {message}")
if progress_service:
await progress_service.complete_progress(
@@ -278,11 +348,67 @@ async def perform_nfo_scan_if_needed(progress_service=None):
message=message,
metadata={"step_id": "nfo_scan"}
)
else:
logger.info(
"NFO scan skipped - auto_create and update_on_scan "
"both disabled"
return
# Skip if already completed
if is_nfo_scan_done:
logger.info("Skipping NFO scan - already completed on previous run")
if progress_service:
await progress_service.complete_progress(
progress_id="nfo_scan",
message="Already completed",
metadata={"step_id": "nfo_scan"}
)
return
# Execute the NFO scan
try:
await _execute_nfo_scan(progress_service)
await _mark_nfo_scan_completed()
except Exception as e:
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
if progress_service:
await progress_service.fail_progress(
progress_id="nfo_scan",
error_message=f"NFO scan failed: {str(e)}",
metadata={"step_id": "nfo_scan"}
)
async def _check_media_scan_status() -> bool:
"""Check if initial media scan has been completed.
Returns:
bool: True if media scan was completed, False otherwise
"""
return await _check_scan_status(
check_method=lambda svc, db: svc.is_initial_media_scan_completed(db),
scan_type="media"
)
async def _mark_media_scan_completed() -> None:
"""Mark the initial media scan as completed in system settings."""
await _mark_scan_completed(
mark_method=lambda svc, db: svc.mark_initial_media_scan_completed(db),
scan_type="media"
)
async def _execute_media_scan(background_loader) -> None:
"""Execute the actual media scan and queue background loading.
Args:
background_loader: The background loader service instance
Raises:
Exception: If media scan fails
"""
from src.server.fastapi_app import _check_incomplete_series_on_startup
logger.info("Performing initial media scan...")
await _check_incomplete_series_on_startup(background_loader)
logger.info("Initial media scan completed")
async def perform_media_scan_if_needed(background_loader):
@@ -291,55 +417,16 @@ async def perform_media_scan_if_needed(background_loader):
Args:
background_loader: The background loader service instance
"""
from src.server.database.connection import get_db_session
from src.server.database.system_settings_service import SystemSettingsService
# Check if initial media scan has been completed
is_media_scan_done = False
try:
async with get_db_session() as db:
is_media_scan_done = (
await SystemSettingsService
.is_initial_media_scan_completed(db)
)
except Exception as e:
logger.warning(
"Failed to check media scan status: %s, assuming not done",
e
)
is_media_scan_done = False
# Check if media scan was already completed
is_media_scan_done = await _check_media_scan_status()
# Run media scan only on first run
if not is_media_scan_done:
logger.info("Performing initial media scan...")
try:
# Import the helper function from fastapi_app
from src.server.fastapi_app import _check_incomplete_series_on_startup
# Check for incomplete series and queue background loading
await _check_incomplete_series_on_startup(background_loader)
logger.info("Initial media scan completed")
# Mark media scan as completed
try:
async with get_db_session() as db:
await (
SystemSettingsService
.mark_initial_media_scan_completed(db)
)
logger.info("Marked media scan as completed")
except Exception as e:
logger.warning(
"Failed to mark media scan as completed: %s",
e
)
except Exception as e:
logger.error(
"Failed to complete media scan: %s",
e,
exc_info=True
)
else:
logger.info(
"Skipping media scan - already completed on previous run"
)
if is_media_scan_done:
logger.info("Skipping media scan - already completed on previous run")
return
# Execute the media scan
try:
await _execute_media_scan(background_loader)
await _mark_media_scan_completed()
except Exception as e:
logger.error("Failed to complete media scan: %s", e, exc_info=True)