Compare commits

...

2 Commits

Author SHA1 Message Date
50a77976d5 chore: bump version 2026-05-26 13:28:12 +02:00
dfc28b8e66 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.
2026-05-26 13:23:48 +02:00
7 changed files with 156 additions and 6 deletions

View File

@@ -1 +1 @@
v1.1.15 v1.1.16

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.1.15", "version": "1.1.16",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -163,6 +163,22 @@ async def setup_auth(req: SetupRequest):
# Perform NFO scan if configured # Perform NFO scan if configured
await perform_nfo_scan_if_needed(progress_service) 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 # Send completion event
from src.server.services.progress_service import ProgressType from src.server.services.progress_service import ProgressType
await progress_service.start_progress( await progress_service.start_progress(

View File

@@ -1,7 +1,10 @@
import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
logger = logging.getLogger(__name__)
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
from src.server.services.config_service import ( from src.server.services.config_service import (
ConfigBackupError, ConfigBackupError,
@@ -28,16 +31,53 @@ def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
@router.put("", response_model=AppConfig) @router.put("", response_model=AppConfig)
def update_config( async def update_config(
update: ConfigUpdate, auth: dict = Depends(require_auth) update: ConfigUpdate, auth: dict = Depends(require_auth)
) -> AppConfig: ) -> AppConfig:
"""Apply an update to the configuration and persist it. """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: try:
config_service = get_config_service() 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: except ConfigValidationError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -153,6 +153,22 @@ class SchedulerService:
self._is_running = False self._is_running = False
logger.info("SchedulerService stopped successfully") 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: async def trigger_rescan(self) -> bool:
"""Manually trigger a library rescan. """Manually trigger a library rescan.

View File

@@ -2,7 +2,7 @@
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@@ -207,3 +207,46 @@ async def test_tmdb_validation_endpoint_exists(authenticated_client):
assert "message" in data assert "message" in data
assert data["valid"] is False # Empty key should be invalid assert data["valid"] is False # Empty key should be invalid
assert "required" in data["message"].lower() 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()

View File

@@ -559,3 +559,38 @@ class TestStartupRecovery:
info_calls = [str(c) for c in mock_logger.info.call_args_list] info_calls = [str(c) for c in mock_logger.info.call_args_list]
assert any("next_run" in c for c in info_calls) 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()