Ctrl K
ComunidadGuías…

server/src/scheduler/index.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, ScheduledTask> = new Map();
  private jobStatus: Map<string, JobStatus> = new Map();
  private isRunning: boolean = false;

  constructor(config: Partial<SchedulerConfig> = {}) {
    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<SchedulerConfig>): 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<TaskResult | null> {
    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<TaskResult> {
    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<Map<string, TaskResult>> {
    const results = new Map<string, TaskResult>();

    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<SchedulerConfig>): Scheduler {
  return new Scheduler(config);
}

export default Scheduler;