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:
2026-02-21 08:56:17 +01:00
parent ac7e15e1eb
commit 0265ae2a70
15 changed files with 1923 additions and 1628 deletions

View File

@@ -660,7 +660,10 @@ Return current application configuration.
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
"auto_download_after_rescan": false
},
"logging": {
"level": "INFO",
@@ -691,7 +694,9 @@ Apply an update to the configuration.
{
"scheduler": {
"enabled": true,
"interval_minutes": 30
"interval_minutes": 60,
"schedule_time": "06:30",
"schedule_days": ["mon", "wed", "fri"]
},
"logging": {
"level": "DEBUG"
@@ -1177,47 +1182,21 @@ Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L637-L684)
Prefix: `/api/scheduler`
Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L1-L122)
All GET/POST config responses share the same envelope:
```json
{
"success": true,
"config": { ... },
"status": { ... }
}
```
Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py)
### GET /api/scheduler/config
Get current scheduler configuration.
**Authentication:** Required
**Response (200 OK):**
```json
{
"enabled": true,
"interval_minutes": 60
}
```
Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L22-L42)
### POST /api/scheduler/config
Update scheduler configuration.
**Authentication:** Required
**Request Body:**
```json
{
"enabled": true,
"interval_minutes": 30
}
```
**Response (200 OK):** Updated scheduler configuration
Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L45-L75)
### POST /api/scheduler/trigger-rescan
Manually trigger a library rescan.
Get current scheduler configuration and runtime status.
**Authentication:** Required
@@ -1226,11 +1205,65 @@ Manually trigger a library rescan.
```json
{
"success": true,
"config": {
"enabled": true,
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
"auto_download_after_rescan": false
},
"status": {
"is_running": true,
"next_run": "2025-07-15T03:00:00+00:00",
"last_run": null,
"scan_in_progress": false
}
}
```
### POST /api/scheduler/config
Update scheduler configuration and apply changes immediately.
**Authentication:** Required
**Request Body (all fields optional, uses model defaults):**
```json
{
"enabled": true,
"schedule_time": "06:30",
"schedule_days": ["mon", "wed", "fri"],
"auto_download_after_rescan": true
}
```
**Response (200 OK):** Same envelope as GET, reflecting saved values.
**Validation errors (422):**
- `schedule_time` must match `HH:MM` (00:0023:59)
- `schedule_days` entries must be one of `mon tue wed thu fri sat sun`
- `interval_minutes` must be ≥ 1
### POST /api/scheduler/trigger-rescan
Manually trigger a library rescan (and auto-download if configured).
**Authentication:** Required
**Response (200 OK):**
```json
{
"message": "Rescan started successfully"
}
```
Source: [src/server/api/scheduler.py](../src/server/api/scheduler.py#L78-L122)
**Error responses:**
- `503` — SeriesApp not yet initialised
- `500` — Rescan failed unexpectedly
---

View File

@@ -39,6 +39,23 @@ This changelog follows [Keep a Changelog](https://keepachangelog.com/) principle
## [Unreleased] - 2026-01-18
### Added
- **Cron-based Scheduler**: Replaced the asyncio sleep-loop with APScheduler's `AsyncIOScheduler + CronTrigger`
- Schedule rescans at a specific **time of day** (`HH:MM`) on selected **days of the week**
- New `SchedulerConfig` fields: `schedule_time` (default `"03:00"`), `schedule_days` (default all 7), `auto_download_after_rescan` (default `false`)
- Old `interval_minutes` field retained for backward compatibility
- **Auto-download after rescan**: When `auto_download_after_rescan` is enabled, missing episodes are automatically queued for download after each scheduled rescan
- **Day-of-week UI**: New day-of-week pill toggles (MonSun) in the Settings → Scheduler section
- **Live config reload**: POST `/api/scheduler/config` reschedules the APScheduler job without restarting the application
- **Enriched API response**: GET/POST `/api/scheduler/config` now returns `{"success", "config", "status"}` envelope including `next_run`, `last_run`, and `scan_in_progress`
### Changed
- Scheduler API response format: previously returned flat config; now returns `{"success": true, "config": {...}, "status": {...}}`
- `reload_config()` is now a synchronous method accepting a `SchedulerConfig` argument (previously async, no arguments)
- Dependencies: added `APScheduler>=3.10.4` to `requirements.txt`
### Fixed
- **Series Visibility**: Fixed issue where series added to the database weren't appearing in the API/UI

View File

@@ -114,7 +114,10 @@ Location: `data/config.json`
"data_dir": "data",
"scheduler": {
"enabled": true,
"interval_minutes": 60
"interval_minutes": 60,
"schedule_time": "03:00",
"schedule_days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
"auto_download_after_rescan": false
},
"logging": {
"level": "INFO",
@@ -161,12 +164,17 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L62-L66)
### 4.2 Scheduler Settings
Controls automatic library rescanning.
Controls automatic cron-based library rescanning (powered by APScheduler).
| Field | Type | Default | Description |
| ---------------------------- | ---- | ------- | -------------------------------------------- |
| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. |
| `scheduler.interval_minutes` | int | `60` | Minutes between automatic scans. Minimum: 1. |
| Field | Type | Default | Description |
| -------------------------------------- | ------------ | --------------------------------------------- | -------------------------------------------------------------------- |
| `scheduler.enabled` | bool | `true` | Enable/disable automatic scans. |
| `scheduler.interval_minutes` | int | `60` | Legacy field kept for backward compatibility. Minimum: 1. |
| `scheduler.schedule_time` | string | `"03:00"` | Daily run time in 24-h `HH:MM` format. |
| `scheduler.schedule_days` | list[string] | `["mon","tue","wed","thu","fri","sat","sun"]` | Days of the week to run the scan. Empty list disables the cron job. |
| `scheduler.auto_download_after_rescan` | bool | `false` | Automatically queue missing episodes for download after each rescan. |
Valid day abbreviations: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`.
Source: [src/server/models/config.py](../src/server/models/config.py#L5-L12)

View File

@@ -19,7 +19,7 @@ The application now features a comprehensive configuration system that allows us
- Organized into logical sections with clear labels and help text
- Real-time saving with immediate feedback
- Configuration validation to prevent invalid settings
- Full control over scheduler interval, logging options, and backup settings
- Full control over cron-based scheduler (time, days of week, auto-download), logging options, and backup settings
---
@@ -35,14 +35,14 @@ The application now features a comprehensive configuration system that allows us
- General Settings: Application name and data directory configuration
- Security Settings: Master password setup with strength indicator
- Anime Directory: Primary directory path for anime storage
- Scheduler Settings: Enable/disable scheduler and configure check interval (in minutes)
- Scheduler Settings: Enable/disable scheduler, configure daily run time, select days of week, and optionally auto-download missing episodes after rescan
- Logging Settings: Configure log level, file path, file size limits, and backup count
- Backup Settings: Enable automatic backups with configurable path and retention period
- NFO Settings: TMDB API key, auto-creation options, and media file download preferences
- **Enhanced Settings/Config Modal**: Comprehensive configuration interface accessible from main page:
- General Settings: Edit application name and data directory
- Anime Directory: Modify anime storage location with browse functionality
- Scheduler Configuration: Enable/disable and configure check interval for automated operations
- Scheduler Configuration: Enable/disable, set cron run time (`HH:MM`), select active days of the week, and toggle auto-download after rescan
- Logging Configuration: Full control over logging level, file rotation, and backup count
- Backup Configuration: Configure automatic backup settings including path and retention
- NFO Settings: Complete control over TMDB integration and media file downloads

View File

@@ -17,4 +17,5 @@ sqlalchemy>=2.0.35
aiosqlite>=0.19.0
aiohttp>=3.9.0
lxml>=5.0.0
pillow>=10.0.0
pillow>=10.0.0
APScheduler>=3.10.4

View File

@@ -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

View File

@@ -2,16 +2,67 @@ from typing import Dict, List, Optional
from pydantic import BaseModel, Field, ValidationError, field_validator
_VALID_DAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
_ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
class SchedulerConfig(BaseModel):
"""Scheduler related configuration."""
"""Scheduler related configuration.
Cron-based scheduling is configured via ``schedule_time`` and
``schedule_days``. The legacy ``interval_minutes`` field is kept for
backward compatibility but is **deprecated** and ignored when
``schedule_time`` is set.
"""
enabled: bool = Field(
default=True, description="Whether the scheduler is enabled"
)
interval_minutes: int = Field(
default=60, ge=1, description="Scheduler interval in minutes"
default=60,
ge=1,
description="[Deprecated] Scheduler interval in minutes. "
"Use schedule_time + schedule_days instead.",
)
schedule_time: str = Field(
default="03:00",
description="Daily run time in 24-hour HH:MM format (e.g. '03:00')",
)
schedule_days: List[str] = Field(
default_factory=lambda: list(_ALL_DAYS),
description="Days of week to run the scheduler (3-letter lowercase "
"abbreviations: mon, tue, wed, thu, fri, sat, sun). "
"Empty list means disabled.",
)
auto_download_after_rescan: bool = Field(
default=False,
description="Automatically queue and start downloads for all missing "
"episodes after a scheduled rescan completes.",
)
@field_validator("schedule_time")
@classmethod
def validate_schedule_time(cls, v: str) -> str:
"""Validate HH:MM format within 00:0023:59."""
import re
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", v or ""):
raise ValueError(
f"Invalid schedule_time '{v}'. "
"Expected HH:MM in 24-hour format (00:0023:59)."
)
return v
@field_validator("schedule_days")
@classmethod
def validate_schedule_days(cls, v: List[str]) -> List[str]:
"""Validate each entry is a valid 3-letter lowercase day abbreviation."""
invalid = [d for d in v if d not in _VALID_DAYS]
if invalid:
raise ValueError(
f"Invalid day(s) in schedule_days: {invalid}. "
f"Allowed values: {sorted(_VALID_DAYS)}"
)
return v
class BackupConfig(BaseModel):

View File

@@ -1,305 +1,377 @@
"""Scheduler service for automatic library rescans.
This module provides a background scheduler that performs periodic library rescans
according to the configured interval. It handles conflict resolution with manual
scans and persists scheduler state.
Uses APScheduler's AsyncIOScheduler with CronTrigger for precise
cron-based scheduling. The legacy interval-based loop has been removed
in favour of the cron approach.
"""
import asyncio
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from typing import List, Optional
import structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service
logger = structlog.get_logger(__name__)
_JOB_ID = "scheduled_rescan"
class SchedulerServiceError(Exception):
"""Service-level exception for scheduler operations."""
class SchedulerService:
"""Manages automatic library rescans on a configurable schedule.
Features:
- Periodic library rescans based on configured interval
- Conflict resolution (prevents concurrent scans)
- State persistence across restarts
- Manual trigger capability
- Enable/disable functionality
The scheduler uses a simple interval-based approach where rescans
are triggered every N minutes as configured.
"""Manages automatic library rescans on a cron-based schedule.
Uses APScheduler's AsyncIOScheduler so scheduling integrates cleanly
with the running asyncio event loop. Supports:
- Cron-based scheduling (time of day + days of week)
- Immediate manual trigger
- Live config reloading without app restart
- Auto-queueing downloads of missing episodes after rescan
"""
def __init__(self):
"""Initialize the scheduler service."""
def __init__(self) -> None:
"""Initialise the scheduler service."""
self._is_running: bool = False
self._task: Optional[asyncio.Task] = None
self._scheduler: Optional[AsyncIOScheduler] = None
self._config: Optional[SchedulerConfig] = None
self._last_scan_time: Optional[datetime] = None
self._next_scan_time: Optional[datetime] = None
self._scan_in_progress: bool = False
logger.info("SchedulerService initialized")
logger.info("SchedulerService initialised")
# ------------------------------------------------------------------
# Public lifecycle methods
# ------------------------------------------------------------------
async def start(self) -> None:
"""Start the scheduler background task.
"""Start the APScheduler with the configured cron trigger.
Raises:
SchedulerServiceError: If scheduler is already running
SchedulerServiceError: If the scheduler is already running or
config cannot be loaded.
"""
if self._is_running:
raise SchedulerServiceError("Scheduler is already running")
# Load configuration
try:
config_service = get_config_service()
config = config_service.load_config()
self._config = config.scheduler
except ConfigServiceError as e:
logger.error("Failed to load scheduler configuration", error=str(e))
raise SchedulerServiceError(f"Failed to load config: {e}") from e
except ConfigServiceError as exc:
logger.error("Failed to load scheduler configuration", error=str(exc))
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
self._scheduler = AsyncIOScheduler()
if not self._config.enabled:
logger.info("Scheduler is disabled in configuration")
logger.info("Scheduler is disabled in configuration — not adding jobs")
self._is_running = True
return
trigger = self._build_cron_trigger()
if trigger is None:
logger.warning(
"schedule_days is empty — scheduler started but no job scheduled"
)
else:
self._scheduler.add_job(
self._perform_rescan,
trigger=trigger,
id=_JOB_ID,
replace_existing=True,
misfire_grace_time=300,
)
logger.info(
"Scheduler started with cron trigger",
schedule_time=self._config.schedule_time,
schedule_days=self._config.schedule_days,
)
self._scheduler.start()
self._is_running = True
self._task = asyncio.create_task(self._scheduler_loop())
logger.info(
"Scheduler started",
interval_minutes=self._config.interval_minutes
)
async def stop(self) -> None:
"""Stop the scheduler background task gracefully.
Cancels the running scheduler task and waits for it to complete.
"""
"""Stop the APScheduler gracefully."""
if not self._is_running:
logger.debug("Scheduler stop called but not running")
return
if self._scheduler and self._scheduler.running:
self._scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")
self._is_running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
logger.info("Scheduler task cancelled successfully")
logger.info("Scheduler stopped")
async def trigger_rescan(self) -> bool:
"""Manually trigger a library rescan.
Returns:
True if rescan was triggered, False if scan already in progress
True if rescan was started; False if a scan is already running.
Raises:
SchedulerServiceError: If scheduler is not running
SchedulerServiceError: If the scheduler service is not started.
"""
if not self._is_running:
raise SchedulerServiceError("Scheduler is not running")
if self._scan_in_progress:
logger.warning("Cannot trigger rescan: scan already in progress")
return False
logger.info("Manual rescan triggered")
await self._perform_rescan()
return True
async def reload_config(self) -> None:
"""Reload scheduler configuration from config service.
The scheduler will restart with the new configuration if it's running.
Raises:
SchedulerServiceError: If config reload fails
def reload_config(self, config: SchedulerConfig) -> None:
"""Apply a new SchedulerConfig immediately.
If the scheduler is already running the job is rescheduled (or
removed) without stopping the scheduler.
Args:
config: New scheduler configuration to apply.
"""
try:
config_service = get_config_service()
config = config_service.load_config()
old_config = self._config
self._config = config.scheduler
logger.info(
"Scheduler configuration reloaded",
old_enabled=old_config.enabled if old_config else None,
new_enabled=self._config.enabled,
old_interval=old_config.interval_minutes if old_config else None,
new_interval=self._config.interval_minutes
)
# Restart scheduler if it's running and config changed
if self._is_running:
if not self._config.enabled:
logger.info("Scheduler disabled, stopping...")
await self.stop()
elif old_config and old_config.interval_minutes != self._config.interval_minutes:
logger.info("Interval changed, restarting scheduler...")
await self.stop()
await self.start()
elif self._config.enabled and not self._is_running:
logger.info("Scheduler enabled, starting...")
await self.start()
except ConfigServiceError as e:
logger.error("Failed to reload scheduler configuration", error=str(e))
raise SchedulerServiceError(f"Failed to reload config: {e}") from e
self._config = config
logger.info(
"Scheduler config reloaded",
enabled=config.enabled,
schedule_time=config.schedule_time,
schedule_days=config.schedule_days,
auto_download=config.auto_download_after_rescan,
)
if not self._scheduler or not self._scheduler.running:
return
if not config.enabled:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.remove_job(_JOB_ID)
logger.info("Scheduler job removed (disabled)")
return
trigger = self._build_cron_trigger()
if trigger is None:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.remove_job(_JOB_ID)
logger.warning("Scheduler job removed — schedule_days is empty")
else:
if self._scheduler.get_job(_JOB_ID):
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
logger.info(
"Scheduler rescheduled with cron trigger",
schedule_time=config.schedule_time,
schedule_days=config.schedule_days,
)
else:
self._scheduler.add_job(
self._perform_rescan,
trigger=trigger,
id=_JOB_ID,
replace_existing=True,
misfire_grace_time=300,
)
logger.info(
"Scheduler job added with cron trigger",
schedule_time=config.schedule_time,
schedule_days=config.schedule_days,
)
def get_status(self) -> dict:
"""Get current scheduler status.
"""Return current scheduler status including cron configuration.
Returns:
Dict containing scheduler state information
Dict containing scheduler state and config fields.
"""
next_run: Optional[str] = None
if self._scheduler and self._scheduler.running:
job = self._scheduler.get_job(_JOB_ID)
if job and job.next_run_time:
next_run = job.next_run_time.isoformat()
return {
"is_running": self._is_running,
"enabled": self._config.enabled if self._config else False,
"interval_minutes": self._config.interval_minutes if self._config else None,
"last_scan_time": self._last_scan_time.isoformat() if self._last_scan_time else None,
"next_scan_time": self._next_scan_time.isoformat() if self._next_scan_time else None,
"schedule_time": self._config.schedule_time if self._config else None,
"schedule_days": self._config.schedule_days if self._config else [],
"auto_download_after_rescan": (
self._config.auto_download_after_rescan if self._config else False
),
"last_run": self._last_scan_time.isoformat() if self._last_scan_time else None,
"next_run": next_run,
"scan_in_progress": self._scan_in_progress,
}
async def _scheduler_loop(self) -> None:
"""Main scheduler loop that runs periodic rescans.
This coroutine runs indefinitely until cancelled, sleeping for the
configured interval between rescans.
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _build_cron_trigger(self) -> Optional[CronTrigger]:
"""Convert config fields into an APScheduler CronTrigger.
Returns:
CronTrigger instance or None if schedule_days is empty.
"""
logger.info("Scheduler loop started")
while self._is_running:
try:
if not self._config or not self._config.enabled:
logger.debug("Scheduler disabled, exiting loop")
break
# Calculate next scan time
interval_seconds = self._config.interval_minutes * 60
self._next_scan_time = datetime.now(timezone.utc)
self._next_scan_time = self._next_scan_time.replace(
second=0, microsecond=0
)
# Wait for the interval
logger.debug(
"Waiting for next scan",
interval_minutes=self._config.interval_minutes,
next_scan=self._next_scan_time.isoformat()
)
await asyncio.sleep(interval_seconds)
# Perform the rescan
if self._is_running: # Check again after sleep
await self._perform_rescan()
except asyncio.CancelledError:
logger.info("Scheduler loop cancelled")
break
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(
"Error in scheduler loop",
error=str(e),
exc_info=True
)
# Continue loop despite errors
await asyncio.sleep(60) # Wait 1 minute before retrying
logger.info("Scheduler loop exited")
if not self._config or not self._config.schedule_days:
return None
hour_str, minute_str = self._config.schedule_time.split(":")
day_of_week = ",".join(self._config.schedule_days)
trigger = CronTrigger(
hour=int(hour_str),
minute=int(minute_str),
day_of_week=day_of_week,
)
logger.debug(
"CronTrigger built",
hour=hour_str,
minute=minute_str,
day_of_week=day_of_week,
)
return trigger
async def _broadcast(self, event_type: str, data: dict) -> None:
"""Broadcast a WebSocket event to all connected clients."""
try:
from src.server.services.websocket_service import ( # noqa: PLC0415
get_websocket_service,
)
ws_service = get_websocket_service()
await ws_service.manager.broadcast({"type": event_type, "data": data})
except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("WebSocket broadcast failed", event=event_type, error=str(exc))
async def _auto_download_missing(self) -> None:
"""Queue and start downloads for all series with missing episodes."""
from src.server.models.download import EpisodeIdentifier # noqa: PLC0415
from src.server.utils.dependencies import ( # noqa: PLC0415
get_anime_service,
get_download_service,
)
anime_service = get_anime_service()
download_service = get_download_service()
series_list = anime_service._cached_list_missing()
queued_count = 0
for series in series_list:
episode_dict: dict = series.get("episodeDict") or {}
if not episode_dict:
continue
episodes: List[EpisodeIdentifier] = []
for season_str, ep_numbers in episode_dict.items():
for ep_num in ep_numbers:
episodes.append(
EpisodeIdentifier(season=int(season_str), episode=int(ep_num))
)
if not episodes:
continue
await download_service.add_to_queue(
serie_id=series.get("key", ""),
serie_folder=series.get("folder", series.get("name", "")),
serie_name=series.get("name", ""),
episodes=episodes,
)
queued_count += len(episodes)
logger.info(
"Auto-download queued episodes",
series=series.get("key"),
count=len(episodes),
)
if queued_count:
await download_service.start_queue_processing()
logger.info("Auto-download queue processing started", queued=queued_count)
await self._broadcast("auto_download_started", {"queued_count": queued_count})
logger.info("Auto-download completed", queued_count=queued_count)
async def _perform_rescan(self) -> None:
"""Execute a library rescan.
This method calls the anime service to perform the actual rescan.
It includes conflict detection to prevent concurrent scans.
"""
"""Execute a library rescan and optionally trigger auto-download."""
if self._scan_in_progress:
logger.warning("Skipping rescan: previous scan still in progress")
return
self._scan_in_progress = True
scan_start = datetime.now(timezone.utc)
try:
logger.info("Starting scheduled library rescan")
# Import here to avoid circular dependency
from src.server.services.websocket_service import get_websocket_service
from src.server.utils.dependencies import get_anime_service
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
anime_service = get_anime_service()
ws_service = get_websocket_service()
# Notify clients that scheduled rescan started
await ws_service.manager.broadcast({
"type": "scheduled_rescan_started",
"data": {
"timestamp": scan_start.isoformat()
}
})
# Perform the rescan
await self._broadcast(
"scheduled_rescan_started",
{"timestamp": scan_start.isoformat()},
)
await anime_service.rescan()
self._last_scan_time = datetime.now(timezone.utc)
logger.info(
"Scheduled library rescan completed",
duration_seconds=(self._last_scan_time - scan_start).total_seconds()
)
# Notify clients that rescan completed
await ws_service.manager.broadcast({
"type": "scheduled_rescan_completed",
"data": {
duration = (self._last_scan_time - scan_start).total_seconds()
logger.info("Scheduled library rescan completed", duration_seconds=duration)
await self._broadcast(
"scheduled_rescan_completed",
{
"timestamp": self._last_scan_time.isoformat(),
"duration_seconds": (self._last_scan_time - scan_start).total_seconds()
}
})
except Exception as e: # pylint: disable=broad-exception-caught
logger.error(
"Scheduled rescan failed",
error=str(e),
exc_info=True
"duration_seconds": duration,
},
)
# Notify clients of error
try:
from src.server.services.websocket_service import get_websocket_service
ws_service = get_websocket_service()
await ws_service.manager.broadcast({
"type": "scheduled_rescan_error",
"data": {
"error": str(e),
"timestamp": datetime.now(timezone.utc).isoformat()
}
})
except Exception: # pylint: disable=broad-exception-caught
pass # Don't fail if WebSocket notification fails
# Auto-download after rescan
if self._config and self._config.auto_download_after_rescan:
logger.info("Auto-download after rescan is enabled — starting")
try:
await self._auto_download_missing()
except Exception as dl_exc: # pylint: disable=broad-exception-caught
logger.error(
"Auto-download after rescan failed",
error=str(dl_exc),
exc_info=True,
)
await self._broadcast(
"auto_download_error", {"error": str(dl_exc)}
)
else:
logger.debug("Auto-download after rescan is disabled — skipping")
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Scheduled rescan failed", error=str(exc), exc_info=True)
await self._broadcast(
"scheduled_rescan_error",
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
)
finally:
self._scan_in_progress = False
# Module-level singleton instance
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_scheduler_service: Optional[SchedulerService] = None
def get_scheduler_service() -> SchedulerService:
"""Get the singleton scheduler service instance.
Returns:
SchedulerService singleton
"""
"""Return the singleton SchedulerService instance."""
global _scheduler_service
if _scheduler_service is None:
_scheduler_service = SchedulerService()
@@ -307,6 +379,6 @@ def get_scheduler_service() -> SchedulerService:
def reset_scheduler_service() -> None:
"""Reset the scheduler service singleton (for testing)."""
"""Reset the singleton (used in tests)."""
global _scheduler_service
_scheduler_service = None

View File

@@ -228,3 +228,122 @@
font-size: var(--font-size-title);
}
}
/* ============================================================
Scheduler day-of-week toggle pills
============================================================ */
.scheduler-days-container {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.scheduler-day-toggle-label {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
}
/* Hide the raw checkbox visually */
.scheduler-day-toggle-label .scheduler-day-checkbox {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
/* Pill styling */
.scheduler-day-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.6rem;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-xl);
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-secondary);
background-color: var(--color-bg-secondary);
transition: background-color var(--transition-duration) var(--transition-easing),
color var(--transition-duration) var(--transition-easing),
border-color var(--transition-duration) var(--transition-easing);
cursor: pointer;
}
/* Checked state filled accent */
.scheduler-day-checkbox:checked + .scheduler-day-label {
background-color: var(--color-accent);
border-color: var(--color-accent);
color: #ffffff;
}
/* Hover for unchecked */
.scheduler-day-toggle-label:hover .scheduler-day-label {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Hover for checked */
.scheduler-day-toggle-label:hover .scheduler-day-checkbox:checked + .scheduler-day-label {
background-color: var(--color-accent-hover);
border-color: var(--color-accent-hover);
color: #ffffff;
}
/* Dark theme overrides */
[data-theme="dark"] .scheduler-day-label {
border-color: var(--color-border-dark);
color: var(--color-text-secondary-dark);
background-color: var(--color-bg-secondary-dark);
}
[data-theme="dark"] .scheduler-day-checkbox:checked + .scheduler-day-label {
background-color: var(--color-accent-dark);
border-color: var(--color-accent-dark);
color: var(--color-bg-primary-dark);
}
[data-theme="dark"] .scheduler-day-toggle-label:hover .scheduler-day-label {
border-color: var(--color-accent-dark);
color: var(--color-accent-dark);
}
/* Next run display */
#scheduler-next-run {
font-style: italic;
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
}
[data-theme="dark"] #scheduler-next-run {
color: var(--color-text-tertiary-dark);
}
/* Advanced/collapsible section */
.config-advanced {
font-size: var(--font-size-caption);
color: var(--color-text-secondary);
margin-top: var(--spacing-sm);
}
.config-advanced summary {
cursor: pointer;
padding: var(--spacing-xs) 0;
font-weight: 500;
}
/* Responsive: wrap day pills to 2 rows on mobile */
@media (max-width: 480px) {
.scheduler-days-container {
gap: var(--spacing-xs);
}
.scheduler-day-label {
min-width: 2.2rem;
padding: var(--spacing-xs);
}
}

View File

@@ -1554,24 +1554,42 @@ class AniWorldApp {
const data = await response.json();
if (data.success) {
const config = data.config;
const config = data.config || {};
const schedulerStatus = data.status || {};
// Update UI elements
document.getElementById('scheduled-rescan-enabled').checked = config.enabled;
document.getElementById('scheduled-rescan-time').value = config.time || '03:00';
document.getElementById('auto-download-after-rescan').checked = config.auto_download_after_rescan;
document.getElementById('scheduled-rescan-enabled').checked = !!config.enabled;
document.getElementById('scheduled-rescan-time').value = config.schedule_time || '03:00';
document.getElementById('auto-download-after-rescan').checked = !!config.auto_download_after_rescan;
// Update day-of-week checkboxes
const days = Array.isArray(config.schedule_days) ? config.schedule_days : ['mon','tue','wed','thu','fri','sat','sun'];
['mon','tue','wed','thu','fri','sat','sun'].forEach(day => {
const cb = document.getElementById(`scheduler-day-${day}`);
if (cb) cb.checked = days.includes(day);
});
// Update status display
document.getElementById('next-rescan-time').textContent =
config.next_run ? new Date(config.next_run).toLocaleString() : 'Not scheduled';
document.getElementById('last-rescan-time').textContent =
config.last_run ? new Date(config.last_run).toLocaleString() : 'Never';
const nextRunEl = document.getElementById('scheduler-next-run');
if (nextRunEl) {
nextRunEl.textContent = schedulerStatus.next_run
? new Date(schedulerStatus.next_run).toLocaleString()
: 'Not scheduled';
}
const lastRunEl = document.getElementById('last-rescan-time');
if (lastRunEl) {
lastRunEl.textContent = schedulerStatus.last_run
? new Date(schedulerStatus.last_run).toLocaleString()
: 'Never';
}
const statusBadge = document.getElementById('scheduler-running-status');
statusBadge.textContent = config.is_running ? 'Running' : 'Stopped';
statusBadge.className = `info-value status-badge ${config.is_running ? 'running' : 'stopped'}`;
if (statusBadge) {
statusBadge.textContent = schedulerStatus.is_running ? 'Running' : 'Stopped';
statusBadge.className = `info-value status-badge ${schedulerStatus.is_running ? 'running' : 'stopped'}`;
}
// Enable/disable time input based on checkbox
// Enable/disable time/day inputs based on checkbox
this.toggleSchedulerTimeInput();
}
} catch (error) {
@@ -1583,17 +1601,23 @@ class AniWorldApp {
async saveSchedulerConfig() {
try {
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
const time = document.getElementById('scheduled-rescan-time').value;
const scheduleTime = document.getElementById('scheduled-rescan-time').value || '03:00';
const autoDownload = document.getElementById('auto-download-after-rescan').checked;
// Collect checked day-of-week values
const scheduleDays = ['mon','tue','wed','thu','fri','sat','sun']
.filter(day => {
const cb = document.getElementById(`scheduler-day-${day}`);
return cb ? cb.checked : true;
});
const response = await this.makeAuthenticatedRequest('/api/scheduler/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: enabled,
time: time,
schedule_time: scheduleTime,
schedule_days: scheduleDays,
auto_download_after_rescan: autoDownload
})
});
@@ -1603,7 +1627,12 @@ class AniWorldApp {
if (data.success) {
this.showToast('Scheduler configuration saved successfully', 'success');
// Reload config to update display
// Update next-run display from response
const nextRunEl = document.getElementById('scheduler-next-run');
if (nextRunEl && data.status && data.status.next_run) {
nextRunEl.textContent = new Date(data.status.next_run).toLocaleString();
}
// Reload config to sync the full UI
await this.loadSchedulerConfig();
} else {
this.showToast(`Failed to save config: ${data.error}`, 'error');
@@ -1637,11 +1666,19 @@ class AniWorldApp {
toggleSchedulerTimeInput() {
const enabled = document.getElementById('scheduled-rescan-enabled').checked;
const timeConfig = document.getElementById('rescan-time-config');
const daysConfig = document.getElementById('rescan-days-config');
const nextRunEl = document.getElementById('scheduler-next-run');
if (enabled) {
timeConfig.classList.add('enabled');
} else {
timeConfig.classList.remove('enabled');
if (timeConfig) {
timeConfig.classList.toggle('enabled', enabled);
}
if (daysConfig) {
daysConfig.classList.toggle('enabled', enabled);
}
if (nextRunEl) {
nextRunEl.parentElement && nextRunEl.parentElement.parentElement
? nextRunEl.parentElement.parentElement.classList.toggle('hidden', !enabled)
: null;
}
}

View File

@@ -254,17 +254,46 @@
</label>
</div>
<div class="config-item" id="rescan-interval-config">
<label for="scheduled-rescan-interval" data-text="rescan-interval">Check Interval (minutes):</label>
<input type="number" id="scheduled-rescan-interval" value="60" min="1" class="input-field">
<small class="config-hint" data-text="rescan-interval-hint">
How often to check for new episodes (minimum 1 minute)
</small>
<div class="config-item" id="rescan-time-config">
<label for="scheduled-rescan-time" data-text="rescan-time">Run at:</label>
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
</div>
<div class="config-item" id="rescan-time-config">
<label for="scheduled-rescan-time" data-text="rescan-time">Rescan Time (24h format):</label>
<input type="time" id="scheduled-rescan-time" value="03:00" class="input-field">
<div class="config-item" id="rescan-days-config">
<label data-text="rescan-days">Days of week:</label>
<div class="scheduler-days-container">
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-mon" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-mon">Mon</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-tue" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-tue">Tue</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-wed" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-wed">Wed</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-thu" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-thu">Thu</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-fri" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-fri">Fri</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-sat" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-sat">Sat</span>
</label>
<label class="scheduler-day-toggle-label">
<input type="checkbox" id="scheduler-day-sun" checked class="scheduler-day-checkbox">
<span class="scheduler-day-label" data-text="day-sun">Sun</span>
</label>
</div>
<small class="config-hint" data-text="rescan-days-hint">
Scheduler runs at the selected time on checked days. Uncheck all to disable scheduling.
</small>
</div>
<div class="config-item">
@@ -276,11 +305,23 @@
</label>
</div>
<!-- Advanced: legacy interval (hidden by default) -->
<details class="config-advanced">
<summary data-text="advanced-settings">Advanced</summary>
<div class="config-item" id="rescan-interval-config">
<label for="scheduled-rescan-interval" data-text="rescan-interval">Legacy Check Interval (minutes):</label>
<input type="number" id="scheduled-rescan-interval" value="60" min="1" class="input-field">
<small class="config-hint" data-text="rescan-interval-hint">
Deprecated: only used if cron scheduling is not configured
</small>
</div>
</details>
<div class="config-item scheduler-status" id="scheduler-status">
<div class="scheduler-info">
<div class="info-row">
<span data-text="next-rescan">Next Scheduled Rescan:</span>
<span id="next-rescan-time" class="info-value">-</span>
<span id="scheduler-next-run" class="info-value">-</span>
</div>
<div class="info-row">
<span data-text="last-rescan">Last Scheduled Rescan:</span>

View File

@@ -36,393 +36,409 @@ async def client():
@pytest.fixture
async def authenticated_client(client):
"""Create an authenticated test client with token."""
# Login to get token
response = await client.post(
"/api/auth/login",
json={"password": "TestPass123!"}
)
assert response.status_code == 200
token = response.json()["access_token"]
# Add token to default headers
client.headers.update({"Authorization": f"Bearer {token}"})
yield client
@pytest.fixture
def mock_config_service():
"""Create mock configuration service."""
"""Create mock configuration service with default SchedulerConfig."""
service = Mock()
# Mock config object with scheduler section
config = Mock()
config.scheduler = SchedulerConfig(
enabled=True,
interval_minutes=60
interval_minutes=60,
schedule_time="03:00",
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
auto_download_after_rescan=False,
)
def save_config_side_effect(new_config):
"""Update the scheduler config when save is called."""
config.scheduler = new_config.scheduler
service.load_config = Mock(return_value=config)
service.save_config = Mock(side_effect=save_config_side_effect)
return service
@pytest.fixture
def mock_scheduler_service():
"""Create a mock scheduler service returning a basic status."""
svc = Mock()
svc.get_status = Mock(return_value={
"is_running": True,
"next_run": None,
"last_run": None,
"scan_in_progress": False,
})
svc.reload_config = Mock()
return svc
# ---------------------------------------------------------------------------
# GET /api/scheduler/config
# ---------------------------------------------------------------------------
class TestGetSchedulerConfig:
"""Tests for GET /api/scheduler/config endpoint."""
@pytest.mark.asyncio
async def test_get_scheduler_config_success(
self,
authenticated_client,
mock_config_service
async def test_returns_success_envelope(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test successful scheduler configuration retrieval."""
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
"""Response carries the top-level success/config/status envelope."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
assert response.status_code == 200
data = response.json()
assert data["enabled"] is True
assert data["interval_minutes"] == 60
mock_config_service.load_config.assert_called_once()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "config" in data
assert "status" in data
@pytest.mark.asyncio
async def test_get_scheduler_config_unauthorized(self, client):
"""Test scheduler config retrieval without authentication."""
async def test_config_contains_all_fields(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Config block includes all SchedulerConfig fields."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
cfg = response.json()["config"]
assert cfg["enabled"] is True
assert cfg["interval_minutes"] == 60
assert cfg["schedule_time"] == "03:00"
assert cfg["schedule_days"] == ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
assert cfg["auto_download_after_rescan"] is False
@pytest.mark.asyncio
async def test_status_block_present(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Status block includes runtime keys."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
st = response.json()["status"]
for key in ("is_running", "next_run", "last_run", "scan_in_progress"):
assert key in st
@pytest.mark.asyncio
async def test_unauthorized(self, client):
"""GET without auth token returns 401."""
response = await client.get("/api/scheduler/config")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_scheduler_config_load_failure(
self,
authenticated_client,
mock_config_service
async def test_config_load_failure_returns_500(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test scheduler config retrieval when config loading fails."""
"""500 when config_service.load_config raises ConfigServiceError."""
from src.server.services.config_service import ConfigServiceError
mock_config_service.load_config.side_effect = ConfigServiceError(
"Failed to load config"
)
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
mock_config_service.load_config.side_effect = ConfigServiceError("disk error")
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.get("/api/scheduler/config")
assert response.status_code == 500
assert "Failed to load scheduler configuration" in response.text
assert response.status_code == 500
assert "Failed to load scheduler configuration" in response.text
# ---------------------------------------------------------------------------
# POST /api/scheduler/config
# ---------------------------------------------------------------------------
class TestUpdateSchedulerConfig:
"""Tests for POST /api/scheduler/config endpoint."""
@pytest.mark.asyncio
async def test_update_scheduler_config_success(
self,
authenticated_client,
mock_config_service
async def test_update_returns_success_envelope(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test successful scheduler configuration update."""
new_config = {
"""POST returns success envelope with saved values."""
payload = {
"enabled": False,
"interval_minutes": 120
"interval_minutes": 120,
"schedule_time": "06:30",
"schedule_days": ["mon", "wed", "fri"],
"auto_download_after_rescan": True,
}
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
response = await authenticated_client.post(
"/api/scheduler/config",
json=new_config
)
assert response.status_code == 200
data = response.json()
assert data["enabled"] is False
assert data["interval_minutes"] == 120
mock_config_service.load_config.assert_called_once()
mock_config_service.save_config.assert_called_once()
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["config"]["enabled"] is False
assert data["config"]["schedule_time"] == "06:30"
assert data["config"]["schedule_days"] == ["mon", "wed", "fri"]
assert data["config"]["auto_download_after_rescan"] is True
@pytest.mark.asyncio
async def test_update_scheduler_config_unauthorized(self, client):
"""Test scheduler config update without authentication."""
new_config = {
"enabled": False,
"interval_minutes": 120
}
async def test_update_persists_to_config_service(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST calls save_config exactly once."""
payload = {"enabled": True, "interval_minutes": 30}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
mock_config_service.save_config.assert_called_once()
@pytest.mark.asyncio
async def test_reload_config_called_after_save(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""POST calls scheduler_service.reload_config(SchedulerConfig) after save."""
payload = {"enabled": True, "schedule_time": "10:00"}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
await authenticated_client.post("/api/scheduler/config", json=payload)
mock_scheduler_service.reload_config.assert_called_once()
call_arg = mock_scheduler_service.reload_config.call_args[0][0]
assert isinstance(call_arg, SchedulerConfig)
assert call_arg.schedule_time == "10:00"
@pytest.mark.asyncio
async def test_update_unauthorized(self, client):
"""POST without auth token returns 401."""
response = await client.post(
"/api/scheduler/config",
json=new_config
json={"enabled": False, "interval_minutes": 120},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_update_scheduler_config_invalid_data(
self,
authenticated_client
):
"""Test scheduler config update with invalid data."""
invalid_config = {
"enabled": "not_a_boolean", # Should be boolean
"interval_minutes": -1 # Should be positive (>= 1)
}
async def test_invalid_interval_returns_422(self, authenticated_client):
"""interval_minutes < 1 triggers Pydantic validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json=invalid_config
json={"enabled": True, "interval_minutes": 0},
)
# Pydantic validation should fail with 422
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_scheduler_config_save_failure(
self,
authenticated_client,
mock_config_service
):
"""Test scheduler config update when save fails."""
from src.server.services.config_service import ConfigServiceError
mock_config_service.save_config.side_effect = ConfigServiceError(
"Failed to save config"
async def test_invalid_schedule_time_returns_422(self, authenticated_client):
"""Bad schedule_time format triggers validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "schedule_time": "25:00"},
)
new_config = {
"enabled": False,
"rescan_interval_hours": 48,
"rescan_on_startup": True
}
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
response = await authenticated_client.post(
"/api/scheduler/config",
json=new_config
)
assert response.status_code == 500
assert "Failed to update scheduler configuration" in response.text
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_scheduler_enable_disable_toggle(
self,
authenticated_client,
mock_config_service
):
"""Test toggling scheduler enabled state."""
# First enable
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
response = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": True,
"interval_minutes": 60
}
)
assert response.status_code == 200
assert response.json()["enabled"] is True
# Then disable
response = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": False,
"interval_minutes": 60
}
)
assert response.status_code == 200
assert response.json()["enabled"] is False
async def test_invalid_schedule_days_returns_422(self, authenticated_client):
"""Unknown day abbreviation triggers validation error (422)."""
response = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "schedule_days": ["monday"]},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_update_scheduler_interval_validation(
self,
authenticated_client,
mock_config_service
async def test_empty_schedule_days_accepted(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test scheduler interval value validation."""
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
# Test minimum interval (1 minute)
"""Empty schedule_days list is valid (disables the cron job)."""
payload = {"enabled": True, "schedule_days": []}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
assert response.json()["config"]["schedule_days"] == []
@pytest.mark.asyncio
async def test_update_enable_disable_toggle(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Toggling enabled is reflected in the returned config."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
r1 = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": 60},
)
assert r1.json()["config"]["enabled"] is True
r2 = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": False, "interval_minutes": 60},
)
assert r2.json()["config"]["enabled"] is False
@pytest.mark.asyncio
async def test_save_failure_returns_500(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""500 when config_service.save_config raises ConfigServiceError."""
from src.server.services.config_service import ConfigServiceError
mock_config_service.save_config.side_effect = ConfigServiceError("disk full")
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": True,
"interval_minutes": 1
}
json={"enabled": False},
)
assert response.status_code == 200
# Test large interval (7 days = 10080 minutes)
response = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": True,
"interval_minutes": 10080
}
)
assert response.status_code == 200
assert response.status_code == 500
assert "Failed to update scheduler configuration" in response.text
@pytest.mark.asyncio
async def test_backward_compat_minimal_payload(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Payload with only legacy fields fills new fields with model defaults."""
payload = {"enabled": True, "interval_minutes": 60}
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
response = await authenticated_client.post("/api/scheduler/config", json=payload)
assert response.status_code == 200
cfg = response.json()["config"]
assert cfg["schedule_time"] == "03:00"
assert cfg["auto_download_after_rescan"] is False
assert len(cfg["schedule_days"]) == 7
@pytest.mark.asyncio
async def test_interval_boundary_values(
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""interval_minutes = 1 and 10080 (1 week) are both valid."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
for minutes in (1, 10080):
r = await authenticated_client.post(
"/api/scheduler/config",
json={"enabled": True, "interval_minutes": minutes},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# POST /api/scheduler/trigger-rescan
# ---------------------------------------------------------------------------
class TestTriggerRescan:
"""Tests for POST /api/scheduler/trigger-rescan endpoint."""
@pytest.mark.asyncio
async def test_trigger_rescan_success(self, authenticated_client):
"""Test successful manual rescan trigger."""
"""Successful trigger returns 200 with a message."""
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
mock_series_app = Mock()
with patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), patch(
'src.server.api.anime.trigger_rescan',
mock_trigger
):
response = await authenticated_client.post(
"/api/scheduler/trigger-rescan"
)
assert response.status_code == 200
data = response.json()
assert "message" in data
mock_trigger.assert_called_once()
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 200
assert "message" in response.json()
mock_trigger.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_unauthorized(self, client):
"""Test manual rescan trigger without authentication."""
"""Trigger without auth token returns 401."""
response = await client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_trigger_rescan_series_app_not_initialized(
self,
authenticated_client
self, authenticated_client
):
"""Test manual rescan trigger when SeriesApp not initialized."""
with patch(
'src.server.utils.dependencies.get_series_app',
return_value=None
):
response = await authenticated_client.post(
"/api/scheduler/trigger-rescan"
)
assert response.status_code == 503
assert "SeriesApp not initialized" in response.text
"""503 when SeriesApp is not yet initialised."""
with patch("src.server.utils.dependencies.get_series_app", return_value=None):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 503
assert "SeriesApp not initialized" in response.text
@pytest.mark.asyncio
async def test_trigger_rescan_failure(self, authenticated_client):
"""Test manual rescan trigger when rescan fails."""
mock_trigger = AsyncMock(
side_effect=Exception("Rescan failed")
)
"""500 when underlying rescan call raises an exception."""
mock_trigger = AsyncMock(side_effect=Exception("Rescan failed"))
mock_series_app = Mock()
with patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), patch(
'src.server.api.anime.trigger_rescan',
mock_trigger
):
response = await authenticated_client.post(
"/api/scheduler/trigger-rescan"
)
assert response.status_code == 500
assert "Failed to trigger rescan" in response.text
with patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
response = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert response.status_code == 500
assert "Failed to trigger rescan" in response.text
# ---------------------------------------------------------------------------
# Multi-step integration tests
# ---------------------------------------------------------------------------
class TestSchedulerEndpointsIntegration:
"""Integration tests for scheduler endpoints."""
"""Multi-step integration tests for scheduler endpoints."""
@pytest.mark.asyncio
async def test_full_config_workflow(
self,
authenticated_client,
mock_config_service
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test complete workflow: get config, update, get again."""
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
):
# Get initial config
response = await authenticated_client.get("/api/scheduler/config")
assert response.status_code == 200
initial_config = response.json()
assert initial_config["enabled"] is True
# Update config
new_config = {
"enabled": False,
"interval_minutes": 30
}
response = await authenticated_client.post(
"""GET → POST → verify save called and response consistent."""
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service):
r = await authenticated_client.get("/api/scheduler/config")
assert r.status_code == 200
assert r.json()["config"]["enabled"] is True
r = await authenticated_client.post(
"/api/scheduler/config",
json=new_config
json={
"enabled": False,
"interval_minutes": 30,
"schedule_time": "12:00",
"schedule_days": ["mon", "fri"],
},
)
assert response.status_code == 200
updated_config = response.json()
assert updated_config["enabled"] is False
assert updated_config["interval_minutes"] == 30
# Verify config persisted
mock_config_service.save_config.assert_called_once()
assert r.status_code == 200
cfg = r.json()["config"]
assert cfg["enabled"] is False
assert cfg["interval_minutes"] == 30
assert cfg["schedule_time"] == "12:00"
assert cfg["schedule_days"] == ["mon", "fri"]
mock_config_service.save_config.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_after_config_update(
self,
authenticated_client,
mock_config_service
self, authenticated_client, mock_config_service, mock_scheduler_service
):
"""Test triggering rescan after updating config."""
"""POST config then POST trigger-rescan both succeed."""
mock_trigger = AsyncMock(return_value={"message": "Rescan triggered"})
mock_series_app = Mock()
with patch(
'src.server.api.scheduler.get_config_service',
return_value=mock_config_service
), patch(
'src.server.utils.dependencies.get_series_app',
return_value=mock_series_app
), patch(
'src.server.api.anime.trigger_rescan',
mock_trigger
):
# Update config to enable scheduler
response = await authenticated_client.post(
with patch("src.server.api.scheduler.get_config_service", return_value=mock_config_service), \
patch("src.server.api.scheduler.get_scheduler_service", return_value=mock_scheduler_service), \
patch("src.server.utils.dependencies.get_series_app", return_value=mock_series_app), \
patch("src.server.api.anime.trigger_rescan", mock_trigger):
r = await authenticated_client.post(
"/api/scheduler/config",
json={
"enabled": True,
"interval_minutes": 360
}
json={"enabled": True, "interval_minutes": 360},
)
assert response.status_code == 200
# Trigger manual rescan
response = await authenticated_client.post(
"/api/scheduler/trigger-rescan"
)
assert response.status_code == 200
assert r.status_code == 200
r = await authenticated_client.post("/api/scheduler/trigger-rescan")
assert r.status_code == 200
mock_trigger.assert_called_once()

View File

@@ -1,10 +1,8 @@
"""Integration tests for scheduler workflow.
This module tests end-to-end scheduler workflows including:
- Scheduler trigger → library rescan → database update workflow
- Configuration changes apply immediately
- Scheduler persistence after application restart
- Concurrent manual and automated scan handling
Tests end-to-end scheduler workflows with the APScheduler-based
SchedulerService, covering lifecycle, manual triggers, config reloading,
WebSocket broadcasting, auto-download, and concurrency protection.
"""
import asyncio
from datetime import datetime, timezone
@@ -15,499 +13,511 @@ import pytest
from src.server.models.config import AppConfig, SchedulerConfig
from src.server.services.scheduler_service import (
SchedulerService,
SchedulerServiceError,
_JOB_ID,
get_scheduler_service,
reset_scheduler_service,
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_config_service():
"""Create a mock configuration service."""
"""Patch get_config_service used by SchedulerService.start()."""
with patch("src.server.services.scheduler_service.get_config_service") as mock:
config_service = Mock()
# Default configuration
app_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=1 # Short interval for testing
schedule_time="03:00",
schedule_days=["mon", "tue", "wed", "thu", "fri", "sat", "sun"],
auto_download_after_rescan=False,
)
)
config_service.load_config.return_value = app_config
config_service.update_config = Mock()
mock.return_value = config_service
yield config_service
@pytest.fixture
def mock_anime_service():
"""Create a mock anime service that simulates database updates."""
"""Patch get_anime_service used inside _perform_rescan."""
with patch("src.server.utils.dependencies.get_anime_service") as mock:
service = Mock()
service.rescan = AsyncMock()
service.series_list = []
# Simulate database update during rescan
async def rescan_side_effect():
# Simulate finding new series
service.series_list = [
{"key": "series1", "name": "New Series 1"},
{"key": "series2", "name": "New Series 2"}
]
await asyncio.sleep(0.1) # Simulate work
service.rescan.side_effect = rescan_side_effect
mock.return_value = service
yield service
@pytest.fixture
def mock_websocket_service():
"""Create a mock WebSocket service that tracks broadcasts."""
"""Patch get_websocket_service to capture broadcasts."""
with patch("src.server.services.websocket_service.get_websocket_service") as mock:
service = Mock()
service.manager = Mock()
service.broadcasts = [] # Track all broadcasts
service.broadcasts = []
async def broadcast_side_effect(message):
service.broadcasts.append(message)
service.manager.broadcast = AsyncMock(side_effect=broadcast_side_effect)
mock.return_value = service
yield service
@pytest.fixture
async def scheduler_service():
"""Create a fresh scheduler service instance for each test."""
async def scheduler_service(mock_config_service):
"""Fresh SchedulerService instance; stopped automatically after each test."""
reset_scheduler_service()
service = SchedulerService()
yield service
# Cleanup
if service._is_running:
await service.stop()
svc = SchedulerService()
yield svc
if svc._is_running:
await svc.stop()
class TestSchedulerWorkflow:
"""Tests for end-to-end scheduler workflows."""
# ---------------------------------------------------------------------------
# TestSchedulerLifecycle
# ---------------------------------------------------------------------------
class TestSchedulerLifecycle:
"""Tests for SchedulerService start/stop lifecycle."""
@pytest.mark.asyncio
async def test_scheduled_rescan_updates_database(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduled rescan updates the database with new series."""
# Start scheduler
await scheduler_service.start()
# Wait for at least one scan cycle (1 minute + buffer)
await asyncio.sleep(65)
# Verify database was updated
assert mock_anime_service.rescan.call_count >= 1
assert len(mock_anime_service.series_list) == 2
# Verify WebSocket notifications were sent
assert len(mock_websocket_service.broadcasts) >= 2
# Check for rescan events
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_started" in event_types
assert "scheduled_rescan_completed" in event_types
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_configuration_change_applies_immediately(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that configuration changes are applied immediately."""
# Start with 1 minute interval
await scheduler_service.start()
original_interval = scheduler_service._config.interval_minutes
assert original_interval == 1
# Change interval to 2 minutes
new_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=2
)
)
mock_config_service.load_config.return_value = new_config
# Reload configuration
await scheduler_service.reload_config()
# Verify new interval is applied
assert scheduler_service._config.interval_minutes == 2
assert scheduler_service._is_running is True # Should still be running
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_disable_scheduler_stops_execution(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that disabling scheduler stops future rescans."""
# Start scheduler
async def test_start_sets_is_running(self, scheduler_service):
"""start() sets _is_running to True."""
await scheduler_service.start()
assert scheduler_service._is_running is True
# Wait for one scan to complete
await asyncio.sleep(65)
initial_scan_count = mock_anime_service.rescan.call_count
assert initial_scan_count >= 1
# Disable scheduler
disabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=False,
interval_minutes=1
)
)
mock_config_service.load_config.return_value = disabled_config
await scheduler_service.reload_config()
# Verify scheduler stopped
@pytest.mark.asyncio
async def test_stop_clears_is_running(self, scheduler_service):
"""stop() sets _is_running to False."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service._is_running is False
# Wait another scan cycle
await asyncio.sleep(65)
# Verify no additional scans occurred
assert mock_anime_service.rescan.call_count == initial_scan_count
@pytest.mark.asyncio
async def test_manual_scan_blocks_scheduled_scan(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that manual scan prevents concurrent scheduled scan."""
async def test_start_twice_raises(self, scheduler_service):
"""Calling start() when already running raises SchedulerServiceError."""
await scheduler_service.start()
# Make rescan slow to simulate long-running operation
async def slow_rescan():
await asyncio.sleep(2)
mock_anime_service.rescan.side_effect = slow_rescan
# Trigger manual scan
task1 = asyncio.create_task(scheduler_service._perform_rescan())
# Wait a bit to ensure manual scan is in progress
await asyncio.sleep(0.5)
assert scheduler_service._scan_in_progress is True
# Try to trigger another scan (simulating scheduled trigger)
result = await scheduler_service.trigger_rescan()
# Second scan should be blocked
assert result is False
# Wait for first scan to complete
await task1
# Verify only one scan executed
assert mock_anime_service.rescan.call_count == 1
# Cleanup
await scheduler_service.stop()
with pytest.raises(SchedulerServiceError, match="already running"):
await scheduler_service.start()
@pytest.mark.asyncio
async def test_scheduler_state_persists_across_restart(
self,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduler can restart with same configuration."""
# Create and start first scheduler instance
async def test_stop_when_not_running_is_noop(self, scheduler_service):
"""stop() when not started does not raise."""
await scheduler_service.stop() # should not raise
assert scheduler_service._is_running is False
@pytest.mark.asyncio
async def test_start_loads_config(self, scheduler_service, mock_config_service):
"""start() loads configuration via config_service."""
await scheduler_service.start()
mock_config_service.load_config.assert_called_once()
@pytest.mark.asyncio
async def test_start_disabled_scheduler_no_job(self, mock_config_service):
"""Disabled scheduler starts but does not add an APScheduler job."""
mock_config_service.load_config.return_value = AppConfig(
scheduler=SchedulerConfig(enabled=False)
)
reset_scheduler_service()
scheduler1 = SchedulerService()
await scheduler1.start()
# Record configuration
original_config = scheduler1._config
assert scheduler1._is_running is True
# Stop scheduler (simulating app shutdown)
await scheduler1.stop()
assert scheduler1._is_running is False
# Create new scheduler instance (simulating app restart)
reset_scheduler_service()
scheduler2 = SchedulerService()
# Start new scheduler with same configuration
await scheduler2.start()
# Verify it has same configuration and is running
assert scheduler2._is_running is True
assert scheduler2._config.enabled == original_config.enabled
assert scheduler2._config.interval_minutes == original_config.interval_minutes
# Cleanup
await scheduler2.stop()
svc = SchedulerService()
await svc.start()
assert svc._is_running is True
# No job should be registered
assert svc._scheduler.get_job(_JOB_ID) is None
await svc.stop()
@pytest.mark.asyncio
async def test_scheduler_recovers_from_rescan_failure(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test that scheduler continues after rescan failure."""
# Make first rescan fail, subsequent rescans succeed
call_count = {"count": 0}
async def failing_rescan():
call_count["count"] += 1
if call_count["count"] == 1:
raise Exception("Database connection error")
# Subsequent calls succeed
mock_anime_service.rescan.side_effect = failing_rescan
async def test_start_registers_apscheduler_job(self, scheduler_service):
"""Enabled scheduler registers a job with _JOB_ID."""
await scheduler_service.start()
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
@pytest.mark.asyncio
async def test_restart_after_stop(self, scheduler_service):
"""Service can be started again after being stopped."""
await scheduler_service.start()
# Wait for multiple scan cycles (2 minutes + buffer)
await asyncio.sleep(130)
# Verify multiple scans were attempted despite failure
assert mock_anime_service.rescan.call_count >= 2
# Verify error was broadcast
error_broadcasts = [
b for b in mock_websocket_service.broadcasts
if b.get("type") == "scheduled_rescan_error"
]
assert len(error_broadcasts) >= 1
# Cleanup
await scheduler_service.stop()
await scheduler_service.start()
assert scheduler_service._is_running is True
# ---------------------------------------------------------------------------
# TestSchedulerTriggerRescan
# ---------------------------------------------------------------------------
class TestSchedulerTriggerRescan:
"""Tests for manual trigger_rescan workflow."""
@pytest.mark.asyncio
async def test_full_workflow_trigger_rescan_update_notify(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_trigger_rescan_calls_anime_service(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test complete workflow: triggerrescan → update → notify."""
"""trigger_rescan() calls anime_service.rescan()."""
await scheduler_service.start()
# Trigger manual rescan
result = await scheduler_service.trigger_rescan()
assert result is True
# Verify workflow steps
# 1. Rescan was performed
assert mock_anime_service.rescan.call_count == 1
# 2. Database was updated with new series
assert len(mock_anime_service.series_list) == 2
# 3. WebSocket notifications were sent
assert len(mock_websocket_service.broadcasts) >= 2
# 4. Verify event sequence
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
start_index = event_types.index("scheduled_rescan_started")
complete_index = event_types.index("scheduled_rescan_completed")
assert complete_index > start_index # Complete comes after start
# 5. Verify scan time was recorded
mock_anime_service.rescan.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_rescan_records_last_run(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""trigger_rescan() updates _last_scan_time."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._last_scan_time is not None
assert isinstance(scheduler_service._last_scan_time, datetime)
# 6. Scan is no longer in progress
@pytest.mark.asyncio
async def test_trigger_rescan_when_not_running_raises(self, scheduler_service):
"""trigger_rescan() without start() raises SchedulerServiceError."""
with pytest.raises(SchedulerServiceError, match="not running"):
await scheduler_service.trigger_rescan()
@pytest.mark.asyncio
async def test_trigger_rescan_blocked_during_scan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Second trigger_rescan() returns False while a scan is in progress."""
async def slow_rescan():
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
await asyncio.sleep(0.05)
assert scheduler_service._scan_in_progress is True
result = await scheduler_service.trigger_rescan()
assert result is False
await task
@pytest.mark.asyncio
async def test_trigger_rescan_scan_in_progress_false_after_completion(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress returns to False after trigger_rescan completes."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
assert scheduler_service._scan_in_progress is False
# Cleanup
await scheduler_service.stop()
@pytest.mark.asyncio
async def test_multiple_sequential_rescans(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test multiple sequential rescans execute successfully."""
"""Three sequential manual rescans all execute successfully."""
await scheduler_service.start()
# Trigger 3 manual rescans sequentially
for i in range(3):
for _ in range(3):
result = await scheduler_service.trigger_rescan()
assert result is True
# Small delay between rescans
await asyncio.sleep(0.1)
# Verify all 3 rescans executed
assert mock_anime_service.rescan.call_count == 3
# Verify 6 WebSocket broadcasts (start + complete for each scan)
assert len(mock_websocket_service.broadcasts) >= 6
# Cleanup
await scheduler_service.stop()
# ---------------------------------------------------------------------------
# TestSchedulerWebSocketBroadcasts
# ---------------------------------------------------------------------------
class TestSchedulerWebSocketBroadcasts:
"""Tests for WebSocket event emission during rescan."""
@pytest.mark.asyncio
async def test_scheduler_status_accuracy_during_workflow(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_rescan_broadcasts_started_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test that status accurately reflects scheduler state during workflow."""
# Initial status
"""_perform_rescan() broadcasts 'scheduled_rescan_started'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_started" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_completed_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_completed'."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
event_types = [b["type"] for b in mock_websocket_service.broadcasts]
assert "scheduled_rescan_completed" in event_types
@pytest.mark.asyncio
async def test_rescan_broadcasts_error_on_failure(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_perform_rescan() broadcasts 'scheduled_rescan_error' when rescan raises."""
mock_anime_service.rescan.side_effect = RuntimeError("DB failure")
await scheduler_service.start()
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "scheduled_rescan_error"
]
assert len(error_events) >= 1
@pytest.mark.asyncio
async def test_rescan_completed_event_order(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""'started' event precedes 'completed' event in broadcast sequence."""
await scheduler_service.start()
await scheduler_service.trigger_rescan()
types = [b["type"] for b in mock_websocket_service.broadcasts]
started_idx = types.index("scheduled_rescan_started")
completed_idx = types.index("scheduled_rescan_completed")
assert completed_idx > started_idx
# ---------------------------------------------------------------------------
# TestSchedulerGetStatus
# ---------------------------------------------------------------------------
class TestSchedulerGetStatus:
"""Tests for get_status() accuracy."""
@pytest.mark.asyncio
async def test_status_not_running_before_start(self, scheduler_service):
"""is_running is False before start()."""
status = scheduler_service.get_status()
assert status["is_running"] is False
assert status["scan_in_progress"] is False
# Start scheduler
@pytest.mark.asyncio
async def test_status_is_running_after_start(self, scheduler_service):
"""is_running is True after start()."""
await scheduler_service.start()
status = scheduler_service.get_status()
assert status["is_running"] is True
assert status["enabled"] is True
assert status["interval_minutes"] == 1
# Make rescan slow to check in-progress status
async def slow_rescan():
await asyncio.sleep(0.5)
mock_anime_service.rescan.side_effect = slow_rescan
# Start rescan
task = asyncio.create_task(scheduler_service._perform_rescan())
# Check status during rescan
await asyncio.sleep(0.1)
status = scheduler_service.get_status()
assert status["scan_in_progress"] is True
# Wait for rescan to complete
await task
# Check status after rescan
status = scheduler_service.get_status()
assert status["scan_in_progress"] is False
assert status["last_scan_time"] is not None
# Cleanup
await scheduler_service.stop()
# Final status
status = scheduler_service.get_status()
assert status["is_running"] is False
class TestSchedulerEdgeCases:
"""Tests for edge cases in scheduler workflows."""
@pytest.mark.asyncio
async def test_rapid_enable_disable_cycles(
self,
mock_config_service,
mock_anime_service,
mock_websocket_service
async def test_status_last_run_populated_after_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Test rapid enable/disable cycles don't cause issues."""
reset_scheduler_service()
scheduler = SchedulerService()
# Rapidly enable and disable 5 times
for i in range(5):
enabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=1
)
)
disabled_config = AppConfig(
scheduler=SchedulerConfig(
enabled=False,
interval_minutes=1
)
)
if i % 2 == 0:
mock_config_service.load_config.return_value = enabled_config
await scheduler.reload_config()
else:
mock_config_service.load_config.return_value = disabled_config
await scheduler.reload_config()
await asyncio.sleep(0.1)
# Final state should match last configuration (i=4 is even, so enabled)
status = scheduler.get_status()
assert status["is_running"] is True # Last config (i=4) was enabled
# Cleanup
if scheduler._is_running:
await scheduler.stop()
@pytest.mark.asyncio
async def test_interval_change_during_active_scan(
self,
scheduler_service,
mock_config_service,
mock_anime_service,
mock_websocket_service
):
"""Test configuration change during active scan."""
"""last_run is not None after a successful rescan."""
await scheduler_service.start()
# Make rescan slow
await scheduler_service.trigger_rescan()
status = scheduler_service.get_status()
assert status["last_run"] is not None
@pytest.mark.asyncio
async def test_status_scan_in_progress_during_slow_rescan(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""scan_in_progress is True while rescan is executing."""
async def slow_rescan():
await asyncio.sleep(1)
await asyncio.sleep(0.3)
mock_anime_service.rescan.side_effect = slow_rescan
# Start a rescan
await scheduler_service.start()
task = asyncio.create_task(scheduler_service._perform_rescan())
# Change interval while scan is in progress
await asyncio.sleep(0.2)
new_config = AppConfig(
scheduler=SchedulerConfig(
enabled=True,
interval_minutes=5
)
)
mock_config_service.load_config.return_value = new_config
# Reload config (should restart scheduler)
await scheduler_service.reload_config()
# Wait for scan to complete
await asyncio.sleep(0.05)
assert scheduler_service.get_status()["scan_in_progress"] is True
await task
# Verify new interval is applied
assert scheduler_service._config.interval_minutes == 5
# Cleanup
@pytest.mark.asyncio
async def test_status_is_running_false_after_stop(self, scheduler_service):
"""is_running is False after stop()."""
await scheduler_service.start()
await scheduler_service.stop()
assert scheduler_service.get_status()["is_running"] is False
@pytest.mark.asyncio
async def test_status_includes_cron_fields(self, scheduler_service):
"""get_status() includes schedule_time, schedule_days, auto_download keys."""
await scheduler_service.start()
status = scheduler_service.get_status()
for key in ("schedule_time", "schedule_days", "auto_download_after_rescan", "next_run"):
assert key in status
# ---------------------------------------------------------------------------
# TestReloadConfig
# ---------------------------------------------------------------------------
class TestReloadConfig:
"""Tests for reload_config() live reconfiguration."""
@pytest.mark.asyncio
async def test_reload_reschedules_job_on_time_change(self, scheduler_service):
"""Changing schedule_time reschedules the existing job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
new_config = SchedulerConfig(enabled=True, schedule_time="08:00")
scheduler_service.reload_config(new_config)
job = scheduler_service._scheduler.get_job(_JOB_ID)
assert job is not None
assert scheduler_service._config.schedule_time == "08:00"
@pytest.mark.asyncio
async def test_reload_removes_job_when_disabled(self, scheduler_service):
"""Setting enabled=False removes the APScheduler job."""
await scheduler_service.start()
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
scheduler_service.reload_config(
SchedulerConfig(enabled=False)
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_removes_job_when_days_empty(self, scheduler_service):
"""Empty schedule_days removes the APScheduler job."""
await scheduler_service.start()
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_days=[])
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
@pytest.mark.asyncio
async def test_reload_adds_job_when_reenabling(self, scheduler_service):
"""Re-enabling after disable adds a new job."""
await scheduler_service.start()
scheduler_service.reload_config(SchedulerConfig(enabled=False))
assert scheduler_service._scheduler.get_job(_JOB_ID) is None
scheduler_service.reload_config(
SchedulerConfig(enabled=True, schedule_time="09:00")
)
assert scheduler_service._scheduler.get_job(_JOB_ID) is not None
@pytest.mark.asyncio
async def test_reload_updates_config_attribute(self, scheduler_service):
"""reload_config() updates self._config with the supplied instance."""
await scheduler_service.start()
new = SchedulerConfig(enabled=True, schedule_time="14:30", schedule_days=["mon"])
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "14:30"
assert scheduler_service._config.schedule_days == ["mon"]
def test_reload_before_start_stores_config(self, scheduler_service):
"""reload_config() before start() stores config without raising."""
new = SchedulerConfig(enabled=True, schedule_time="22:00")
scheduler_service.reload_config(new)
assert scheduler_service._config.schedule_time == "22:00"
# ---------------------------------------------------------------------------
# TestAutoDownloadWorkflow
# ---------------------------------------------------------------------------
class TestAutoDownloadWorkflow:
"""Tests for auto-download-after-rescan integration."""
@pytest.mark.asyncio
async def test_auto_download_triggered_when_enabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is called when auto_download_after_rescan=True."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == [True]
@pytest.mark.asyncio
async def test_auto_download_not_called_when_disabled(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""_auto_download_missing() is NOT called when auto_download_after_rescan=False."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=False,
)
scheduler_service._is_running = True
called = []
async def fake_auto_download():
called.append(True)
scheduler_service._auto_download_missing = fake_auto_download
await scheduler_service._perform_rescan()
assert called == []
@pytest.mark.asyncio
async def test_auto_download_error_broadcasts_event(
self, scheduler_service, mock_anime_service, mock_websocket_service
):
"""Error in _auto_download_missing broadcasts 'auto_download_error'."""
scheduler_service._config = SchedulerConfig(
enabled=True,
auto_download_after_rescan=True,
)
scheduler_service._is_running = True
async def failing_auto_download():
raise RuntimeError("download failed")
scheduler_service._auto_download_missing = failing_auto_download
await scheduler_service._perform_rescan()
error_events = [
b for b in mock_websocket_service.broadcasts
if b["type"] == "auto_download_error"
]
assert len(error_events) == 1
# ---------------------------------------------------------------------------
# TestSchedulerSingletonHelpers
# ---------------------------------------------------------------------------
class TestSchedulerSingletonHelpers:
"""Tests for module-level singleton helpers."""
def test_get_scheduler_service_returns_same_instance(self):
"""get_scheduler_service() returns the same object on repeated calls."""
svc1 = get_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is svc2
def test_reset_clears_singleton(self):
"""reset_scheduler_service() causes get_scheduler_service() to return a new instance."""
svc1 = get_scheduler_service()
reset_scheduler_service()
svc2 = get_scheduler_service()
assert svc1 is not svc2
@pytest.mark.asyncio
async def test_state_persists_across_restart(self, mock_config_service):
"""Stopping and restarting loads config from service each time."""
reset_scheduler_service()
svc = SchedulerService()
await svc.start()
original_time = svc._config.schedule_time
assert svc._is_running is True
await svc.stop()
assert svc._is_running is False
reset_scheduler_service()
svc2 = SchedulerService()
await svc2.start()
assert svc2._is_running is True
assert svc2._config.schedule_time == original_time
await svc2.stop()

View File

@@ -0,0 +1,129 @@
"""Unit tests for SchedulerConfig model fields and validators (Task 3)."""
import pytest
from pydantic import ValidationError
from src.server.models.config import SchedulerConfig
ALL_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
class TestSchedulerConfigDefaults:
"""3.1 Default values."""
def test_default_schedule_time(self) -> None:
config = SchedulerConfig()
assert config.schedule_time == "03:00"
def test_default_schedule_days(self) -> None:
config = SchedulerConfig()
assert config.schedule_days == ALL_DAYS
def test_default_auto_download(self) -> None:
config = SchedulerConfig()
assert config.auto_download_after_rescan is False
def test_default_enabled(self) -> None:
config = SchedulerConfig()
assert config.enabled is True
def test_default_interval_minutes(self) -> None:
config = SchedulerConfig()
assert config.interval_minutes == 60
class TestSchedulerConfigValidScheduleTime:
"""3.2 Valid schedule_time values."""
@pytest.mark.parametrize("time_val", ["00:00", "03:00", "12:30", "23:59"])
def test_valid_times(self, time_val: str) -> None:
config = SchedulerConfig(schedule_time=time_val)
assert config.schedule_time == time_val
class TestSchedulerConfigInvalidScheduleTime:
"""3.3 Invalid schedule_time values must raise ValidationError."""
@pytest.mark.parametrize(
"time_val",
["25:00", "3pm", "", "3:00pm", "24:00", "-1:00", "9:00", "1:60"],
)
def test_invalid_times(self, time_val: str) -> None:
with pytest.raises(ValidationError):
SchedulerConfig(schedule_time=time_val)
class TestSchedulerConfigValidScheduleDays:
"""3.4 Valid schedule_days values."""
def test_single_day(self) -> None:
config = SchedulerConfig(schedule_days=["mon"])
assert config.schedule_days == ["mon"]
def test_multiple_days(self) -> None:
config = SchedulerConfig(schedule_days=["mon", "fri"])
assert config.schedule_days == ["mon", "fri"]
def test_all_days(self) -> None:
config = SchedulerConfig(schedule_days=ALL_DAYS)
assert config.schedule_days == ALL_DAYS
def test_empty_list(self) -> None:
config = SchedulerConfig(schedule_days=[])
assert config.schedule_days == []
class TestSchedulerConfigInvalidScheduleDays:
"""3.5 Invalid schedule_days values must raise ValidationError."""
@pytest.mark.parametrize(
"days",
[
["monday"],
["xyz"],
["Mon"], # Case-sensitive — must be lowercase
[""],
],
)
def test_invalid_days(self, days: list) -> None:
with pytest.raises(ValidationError):
SchedulerConfig(schedule_days=days)
class TestSchedulerConfigAutoDownload:
"""3.6 auto_download_after_rescan field."""
def test_set_true(self) -> None:
config = SchedulerConfig(auto_download_after_rescan=True)
assert config.auto_download_after_rescan is True
def test_set_false(self) -> None:
config = SchedulerConfig(auto_download_after_rescan=False)
assert config.auto_download_after_rescan is False
class TestSchedulerConfigBackwardCompat:
"""3.7 Backward compatibility: old fields still work."""
def test_legacy_fields_use_defaults(self) -> None:
config = SchedulerConfig(enabled=True, interval_minutes=30)
assert config.schedule_time == "03:00"
assert config.schedule_days == ALL_DAYS
assert config.auto_download_after_rescan is False
assert config.enabled is True
assert config.interval_minutes == 30
class TestSchedulerConfigSerialisation:
"""3.8 Serialisation roundtrip."""
def test_roundtrip(self) -> None:
original = SchedulerConfig(
enabled=True,
interval_minutes=120,
schedule_time="04:30",
schedule_days=["mon", "wed", "fri"],
auto_download_after_rescan=True,
)
dumped = original.model_dump()
restored = SchedulerConfig(**dumped)
assert restored == original

File diff suppressed because it is too large Load Diff