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

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