From dfc28b8e66ee674ea20c36124616c8fc67d20b01 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 26 May 2026 13:23:48 +0200 Subject: [PATCH] fix(scheduler): ensure scheduler starts after setup/config update Add ensure_started() to SchedulerService as idempotent entry point. Start scheduler in auth setup run_initialization() after NFO scan. Sync anime_directory and start scheduler in config update endpoint. Add unit and endpoint tests for ensure_started() behavior. --- src/server/api/auth.py | 16 +++++++++ src/server/api/config.py | 46 ++++++++++++++++++++++-- src/server/services/scheduler_service.py | 16 +++++++++ tests/api/test_config_endpoints.py | 45 ++++++++++++++++++++++- tests/unit/test_scheduler_service.py | 35 ++++++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) diff --git a/src/server/api/auth.py b/src/server/api/auth.py index 3a3b849..8409311 100644 --- a/src/server/api/auth.py +++ b/src/server/api/auth.py @@ -163,6 +163,22 @@ async def setup_auth(req: SetupRequest): # Perform NFO scan if configured await perform_nfo_scan_if_needed(progress_service) + # Start scheduler if anime_directory is now set + try: + from src.server.services.scheduler_service import ( + get_scheduler_service, + ) + + scheduler_svc = get_scheduler_service() + logger.info("Starting scheduler after initialization") + await scheduler_svc.ensure_started() + logger.info("Scheduler started successfully during setup") + except Exception as sched_exc: + logger.warning( + "Failed to start scheduler during setup: %s", sched_exc + ) + # Continue — scheduler failure should not break initialization + # Send completion event from src.server.services.progress_service import ProgressType await progress_service.start_progress( diff --git a/src/server/api/config.py b/src/server/api/config.py index ff3a149..6717af9 100644 --- a/src/server/api/config.py +++ b/src/server/api/config.py @@ -1,7 +1,10 @@ +import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status +logger = logging.getLogger(__name__) + from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult from src.server.services.config_service import ( ConfigBackupError, @@ -28,16 +31,53 @@ def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig: @router.put("", response_model=AppConfig) -def update_config( +async def update_config( update: ConfigUpdate, auth: dict = Depends(require_auth) ) -> AppConfig: """Apply an update to the configuration and persist it. - Creates automatic backup before applying changes. + Creates automatic backup before applying changes. If anime_directory + is configured, starts the scheduler service. """ try: config_service = get_config_service() - return config_service.update_config(update) + updated_config = config_service.update_config(update) + + # Sync anime_directory to settings if it was updated + from src.config.settings import settings as app_settings + + anime_dir_changed = False + if update.other and update.other.get("anime_directory"): + anime_dir = update.other.get("anime_directory") + if anime_dir and not app_settings.anime_directory: + app_settings.anime_directory = str(anime_dir) + anime_dir_changed = True + logger.info("Synced anime_directory from config: %s", anime_dir) + + # Start scheduler if anime_directory was just configured + if anime_dir_changed: + try: + from src.server.services.scheduler_service import ( + get_scheduler_service, + ) + + scheduler_svc = get_scheduler_service() + logger.info( + "Starting scheduler after anime_directory configuration" + ) + await scheduler_svc.ensure_started() + logger.info( + "Scheduler started successfully after config update" + ) + except Exception as sched_exc: + logger.warning( + "Failed to start scheduler after config update: %s", + sched_exc, + ) + # Config was already saved, don't fail the request + + return updated_config + except ConfigValidationError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/src/server/services/scheduler_service.py b/src/server/services/scheduler_service.py index c3654c2..0b44e70 100644 --- a/src/server/services/scheduler_service.py +++ b/src/server/services/scheduler_service.py @@ -153,6 +153,22 @@ class SchedulerService: self._is_running = False logger.info("SchedulerService stopped successfully") + async def ensure_started(self) -> None: + """Ensure the scheduler is running (idempotent). + + If already running, returns immediately. Otherwise, starts the scheduler. + This method is safe to call multiple times and from multiple callers. + + Raises: + SchedulerServiceError: If startup fails (except for already running). + """ + if self._is_running: + logger.debug("Scheduler ensure_started called but already running") + return + + logger.info("Scheduler ensure_started: starting scheduler") + await self.start() + async def trigger_rescan(self) -> bool: """Manually trigger a library rescan. diff --git a/tests/api/test_config_endpoints.py b/tests/api/test_config_endpoints.py index fe968ce..ffaea57 100644 --- a/tests/api/test_config_endpoints.py +++ b/tests/api/test_config_endpoints.py @@ -2,7 +2,7 @@ import tempfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from httpx import ASGITransport, AsyncClient @@ -207,3 +207,46 @@ async def test_tmdb_validation_endpoint_exists(authenticated_client): assert "message" in data assert data["valid"] is False # Empty key should be invalid assert "required" in data["message"].lower() + + +@pytest.mark.asyncio +async def test_update_config_with_anime_directory_starts_scheduler( + authenticated_client, mock_config_service +): + """PUT /api/config with anime_directory syncs and starts scheduler.""" + mock_scheduler = AsyncMock() + + with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn: + mock_sched_fn.return_value = mock_scheduler + + with patch("src.config.settings.settings") as mock_settings: + mock_settings.anime_directory = None + + resp = await authenticated_client.put( + "/api/config", + json={"other": {"anime_directory": "/data/anime"}}, + ) + + assert resp.status_code == 200 + mock_scheduler.ensure_started.assert_called_once() + + +@pytest.mark.asyncio +async def test_update_config_without_anime_directory_does_not_start_scheduler( + authenticated_client, mock_config_service +): + """PUT /api/config without new anime_directory does not call scheduler.ensure_started().""" + mock_scheduler = AsyncMock() + + with patch("src.server.services.scheduler_service.get_scheduler_service") as mock_sched_fn: + mock_sched_fn.return_value = mock_scheduler + + with patch("src.config.settings.settings") as mock_settings: + mock_settings.anime_directory = "/already/set" + + resp = await authenticated_client.put( + "/api/config", json={"other": {}} + ) + + assert resp.status_code == 200 + mock_scheduler.ensure_started.assert_not_called() diff --git a/tests/unit/test_scheduler_service.py b/tests/unit/test_scheduler_service.py index 255148a..bd3ce15 100644 --- a/tests/unit/test_scheduler_service.py +++ b/tests/unit/test_scheduler_service.py @@ -559,3 +559,38 @@ class TestStartupRecovery: info_calls = [str(c) for c in mock_logger.info.call_args_list] assert any("next_run" in c for c in info_calls) + +# --------------------------------------------------------------------------- +# 12.8 ensure_started() – idempotent startup +# --------------------------------------------------------------------------- + +class TestEnsureStarted: + @pytest.mark.asyncio + async def test_ensure_started_when_not_running( + self, scheduler_service, mock_config_service + ): + """ensure_started() calls start() when scheduler is not running.""" + # Mock start method + scheduler_service.start = AsyncMock() + + # Call ensure_started + await scheduler_service.ensure_started() + + # Verify start() was called exactly once + scheduler_service.start.assert_called_once() + + @pytest.mark.asyncio + async def test_ensure_started_when_already_running(self, scheduler_service): + """ensure_started() returns immediately when already running (idempotent).""" + # Set up as already running + scheduler_service._is_running = True + + # Mock start method + scheduler_service.start = AsyncMock() + + # Call ensure_started + await scheduler_service.ensure_started() + + # Verify start() was NOT called + scheduler_service.start.assert_not_called() +