import cron, { ScheduledTask } from 'node-cron'; import { CronExpressionParser } from 'cron-parser'; import logger from '../utils/logger'; import { scanLibraries, processDeletionQueue, sendDeletionReminders, captureStorageSnapshot, captureUnraidCapacitySnapshot, syncPlexUsers, syncPlexLibrary, monitorDiskPressure, sendTelemetryHeartbeat, refreshAnnouncements, getTask, getAvailableTasks, type TaskResult, type TaskFunction, } from './tasks'; // ============================================================================ // Scheduler Configuration // ============================================================================ export interface SchedulerConfig { enabledTasks: string[]; schedules: { syncPlexLibrary: string; scanLibraries: string; processDeletionQueue: string; sendDeletionReminders: string; captureStorageSnapshot: string; captureUnraidCapacitySnapshot: string; syncPlexUsers: string; monitorDiskPressure: string; sendTelemetryHeartbeat: string; refreshAnnouncements: string; }; timezone: string; } const DEFAULT_CONFIG: SchedulerConfig = { enabledTasks: ['syncPlexLibrary', 'scanLibraries', 'processDeletionQueue', 'sendDeletionReminders', 'captureStorageSnapshot', 'captureUnraidCapacitySnapshot', 'syncPlexUsers', 'monitorDiskPressure', 'sendTelemetryHeartbeat', 'refreshAnnouncements'], schedules: { syncPlexLibrary: '0 2 * * *', // Daily at 2 AM (before scan, so rules see fresh catalog) scanLibraries: '0 3 * * *', // Daily at 3 AM processDeletionQueue: '0 4 * * *', // Daily at 4 AM sendDeletionReminders: '0 9 * * *', // Daily at 9 AM captureStorageSnapshot: '30 3 * * *', // Daily at 3:30 AM (after scan) captureUnraidCapacitySnapshot: '35 3 * * *', // Daily at 3:35 AM (after storage snapshot) syncPlexUsers: '45 3 * * *', // Daily at 3:45 AM (after scan) monitorDiskPressure: '*/20 * * * *', // Every 20 minutes (self-disables via settings) // Hourly, but the heartbeat itself only sends once every 20 hours. Running // the check hourly instead of once a day is what spreads the traffic out: // each install settles into its own slot based on when it booted, rather // than every install in the world pinging at the same minute. A skipped // run is a settings read and nothing else. sendTelemetryHeartbeat: '17 * * * *', // Every six hours as a floor for installs nobody opens; page loads also // refresh when the cache is more than a few minutes old. refreshAnnouncements: '41 */6 * * *', }, timezone: 'UTC', }; // ============================================================================ // Job Status Types // ============================================================================ export interface JobStatus { name: string; enabled: boolean; schedule: string; lastRun?: Date; lastResult?: TaskResult; nextRun?: Date; isRunning: boolean; } // ============================================================================ // Scheduler Class // ============================================================================ /** * Scheduler for managing automated tasks using node-cron */ export class Scheduler { private config: SchedulerConfig; private jobs: Map = new Map(); private jobStatus: Map = new Map(); private isRunning: boolean = false; constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; // Initialize job status for all tasks for (const taskName of getAvailableTasks()) { const schedule = this.config.schedules[taskName as keyof typeof this.config.schedules] || ''; this.jobStatus.set(taskName, { name: taskName, enabled: this.config.enabledTasks.includes(taskName), schedule, isRunning: false, }); } } // ============================================================================ // Lifecycle Methods // ============================================================================ /** * Start all scheduled jobs */ start(): void { if (this.isRunning) { logger.warn('Scheduler is already running'); return; } logger.info('Starting scheduler'); // Schedule Plex library sync (runs first; pulls catalog into DB) if (this.config.enabledTasks.includes('syncPlexLibrary')) { this.scheduleJob('syncPlexLibrary', this.config.schedules.syncPlexLibrary, syncPlexLibrary); } // Schedule library scans if (this.config.enabledTasks.includes('scanLibraries')) { this.scheduleScans(this.config.schedules.scanLibraries); } // Schedule deletion processing if (this.config.enabledTasks.includes('processDeletionQueue')) { this.scheduleDeletionProcessing(this.config.schedules.processDeletionQueue); } // Schedule deletion reminders if (this.config.enabledTasks.includes('sendDeletionReminders')) { this.scheduleJob('sendDeletionReminders', this.config.schedules.sendDeletionReminders, sendDeletionReminders); } // Schedule storage snapshots if (this.config.enabledTasks.includes('captureStorageSnapshot')) { this.scheduleJob('captureStorageSnapshot', this.config.schedules.captureStorageSnapshot, captureStorageSnapshot); } // Schedule Unraid capacity snapshots (powers trend/forecast UI) if (this.config.enabledTasks.includes('captureUnraidCapacitySnapshot')) { this.scheduleJob('captureUnraidCapacitySnapshot', this.config.schedules.captureUnraidCapacitySnapshot, captureUnraidCapacitySnapshot); } // Schedule Plex users sync if (this.config.enabledTasks.includes('syncPlexUsers')) { this.scheduleJob('syncPlexUsers', this.config.schedules.syncPlexUsers, syncPlexUsers); } // Schedule disk-pressure monitor (task self-disables via settings) if (this.config.enabledTasks.includes('sendTelemetryHeartbeat')) { this.scheduleJob('sendTelemetryHeartbeat', this.config.schedules.sendTelemetryHeartbeat, sendTelemetryHeartbeat); } if (this.config.enabledTasks.includes('refreshAnnouncements')) { this.scheduleJob('refreshAnnouncements', this.config.schedules.refreshAnnouncements, refreshAnnouncements); } if (this.config.enabledTasks.includes('monitorDiskPressure')) { this.scheduleJob('monitorDiskPressure', this.config.schedules.monitorDiskPressure, monitorDiskPressure); } this.isRunning = true; logger.info(`Scheduler started with ${this.jobs.size} scheduled jobs`); } /** * Stop all scheduled jobs */ stop(): void { if (!this.isRunning) { logger.warn('Scheduler is not running'); return; } logger.info('Stopping scheduler'); for (const [name, job] of this.jobs) { job.stop(); logger.debug(`Stopped job: ${name}`); } this.jobs.clear(); this.isRunning = false; logger.info('Scheduler stopped'); } /** * Restart the scheduler with new configuration */ restart(config?: Partial): void { this.stop(); if (config) { this.config = { ...this.config, ...config }; } this.start(); } // ============================================================================ // Scheduling Methods // ============================================================================ /** * Schedule library scans */ scheduleScans(cronExpression: string): void { this.scheduleJob('scanLibraries', cronExpression, scanLibraries); } /** * Schedule deletion processing */ scheduleDeletionProcessing(cronExpression: string): void { this.scheduleJob('processDeletionQueue', cronExpression, processDeletionQueue); } /** * Schedule a generic job */ private scheduleJob(name: string, cronExpression: string, taskFn: TaskFunction): void { // Validate cron expression if (!cron.validate(cronExpression)) { logger.error(`Invalid cron expression for ${name}: ${cronExpression}`); return; } // Stop existing job if any if (this.jobs.has(name)) { this.jobs.get(name)?.stop(); this.jobs.delete(name); } // Create the scheduled task const job = cron.schedule( cronExpression, async () => { await this.executeTask(name, taskFn); }, { timezone: this.config.timezone, } ); this.jobs.set(name, job); // Update job status const status = this.jobStatus.get(name); if (status) { status.enabled = true; status.schedule = cronExpression; status.nextRun = this.getNextRunTime(cronExpression); } logger.info(`Scheduled job "${name}" with cron: ${cronExpression}`); } /** * Execute a task and track its status */ private async executeTask(name: string, taskFn: TaskFunction): Promise { const status = this.jobStatus.get(name); if (status?.isRunning) { logger.warn(`Task "${name}" is already running, skipping this execution`); return null; } if (status) { status.isRunning = true; status.lastRun = new Date(); } logger.info(`Executing scheduled task: ${name}`); try { const result = await taskFn(); if (status) { status.isRunning = false; status.lastResult = result; status.nextRun = this.getNextRunTime(status.schedule); } if (result.success) { logger.info(`Task "${name}" completed successfully in ${result.durationMs}ms`); } else { logger.error(`Task "${name}" failed: ${result.error}`); } return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (status) { status.isRunning = false; status.lastResult = { success: false, taskName: name, startedAt: status.lastRun || new Date(), completedAt: new Date(), durationMs: 0, error: errorMessage, }; status.nextRun = this.getNextRunTime(status.schedule); } logger.error(`Task "${name}" threw an exception:`, error); return null; } } // ============================================================================ // Manual Execution // ============================================================================ /** * Run a task immediately by name */ async runNow(taskName: string): Promise { logger.info(`Manual execution requested for task: ${taskName}`); const taskFn = getTask(taskName); if (!taskFn) { const error = `Unknown task: ${taskName}. Available tasks: ${getAvailableTasks().join(', ')}`; logger.error(error); throw new Error(error); } const result = await this.executeTask(taskName, taskFn); if (!result) { throw new Error(`Task "${taskName}" is currently running`); } return result; } /** * Run all scheduled tasks immediately */ async runAllNow(): Promise> { const results = new Map(); for (const taskName of this.config.enabledTasks) { try { const result = await this.runNow(taskName); results.set(taskName, result); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); results.set(taskName, { success: false, taskName, startedAt: new Date(), completedAt: new Date(), durationMs: 0, error: errorMessage, }); } } return results; } // ============================================================================ // Status & Information // ============================================================================ /** * Get status of all scheduled jobs */ getStatus(): JobStatus[] { return Array.from(this.jobStatus.values()); } /** * Get status of a specific job */ getJobStatus(name: string): JobStatus | undefined { return this.jobStatus.get(name); } /** * Check if the scheduler is running */ isSchedulerRunning(): boolean { return this.isRunning; } /** * Get current configuration */ getConfig(): SchedulerConfig { return { ...this.config }; } /** * Calculate next run time from cron expression using cron-parser */ private getNextRunTime(cronExpression: string): Date | undefined { if (!cronExpression || !cron.validate(cronExpression)) { return undefined; } try { const options = { currentDate: new Date(), tz: this.config.timezone, }; const interval = CronExpressionParser.parse(cronExpression, options); return interval.next().toDate(); } catch (error) { logger.error('Failed to parse cron expression', { cronExpression, error }); return undefined; } } // ============================================================================ // Configuration Methods // ============================================================================ /** * Enable a specific task */ enableTask(taskName: string): void { if (!getTask(taskName)) { throw new Error(`Unknown task: ${taskName}`); } if (!this.config.enabledTasks.includes(taskName)) { this.config.enabledTasks.push(taskName); } const status = this.jobStatus.get(taskName); if (status) { status.enabled = true; } // If scheduler is running, schedule the task if (this.isRunning) { const schedule = this.config.schedules[taskName as keyof typeof this.config.schedules]; const taskFn = getTask(taskName); if (schedule && taskFn) { this.scheduleJob(taskName, schedule, taskFn); } } logger.info(`Task "${taskName}" enabled`); } /** * Disable a specific task */ disableTask(taskName: string): void { const index = this.config.enabledTasks.indexOf(taskName); if (index > -1) { this.config.enabledTasks.splice(index, 1); } const status = this.jobStatus.get(taskName); if (status) { status.enabled = false; } // Stop the job if running const job = this.jobs.get(taskName); if (job) { job.stop(); this.jobs.delete(taskName); } logger.info(`Task "${taskName}" disabled`); } /** * Update schedule for a task */ updateSchedule(taskName: string, cronExpression: string): void { if (!cron.validate(cronExpression)) { throw new Error(`Invalid cron expression: ${cronExpression}`); } this.config.schedules[taskName as keyof typeof this.config.schedules] = cronExpression; const status = this.jobStatus.get(taskName); if (status) { status.schedule = cronExpression; status.nextRun = this.getNextRunTime(cronExpression); } // Reschedule if scheduler is running and task is enabled if (this.isRunning && this.config.enabledTasks.includes(taskName)) { const taskFn = getTask(taskName); if (taskFn) { this.scheduleJob(taskName, cronExpression, taskFn); } } logger.info(`Schedule updated for "${taskName}": ${cronExpression}`); } } // ============================================================================ // Singleton Instance // ============================================================================ let schedulerInstance: Scheduler | null = null; /** * Get the singleton scheduler instance */ export function getScheduler(): Scheduler { if (!schedulerInstance) { schedulerInstance = new Scheduler(); } return schedulerInstance; } /** * Create a new scheduler instance with custom configuration */ export function createScheduler(config?: Partial): Scheduler { return new Scheduler(config); } export default Scheduler;