diff --git a/docs/API.md b/docs/API.md index 2f2bff4..529de09 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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:00–23: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 --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 82752bb..5355f6c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 (Mon–Sun) 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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 910086e..b37a12c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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) diff --git a/docs/features.md b/docs/features.md index 5c15282..45dcc16 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 diff --git a/requirements.txt b/requirements.txt index 4513817..322817a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,4 +17,5 @@ sqlalchemy>=2.0.35 aiosqlite>=0.19.0 aiohttp>=3.9.0 lxml>=5.0.0 -pillow>=10.0.0 \ No newline at end of file +pillow>=10.0.0 +APScheduler>=3.10.4 \ No newline at end of file diff --git a/src/server/api/scheduler.py b/src/server/api/scheduler.py index 8b5cf2c..e0e4f8d 100644 --- a/src/server/api/scheduler.py +++ b/src/server/api/scheduler.py @@ -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 diff --git a/src/server/models/config.py b/src/server/models/config.py index f16b79e..a910003 100644 --- a/src/server/models/config.py +++ b/src/server/models/config.py @@ -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:00–23: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:00–23: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): diff --git a/src/server/services/scheduler_service.py b/src/server/services/scheduler_service.py index 5c94ab2..f409c11 100644 --- a/src/server/services/scheduler_service.py +++ b/src/server/services/scheduler_service.py @@ -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 diff --git a/src/server/web/static/css/pages/index.css b/src/server/web/static/css/pages/index.css index ab3a40e..ab5ba35 100644 --- a/src/server/web/static/css/pages/index.css +++ b/src/server/web/static/css/pages/index.css @@ -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); + } +} diff --git a/src/server/web/static/js/app.js b/src/server/web/static/js/app.js index 0397515..2ddced2 100644 --- a/src/server/web/static/js/app.js +++ b/src/server/web/static/js/app.js @@ -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; } } diff --git a/src/server/web/templates/index.html b/src/server/web/templates/index.html index fd91f78..994ca16 100644 --- a/src/server/web/templates/index.html +++ b/src/server/web/templates/index.html @@ -254,17 +254,46 @@ -