Files
Aniworld/src/server/api/scheduler.py
Lukas 0265ae2a70 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
2026-02-21 08:56:17 +01:00

156 lines
5.2 KiB
Python

"""Scheduler API endpoints for Aniworld.
This module provides endpoints for managing scheduled tasks such as
automatic anime library rescans.
"""
import logging
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__)
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
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()
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:
Combined config and status response.
Raises:
HTTPException: 500 if configuration cannot be loaded.
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
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: {exc}",
) from exc
@router.post("/config")
def update_scheduler_config(
scheduler_config: SchedulerConfig,
auth: dict = Depends(require_auth),
) -> Dict[str, Any]:
"""Update scheduler configuration and apply changes immediately.
Accepts the full SchedulerConfig body; any fields not supplied default
to their model defaults (backward compatible).
Returns:
Combined config and status response reflecting the saved config.
Raises:
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()
app_config.scheduler = scheduler_config
config_service.save_config(app_config)
logger.info(
"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,
)
# 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: {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 (and auto-download if configured).
Args:
auth: Authentication token (required)
Returns:
Dict with success message
Raises:
HTTPException: If rescan cannot be triggered
"""
try:
from src.server.utils.dependencies import get_series_app # noqa: PLC0415
series_app = get_series_app()
if not series_app:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="SeriesApp not initialized",
)
logger.info(
"Manual rescan triggered by %s", auth.get("username", "unknown")
)
from src.server.api.anime import trigger_rescan as do_rescan # noqa: PLC0415
return await do_rescan()
except HTTPException:
raise
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: {exc}",
) from exc