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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user