- 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
156 lines
5.2 KiB
Python
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
|