feat: cron-based scheduler with auto-download after rescan
- Replace asyncio sleep loop with APScheduler AsyncIOScheduler + CronTrigger
- Add schedule_time (HH:MM), schedule_days (days of week), auto_download_after_rescan fields to SchedulerConfig
- Add _auto_download_missing() to queue missing episodes after rescan
- Reload config live via reload_config(SchedulerConfig) without restart
- Update GET/POST /api/scheduler/config to return {success, config, status} envelope
- Add day-of-week pill toggles to Settings -> Scheduler section in UI
- Update JS loadSchedulerConfig / saveSchedulerConfig for new API shape
- Add 29 unit tests for SchedulerConfig model, 18 unit tests for SchedulerService
- Rewrite 23 endpoint tests and 36 integration tests for APScheduler behaviour
- Coverage: 96% api/scheduler, 95% scheduler_service, 90% total (>= 80% threshold)
- Update docs: API.md, CONFIGURATION.md, features.md, CHANGELOG.md
This commit is contained in:
@@ -4,12 +4,13 @@ This module provides endpoints for managing scheduled tasks such as
|
||||
automatic anime library rescans.
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from src.server.models.config import SchedulerConfig
|
||||
from src.server.services.config_service import ConfigServiceError, get_config_service
|
||||
from src.server.services.scheduler_service import get_scheduler_service
|
||||
from src.server.utils.dependencies import require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,78 +18,105 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
|
||||
|
||||
|
||||
@router.get("/config", response_model=SchedulerConfig)
|
||||
def get_scheduler_config(
|
||||
auth: Optional[dict] = Depends(require_auth)
|
||||
) -> SchedulerConfig:
|
||||
"""Get current scheduler configuration.
|
||||
def _build_response(config: SchedulerConfig) -> Dict[str, Any]:
|
||||
"""Build a standardised GET/POST response combining config + runtime status."""
|
||||
scheduler_service = get_scheduler_service()
|
||||
runtime = scheduler_service.get_status()
|
||||
|
||||
Args:
|
||||
auth: Authentication token (optional for read operations)
|
||||
return {
|
||||
"success": True,
|
||||
"config": {
|
||||
"enabled": config.enabled,
|
||||
"interval_minutes": config.interval_minutes,
|
||||
"schedule_time": config.schedule_time,
|
||||
"schedule_days": config.schedule_days,
|
||||
"auto_download_after_rescan": config.auto_download_after_rescan,
|
||||
},
|
||||
"status": {
|
||||
"is_running": runtime.get("is_running", False),
|
||||
"next_run": runtime.get("next_run"),
|
||||
"last_run": runtime.get("last_run"),
|
||||
"scan_in_progress": runtime.get("scan_in_progress", False),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_scheduler_config(
|
||||
auth: Optional[dict] = Depends(require_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""Get current scheduler configuration along with runtime status.
|
||||
|
||||
Returns:
|
||||
SchedulerConfig: Current scheduler configuration
|
||||
Combined config and status response.
|
||||
|
||||
Raises:
|
||||
HTTPException: If configuration cannot be loaded
|
||||
HTTPException: 500 if configuration cannot be loaded.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
app_config = config_service.load_config()
|
||||
return app_config.scheduler
|
||||
except ConfigServiceError as e:
|
||||
logger.error(f"Failed to load scheduler config: {e}")
|
||||
return _build_response(app_config.scheduler)
|
||||
except ConfigServiceError as exc:
|
||||
logger.error("Failed to load scheduler config: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to load scheduler configuration: {e}",
|
||||
) from e
|
||||
detail=f"Failed to load scheduler configuration: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/config", response_model=SchedulerConfig)
|
||||
@router.post("/config")
|
||||
def update_scheduler_config(
|
||||
scheduler_config: SchedulerConfig,
|
||||
auth: dict = Depends(require_auth),
|
||||
) -> SchedulerConfig:
|
||||
"""Update scheduler configuration.
|
||||
) -> Dict[str, Any]:
|
||||
"""Update scheduler configuration and apply changes immediately.
|
||||
|
||||
Args:
|
||||
scheduler_config: New scheduler configuration
|
||||
auth: Authentication token (required)
|
||||
Accepts the full SchedulerConfig body; any fields not supplied default
|
||||
to their model defaults (backward compatible).
|
||||
|
||||
Returns:
|
||||
SchedulerConfig: Updated scheduler configuration
|
||||
Combined config and status response reflecting the saved config.
|
||||
|
||||
Raises:
|
||||
HTTPException: If configuration update fails
|
||||
HTTPException: 422 on validation errors (handled by FastAPI/Pydantic),
|
||||
500 on save or scheduler failure.
|
||||
"""
|
||||
try:
|
||||
config_service = get_config_service()
|
||||
app_config = config_service.load_config()
|
||||
|
||||
# Update scheduler section
|
||||
app_config.scheduler = scheduler_config
|
||||
|
||||
# Save and return
|
||||
config_service.save_config(app_config)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler config updated by {auth.get('username', 'unknown')}"
|
||||
"Scheduler config updated by %s: time=%s days=%s auto_dl=%s",
|
||||
auth.get("username", "unknown"),
|
||||
scheduler_config.schedule_time,
|
||||
scheduler_config.schedule_days,
|
||||
scheduler_config.auto_download_after_rescan,
|
||||
)
|
||||
|
||||
return scheduler_config
|
||||
except ConfigServiceError as e:
|
||||
logger.error(f"Failed to update scheduler config: {e}")
|
||||
# Apply changes to the running scheduler without restart
|
||||
try:
|
||||
sched_svc = get_scheduler_service()
|
||||
sched_svc.reload_config(scheduler_config)
|
||||
except Exception as sched_exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Scheduler reload after config update failed: %s", sched_exc)
|
||||
# Config was saved — don't fail the request, just warn
|
||||
|
||||
return _build_response(scheduler_config)
|
||||
|
||||
except ConfigServiceError as exc:
|
||||
logger.error("Failed to update scheduler config: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update scheduler configuration: {e}",
|
||||
) from e
|
||||
detail=f"Failed to update scheduler configuration: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/trigger-rescan", response_model=Dict[str, str])
|
||||
async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
"""Manually trigger a library rescan.
|
||||
|
||||
This endpoint triggers an immediate anime library rescan, bypassing
|
||||
the scheduler interval.
|
||||
"""Manually trigger a library rescan (and auto-download if configured).
|
||||
|
||||
Args:
|
||||
auth: Authentication token (required)
|
||||
@@ -100,8 +128,7 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
HTTPException: If rescan cannot be triggered
|
||||
"""
|
||||
try:
|
||||
# Import here to avoid circular dependency
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
from src.server.utils.dependencies import get_series_app # noqa: PLC0415
|
||||
|
||||
series_app = get_series_app()
|
||||
if not series_app:
|
||||
@@ -110,21 +137,19 @@ async def trigger_rescan(auth: dict = Depends(require_auth)) -> Dict[str, str]:
|
||||
detail="SeriesApp not initialized",
|
||||
)
|
||||
|
||||
# Trigger the rescan
|
||||
logger.info(
|
||||
f"Manual rescan triggered by {auth.get('username', 'unknown')}"
|
||||
"Manual rescan triggered by %s", auth.get("username", "unknown")
|
||||
)
|
||||
|
||||
# Use existing rescan logic from anime API
|
||||
from src.server.api.anime import trigger_rescan as do_rescan
|
||||
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
|
||||
|
||||
return await do_rescan()
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to trigger manual rescan")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to trigger rescan: {str(e)}",
|
||||
) from e
|
||||
detail=f"Failed to trigger rescan: {exc}",
|
||||
) from exc
|
||||
|
||||
Reference in New Issue
Block a user