Ctrl K
ComunidadGuías…

server/src/routes/settings.ts

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

Archivo original ↗Preguntar a KiBot
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import settingsRepo from '../db/repositories/settings';
import { SettingInputSchema } from '../types';
import logger from '../utils/logger';
import { getApiKey, clearApiKeyCache, ensureApiKey } from '../middleware/apiAuth';
import crypto from 'crypto';
import { PlexService, TautulliService, SonarrService, RadarrService, OverseerrService, UnraidService } from '../services';
import { TracearrService } from '../services/tracearr';
import { JellyfinService } from '../services/jellyfin';
import { getConfiguredServerType, isMediaServerType } from '../services/mediaServer';
import { refreshServices, initializeServices, applyDiskPressureSchedule } from '../services/init';
import { getScheduler } from '../scheduler';
import { getNotificationService } from '../notifications';
import { getFixedT } from '../i18n';
import { createBackup, restoreFromFile, validateBackupFile } from '../services/backup';
import { clearAnnouncementsCache } from '../services/announcements';
import {
  getTelemetryState,
  markNoticeSeen,
  setTelemetryEnabled,
  TELEMETRY_INSTALL_ID_KEY,
} from '../services/telemetry';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { loadInProgressConfig } from '../rules/inProgress';
import { getEnvManagedKeys, isEnvManaged } from '../services/envSettings';

const router = Router();

// Schema for validating imported settings
const ImportSettingsSchema = z.object({
  version: z.number(),
  exportedAt: z.string(),
  appName: z.string().optional(),
  settings: z.record(z.string(), z.string()),
});

// Known setting key prefixes for validation
const KNOWN_SETTING_PREFIXES = [
  'media_server_type',
  'plex_',
  'jellyfin_',
  'tautulli_',
  'tracearr_',
  'sonarr_',
  'radarr_',
  'overseerr_',
  'unraid_',
  'notifications_',
  'schedule_',
  'plexSync_',
  'display_',
  'exclusion_',
  'excluded_library_',
  'watch_history_',
  'webhooks_',
  'diskPressure_',
  'inProgress_',
  'storage_',
  'telemetry_',
  'announcements_',
  'api_key',
];

function isKnownSettingKey(key: string): boolean {
  return KNOWN_SETTING_PREFIXES.some(prefix => key.startsWith(prefix));
}

// Validation middleware
function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        success: false,
        error: 'Validation failed',
        details: result.error.issues,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

// GET /api/settings - Get all settings in structured format
router.get('/', (_req: Request, res: Response) => {
  try {
    const rawSettings = settingsRepo.getAll();

    // Convert flat key-value pairs to structured object
    const services: Record<string, Record<string, string>> = {};
    const notifications: Record<string, string | boolean> = {};
    const schedule: Record<string, string | boolean | number> = {};
    const plexSync: Record<string, string | boolean | number> = {};
    const display: Record<string, string> = {};
    const watchHistory: Record<string, string> = {};
    const diskPressure: Record<string, string | boolean | number | string[]> = {};
    // Always reported, so the Safety panel shows the defaults on a fresh install.
    const inProgressConfig = loadInProgressConfig();
    const inProgress = { protect: inProgressConfig.protect, recentDays: inProgressConfig.recentDays };
    // Which media server backend the install talks to. Resolved by the same
    // helper the rest of the app uses, so a stored choice wins but an install
    // configured only by MEDIA_SERVER_TYPE still reports its real backend.
    // Reporting Plex there would make the next save write media_server_type=plex
    // and silently switch the backend out from under the user.
    const mediaServerType = getConfiguredServerType();
    let exclusionPatterns: unknown[] = [];
    let excludedLibraryKeys: string[] = [];
    let webhooks: unknown[] = [];

    for (const setting of rawSettings) {
      const { key, value } = setting;

      if (key === 'media_server_type') {
        // Resolved above; skipped here so it does not fall through into the
        // generic service/prefix parsing below.
        continue;
      }

      // Parse exclusion patterns
      if (key === 'exclusion_patterns') {
        try {
          exclusionPatterns = JSON.parse(value);
        } catch { /* empty */ }
        continue;
      }

      // Parse excluded library keys
      if (key === 'excluded_library_keys') {
        try {
          excludedLibraryKeys = JSON.parse(value);
        } catch { /* empty */ }
        continue;
      }

      // Parse outbound webhook targets (stored as a single JSON array)
      if (key === 'webhooks_targets') {
        try {
          webhooks = JSON.parse(value);
        } catch { /* empty */ }
        continue;
      }

      // In-progress show protection — reported from inProgressConfig above.
      if (key.startsWith('inProgress_')) {
        continue;
      }

      // Parse disk-pressure settings
      if (key.startsWith('diskPressure_')) {
        const field = key.replace('diskPressure_', '');
        if (field === 'paths') {
          try {
            diskPressure[field] = JSON.parse(value);
          } catch {
            diskPressure[field] = [];
          }
        } else if (value === 'true' || value === 'false') {
          diskPressure[field] = value === 'true';
        } else if (value !== '' && !isNaN(Number(value))) {
          diskPressure[field] = Number(value);
        } else {
          diskPressure[field] = value;
        }
        continue;
      }

      // Parse service settings (e.g., plex_url, tautulli_apiKey)
      const serviceMatch = key.match(/^(plex|jellyfin|tautulli|tracearr|sonarr|radarr|overseerr|unraid)_(.+)$/);
      if (serviceMatch && serviceMatch[1] && serviceMatch[2]) {
        const serviceName = serviceMatch[1];
        const field = serviceMatch[2];
        if (!services[serviceName]) {
          services[serviceName] = {};
        }
        services[serviceName][field] = value;
        continue;
      }

      // Parse notification settings
      if (key.startsWith('notifications_')) {
        const field = key.replace('notifications_', '');
        notifications[field] = value === 'true' ? true : value === 'false' ? false : value;
        continue;
      }

      // Parse schedule settings
      if (key.startsWith('schedule_')) {
        const field = key.replace('schedule_', '');
        if (value === 'true' || value === 'false') {
          schedule[field] = value === 'true';
        } else if (value !== '' && !isNaN(Number(value)) && field !== 'time') {
          schedule[field] = Number(value);
        } else {
          schedule[field] = value;
        }
        continue;
      }

      // Parse Plex sync settings
      if (key.startsWith('plexSync_')) {
        const field = key.replace('plexSync_', '');
        if (value === 'true' || value === 'false') {
          plexSync[field] = value === 'true';
        } else if (value !== '' && !isNaN(Number(value)) && field !== 'time') {
          plexSync[field] = Number(value);
        } else {
          plexSync[field] = value;
        }
        continue;
      }

      // Parse display settings
      if (key.startsWith('display_')) {
        const field = key.replace('display_', '');
        display[field] = value;
        continue;
      }

      // Parse watch history settings (watch_history_provider, watch_history_lookback_days)
      if (key.startsWith('watch_history_')) {
        const field = key.replace('watch_history_', '');
        watchHistory[field] = value;
        continue;
      }
    }

    res.json({
      success: true,
      data: {
        mediaServerType,
        services,
        notifications,
        schedule,
        plexSync,
        display,
        watchHistory,
        diskPressure,
        inProgress,
        exclusionPatterns,
        excludedLibraryKeys,
        webhooks,
        // Fields locked to environment variables (CONFIG_FROM_ENV); read-only in the UI.
        envManaged: getEnvManagedKeys(),
      },
    });
  } catch (error) {
    logger.error('Failed to get settings:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve settings',
    });
  }
});

// GET /api/settings/export - Export all settings as JSON file
router.get('/export', (_req: Request, res: Response) => {
  try {
    const rawSettings = settingsRepo.getAll();

    const exportData = {
      version: 1,
      exportedAt: new Date().toISOString(),
      appName: 'Prunerr',
      settings: rawSettings
        // The install ID is per-machine, not a preference. Carrying it into a
        // backup would make a restored copy report as the same install as the
        // original, undercounting anyone who runs two.
        .filter((s) => s.key !== 'api_key' && s.key !== TELEMETRY_INSTALL_ID_KEY)
        .reduce((acc, s) => ({
          ...acc,
          [s.key]: s.value
        }), {} as Record<string, string>),
    };

    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Content-Disposition', 'attachment; filename=prunerr-settings.json');
    res.json(exportData);
  } catch (error) {
    logger.error('Failed to export settings:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to export settings',
    });
  }
});

/**
 * GET /api/settings/backup - Download the whole database.
 *
 * The settings export above covers only the settings table. This is the one
 * that actually protects the library, rules, queue and history.
 */
router.get('/backup', (_req: Request, res: Response) => {
  const tempPath = path.join(os.tmpdir(), `prunerr-backup-${Date.now()}.db`);

  try {
    createBackup(tempPath);

    const stamp = new Date().toISOString().slice(0, 10);
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment; filename=prunerr-backup-${stamp}.db`);
    res.setHeader('Content-Length', String(fs.statSync(tempPath).size));

    const stream = fs.createReadStream(tempPath);
    stream.pipe(res);
    // Clean up whether the download completed or the client went away.
    const cleanup = () => fs.rm(tempPath, { force: true }, () => undefined);
    stream.on('close', cleanup);
    stream.on('error', (error) => {
      logger.error('Failed while streaming backup:', error);
      cleanup();
      if (!res.headersSent) res.status(500).end();
    });
  } catch (error) {
    logger.error('Failed to create backup:', error);
    fs.rm(tempPath, { force: true }, () => undefined);
    res.status(500).json({ success: false, error: 'Failed to create backup' });
  }
});

/**
 * POST /api/settings/restore - Replace the database with an uploaded backup.
 *
 * The body is streamed straight to disk rather than buffered: a real library
 * database is far too big to hold in memory.
 */
router.post('/restore', (req: Request, res: Response) => {
  const tempPath = path.join(os.tmpdir(), `prunerr-restore-${Date.now()}.db`);
  const sink = fs.createWriteStream(tempPath);
  const discard = () => fs.rm(tempPath, { force: true }, () => undefined);

  req.pipe(sink);

  sink.on('error', (error) => {
    logger.error('Failed to receive uploaded backup:', error);
    discard();
    if (!res.headersSent) {
      res.status(500).json({ success: false, error: 'Failed to receive the uploaded file' });
    }
  });

  sink.on('finish', () => {
    const validation = validateBackupFile(tempPath);
    if (!validation.valid) {
      discard();
      res.status(400).json({ success: false, error: validation.error });
      return;
    }

    try {
      const { previousDatabasePath } = restoreFromFile(tempPath);
      discard();
      res.json({
        success: true,
        message: 'Database restored',
        data: { previousDatabasePath: path.basename(previousDatabasePath) },
      });
    } catch (error) {
      logger.error('Failed to restore backup:', error);
      discard();
      res.status(500).json({
        success: false,
        error: error instanceof Error ? error.message : 'Failed to restore backup',
      });
    }
  });
});

// POST /api/settings/import - Import settings from JSON
router.post('/import', async (req: Request, res: Response) => {
  try {
    const parsed = ImportSettingsSchema.safeParse(req.body);
    if (!parsed.success) {
      res.status(400).json({
        success: false,
        error: 'Invalid settings file format',
        details: parsed.error.issues,
      });
      return;
    }

    const { settings } = parsed.data;

    // Validate setting keys
    const unknownKeys = Object.keys(settings).filter(key => !isKnownSettingKey(key));
    if (unknownKeys.length > 0) {
      logger.warn(`Import contains unknown setting keys: ${unknownKeys.join(', ')}`);
    }

    // Check for expected settings structure (warn if missing common keys)
    const hasServiceSettings = Object.keys(settings).some(k =>
      k.startsWith('plex_') || k.startsWith('tautulli_') || k.startsWith('sonarr_') || k.startsWith('radarr_')
    );
    if (!hasServiceSettings) {
      logger.warn('Imported settings file contains no service configuration');
    }

    // Convert to array format for setMultiple - only import known keys, never
    // import api_key, and never adopt another install's telemetry ID (a shared
    // settings file would otherwise make several installs count as one).
    const settingsArray = Object.entries(settings)
      .filter(([key]) => isKnownSettingKey(key) && key !== 'api_key' && key !== TELEMETRY_INSTALL_ID_KEY)
      // Environment variables keep ownership of the fields they set.
      .filter(([key]) => !isEnvManaged(key))
      .map(([key, value]) => ({ key, value }));

    if (settingsArray.length === 0) {
      res.status(400).json({
        success: false,
        error: 'No valid settings found in import file',
        details: unknownKeys.length > 0
          ? `Unknown keys ignored: ${unknownKeys.slice(0, 5).join(', ')}${unknownKeys.length > 5 ? '...' : ''}`
          : 'File appears to be empty or incorrectly formatted',
      });
      return;
    }

    const imported = settingsRepo.setMultiple(settingsArray);

    logger.info(`Imported ${imported.length} settings`);

    // Refresh services with new settings
    refreshServices();
    await initializeServices();

    res.json({
      success: true,
      message: `Successfully imported ${imported.length} settings`,
      data: {
        count: imported.length,
        skipped: unknownKeys.length,
        skippedKeys: unknownKeys.length > 0 ? unknownKeys.slice(0, 5) : undefined,
      },
    });
  } catch (error) {
    logger.error('Failed to import settings:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to import settings',
    });
  }
});

// ============================================================================
// Telemetry — anonymous install count
// ============================================================================

const TelemetryUpdateSchema = z.object({
  enabled: z.boolean().optional(),
  /** Set once the first-run notice has been read, so it stops appearing. */
  noticeSeen: z.boolean().optional(),
});

// GET /api/settings/telemetry - What is sent, and whether it is on
router.get('/telemetry', (_req: Request, res: Response) => {
  try {
    res.json({ success: true, data: getTelemetryState() });
  } catch (error) {
    logger.error('Failed to get telemetry state:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve telemetry state',
    });
  }
});

// PUT /api/settings/telemetry - Toggle it, or acknowledge the first-run notice
router.put('/telemetry', validateBody(TelemetryUpdateSchema), (req: Request, res: Response) => {
  try {
    const { enabled, noticeSeen } = req.body as z.infer<typeof TelemetryUpdateSchema>;

    if (typeof enabled === 'boolean') {
      setTelemetryEnabled(enabled);
      // The "What's new" feed rides the same switch; forget the cached copy so
      // nothing fetched under the old setting lingers.
      if (!enabled) clearAnnouncementsCache();
    }
    if (noticeSeen) {
      markNoticeSeen();
    }

    res.json({ success: true, data: getTelemetryState() });
  } catch (error) {
    logger.error('Failed to update telemetry state:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to update telemetry state',
    });
  }
});

// GET /api/settings/api-key - Get the current API key and enabled status
router.get('/api-key', (_req: Request, res: Response) => {
  try {
    const apiKey = getApiKey();

    res.json({
      success: true,
      data: {
        apiKey,
      },
    });
  } catch (error) {
    logger.error('Failed to get API key:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve API key',
    });
  }
});

// POST /api/settings/api-key/regenerate - Generate a new API key
router.post('/api-key/regenerate', (_req: Request, res: Response) => {
  try {
    const newKey = crypto.randomBytes(32).toString('hex');
    settingsRepo.set({ key: 'api_key', value: newKey });
    clearApiKeyCache();
    logger.info('API key regenerated');

    res.json({
      success: true,
      data: {
        apiKey: newKey,
      },
      message: 'API key regenerated successfully',
    });
  } catch (error) {
    logger.error('Failed to regenerate API key:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to regenerate API key',
    });
  }
});

// GET /api/settings/:key - Get a specific setting
router.get('/:key', (req: Request, res: Response) => {
  try {
    const { key } = req.params;
    const setting = settingsRepo.getByKey(key as string);

    if (!setting) {
      res.status(404).json({
        success: false,
        error: `Setting not found: ${key}`,
      });
      return;
    }

    res.json({
      success: true,
      data: setting,
    });
  } catch (error) {
    logger.error('Failed to get setting:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve setting',
    });
  }
});

// POST /api/settings - Create or update a setting
router.post('/', validateBody(SettingInputSchema), (req: Request, res: Response) => {
  try {
    const setting = settingsRepo.set(req.body);
    res.status(201).json({
      success: true,
      data: setting,
      message: 'Setting saved successfully',
    });
  } catch (error) {
    logger.error('Failed to save setting:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to save setting',
    });
  }
});

// PUT /api/settings - Save all settings at once
router.put('/', async (req: Request, res: Response) => {
  try {
    const settings = req.body;
    const savedSettings: Array<{ key: string; value: string }> = [];

    // Save the selected media server backend (plex | jellyfin | emby)
    if (settings.mediaServerType !== undefined && settings.mediaServerType !== null) {
      const value = String(settings.mediaServerType);
      if (isMediaServerType(value)) {
        settingsRepo.set({ key: 'media_server_type', value });
        savedSettings.push({ key: 'media_server_type', value });
      } else {
        logger.warn(`Ignoring unknown mediaServerType "${value}"`);
      }
    }

    // Save services configuration
    if (settings.services) {
      for (const [service, config] of Object.entries(settings.services)) {
        if (config && typeof config === 'object') {
          const configObj = config as Record<string, string | boolean>;
          for (const [field, value] of Object.entries(configObj)) {
            const key = `${service}_${field}`;
            // With CONFIG_FROM_ENV an environment variable owns this field and
            // would replace the saved value on the next start anyway.
            if (isEnvManaged(key)) continue;
            if (value !== undefined && value !== null && value !== '') {
              settingsRepo.set({ key, value: String(value) });
              savedSettings.push({ key, value: String(value) });
            }
          }
        }
      }
    }

    // Save notifications configuration
    if (settings.notifications) {
      for (const [field, value] of Object.entries(settings.notifications)) {
        if (value !== undefined && value !== null) {
          const key = `notifications_${field}`;
          settingsRepo.set({ key, value: String(value) });
          savedSettings.push({ key, value: String(value) });
        }
      }
    }

    // Save schedule configuration
    if (settings.schedule) {
      for (const [field, value] of Object.entries(settings.schedule)) {
        if (value !== undefined && value !== null) {
          const key = `schedule_${field}`;
          settingsRepo.set({ key, value: String(value) });
          savedSettings.push({ key, value: String(value) });
        }
      }
    }

    // Save Plex sync configuration
    if (settings.plexSync) {
      for (const [field, value] of Object.entries(settings.plexSync)) {
        if (value !== undefined && value !== null) {
          const key = `plexSync_${field}`;
          settingsRepo.set({ key, value: String(value) });
          savedSettings.push({ key, value: String(value) });
        }
      }
    }

    // Save display configuration
    if (settings.display) {
      for (const [field, value] of Object.entries(settings.display)) {
        if (value !== undefined && value !== null) {
          const key = `display_${field}`;
          settingsRepo.set({ key, value: String(value) });
          savedSettings.push({ key, value: String(value) });
        }
      }
    }

    // Save outbound webhook targets (stored as a single JSON array)
    if (settings.webhooks !== undefined) {
      const value = JSON.stringify(Array.isArray(settings.webhooks) ? settings.webhooks : []);
      settingsRepo.set({ key: 'webhooks_targets', value });
      savedSettings.push({ key: 'webhooks_targets', value });
    }

    // Save disk-pressure configuration
    if (settings.diskPressure) {
      for (const [field, value] of Object.entries(settings.diskPressure)) {
        if (value !== undefined && value !== null) {
          const key = `diskPressure_${field}`;
          // `paths` is an array — store as JSON; everything else as a string
          const stored = field === 'paths' ? JSON.stringify(value) : String(value);
          settingsRepo.set({ key, value: stored });
          savedSettings.push({ key, value: stored });
        }
      }
    }

    // Save in-progress show protection (Safety)
    if (settings.inProgress && typeof settings.inProgress === 'object') {
      const { protect, recentDays } = settings.inProgress as { protect?: unknown; recentDays?: unknown };
      if (typeof protect === 'boolean') {
        settingsRepo.set({ key: 'inProgress_protect', value: String(protect) });
        savedSettings.push({ key: 'inProgress_protect', value: String(protect) });
      }
      const days = Number(recentDays);
      if (recentDays !== undefined && Number.isInteger(days) && days >= 1 && days <= 3650) {
        settingsRepo.set({ key: 'inProgress_recentDays', value: String(days) });
        savedSettings.push({ key: 'inProgress_recentDays', value: String(days) });
      }
    }

    // Update scheduler if schedule or Plex sync settings were changed
    const hasScheduleSettings = savedSettings.some(
      s => s.key.startsWith('schedule_') || s.key.startsWith('plexSync_')
    );
    if (hasScheduleSettings) {
      try {
        const scheduler = getScheduler();

        // Check if scheduling is enabled
        const isEnabled = settings.schedule?.enabled;

        if (isEnabled === false) {
          // Disable the scanLibraries task
          scheduler.disableTask('scanLibraries');
          logger.info('Scheduled scanning disabled');
        } else {
          // Build cron expression from saved schedule settings
          const interval = settings.schedule?.interval || 'daily';
          const time = settings.schedule?.time || '03:00';
          const dayOfWeek = settings.schedule?.dayOfWeek;

          // Parse time (format: "HH:mm")
          const [hour, minute] = time.split(':').map(Number);

          let cronExpression: string;
          switch (interval) {
            case 'hourly':
              cronExpression = `${minute} * * * *`;
              break;
            case 'weekly':
              // dayOfWeek: 0 = Sunday, 1 = Monday, etc.
              cronExpression = `${minute} ${hour} * * ${dayOfWeek ?? 0}`;
              break;
            case 'daily':
            default:
              cronExpression = `${minute} ${hour} * * *`;
              break;
          }

          // Enable the task and update its schedule
          scheduler.enableTask('scanLibraries');
          scheduler.updateSchedule('scanLibraries', cronExpression);
          logger.info(`Scheduler updated with new cron: ${cronExpression}`);
        }

        // Handle auto-process deletion queue setting
        const autoProcess = settings.schedule?.autoProcess;
        if (autoProcess === false) {
          scheduler.disableTask('processDeletionQueue');
          logger.info('Auto-process deletion queue disabled');
        } else if (autoProcess === true) {
          scheduler.enableTask('processDeletionQueue');
          logger.info('Auto-process deletion queue enabled');
        }

        // Handle Plex library sync schedule
        if (settings.plexSync !== undefined) {
          const plexSyncEnabled = settings.plexSync?.enabled;

          if (plexSyncEnabled === false) {
            scheduler.disableTask('syncPlexLibrary');
            logger.info('Scheduled Plex library sync disabled');
          } else {
            const psInterval = settings.plexSync?.interval || 'daily';
            const psTime = settings.plexSync?.time || '02:00';
            const psDayOfWeek = settings.plexSync?.dayOfWeek;
            const [psHour, psMinute] = psTime.split(':').map(Number);

            let psCron: string;
            switch (psInterval) {
              case 'hourly':
                psCron = `${psMinute} * * * *`;
                break;
              case 'weekly':
                psCron = `${psMinute} ${psHour} * * ${psDayOfWeek ?? 0}`;
                break;
              case 'daily':
              default:
                psCron = `${psMinute} ${psHour} * * *`;
                break;
            }

            scheduler.enableTask('syncPlexLibrary');
            scheduler.updateSchedule('syncPlexLibrary', psCron);
            logger.info(`Plex library sync schedule updated: ${psCron}`);
          }
        }
      } catch (error) {
        logger.error('Failed to update scheduler:', error);
        // Don't fail the request - settings are saved, scheduler will pick up on next cycle
      }
    }

    // Apply disk-pressure schedule changes (enable/disable + interval)
    if (savedSettings.some((s) => s.key.startsWith('diskPressure_'))) {
      try {
        applyDiskPressureSchedule();
      } catch (error) {
        logger.error('Failed to update disk-pressure schedule:', error);
        // Settings are saved; scheduler picks up on next restart regardless.
      }
    }

    logger.info(`Saved ${savedSettings.length} settings`);

    // Refresh service instances if service settings were updated
    const hasServiceSettings = savedSettings.some(s =>
      s.key.match(/^(plex|tautulli|tracearr|sonarr|radarr|overseerr)_(url|apiKey|token|api_key)$/)
    );
    if (hasServiceSettings) {
      refreshServices();
      // Reinitialize with new settings
      await initializeServices();
    }

    res.json({
      success: true,
      data: savedSettings,
      message: `${savedSettings.length} settings saved successfully`,
    });
  } catch (error) {
    logger.error('Failed to save settings:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to save settings',
    });
  }
});

// PUT /api/settings/:key - Update a specific setting
router.put('/:key', (req: Request, res: Response) => {
  try {
    const { key } = req.params;
    const { value } = req.body;

    if (typeof value !== 'string') {
      res.status(400).json({
        success: false,
        error: 'Value must be a string',
      });
      return;
    }

    if (isEnvManaged(key as string)) {
      res.status(409).json({
        success: false,
        error: `${key} is set by an environment variable`,
      });
      return;
    }

    const setting = settingsRepo.set({ key: key as string, value });
    res.json({
      success: true,
      data: setting,
      message: 'Setting updated successfully',
    });
  } catch (error) {
    logger.error('Failed to update setting:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to update setting',
    });
  }
});

// DELETE /api/settings/:key - Delete a setting
router.delete('/:key', (req: Request, res: Response) => {
  try {
    const { key } = req.params;
    const deleted = settingsRepo.delete(key as string);

    if (!deleted) {
      res.status(404).json({
        success: false,
        error: `Setting not found: ${key}`,
      });
      return;
    }

    res.json({
      success: true,
      message: 'Setting deleted successfully',
    });
  } catch (error) {
    logger.error('Failed to delete setting:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to delete setting',
    });
  }
});

// POST /api/settings/bulk - Set multiple settings at once
const BulkSettingsSchema = z.array(SettingInputSchema);

router.post('/bulk', validateBody(BulkSettingsSchema), (req: Request, res: Response) => {
  try {
    const settings = settingsRepo.setMultiple(req.body);
    res.status(201).json({
      success: true,
      data: settings,
      message: `${settings.length} setting(s) saved successfully`,
    });
  } catch (error) {
    logger.error('Failed to save bulk settings:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to save settings',
    });
  }
});

// GET /api/settings/prefix/:prefix - Get settings by prefix
router.get('/prefix/:prefix', (req: Request, res: Response) => {
  try {
    const { prefix } = req.params;
    const settings = settingsRepo.getStartingWith(prefix as string);
    res.json({
      success: true,
      data: settings,
    });
  } catch (error) {
    logger.error('Failed to get settings by prefix:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve settings',
    });
  }
});

// POST /api/settings/test/discord - Test Discord webhook notification
// NOTE: This route must come BEFORE /test/:service to avoid being matched as a service
router.post('/test/discord', async (req: Request, res: Response) => {
  try {
    // Accept webhook URL from request body or fall back to stored setting
    const webhookUrl = req.body.webhookUrl || settingsRepo.getValue('notifications_discordWebhook');

    if (!webhookUrl) {
      res.status(400).json({
        success: false,
        error: 'No Discord webhook URL configured',
        details: 'Enter a webhook URL in the Notifications section and try again.',
      });
      return;
    }

    // Validate Discord webhook URL format
    if (!webhookUrl.startsWith('https://discord.com/api/webhooks/')) {
      res.status(400).json({
        success: false,
        error: 'Invalid Discord webhook URL format',
        details: 'URL must start with https://discord.com/api/webhooks/',
      });
      return;
    }

    // Send test notification with a rich embed (localized to the configured language)
    const notificationService = getNotificationService();
    const t = getFixedT(settingsRepo.getValue('notifications_language') || 'en');
    const result = await notificationService.sendDiscord(webhookUrl, {
      username: t('common.brand'),
      avatar_url: 'https://raw.githubusercontent.com/helliott20/prunerr/main/assets/icon.png',
      embeds: [{
        title: t('test.title'),
        description: t('test.desc'),
        color: 0x2ecc71,
        fields: [
          { name: t('test.scanAlertsName'), value: t('test.scanAlertsValue'), inline: true },
          { name: t('test.queueAlertsName'), value: t('test.queueAlertsValue'), inline: true },
          { name: t('test.deletionAlertsName'), value: t('test.deletionAlertsValue'), inline: true },
        ],
        footer: { text: t('common.brand') },
        timestamp: new Date().toISOString(),
      }],
    });

    if (result) {
      res.json({
        success: true,
        message: 'Test notification sent successfully! Check your Discord channel.',
      });
    } else {
      res.status(400).json({
        success: false,
        error: 'Failed to send notification',
        details: 'The webhook URL may be invalid or Discord may be unreachable.',
      });
    }
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';
    logger.error('Failed to send test Discord notification:', error);
    res.status(500).json({
      success: false,
      error: `Failed to send notification: ${errorMessage}`,
    });
  }
});

// POST /api/settings/test/:service - Test connection to a service
router.post('/test/:service', async (req: Request, res: Response) => {
  const service = req.params['service'] as string;
  const validServices = ['plex', 'jellyfin', 'emby', 'tautulli', 'tracearr', 'sonarr', 'radarr', 'overseerr', 'unraid'];

  if (!validServices.includes(service)) {
    res.status(400).json({
      success: false,
      error: `Invalid service: ${service}. Valid services are: ${validServices.join(', ')}`,
    });
    return;
  }

  try {
    // Get service configuration from settings or request body
    const url = req.body.url || settingsRepo.getValue(`${service}_url`);
    const apiKey = req.body.apiKey || req.body.api_key || settingsRepo.getValue(`${service}_api_key`);
    const token = req.body.token || settingsRepo.getValue(`${service}_token`);

    if (!url) {
      res.status(400).json({
        success: false,
        error: `No URL configured for ${service}`,
      });
      return;
    }

    let testResult = false;
    let version: string | undefined;
    let serverName: string | undefined;

    switch (service) {
      case 'plex': {
        if (!token) {
          res.status(400).json({
            success: false,
            error: 'No token configured for Plex',
            details: 'Please enter your Plex token. You can find it at https://www.plex.tv/claim/ or in your Plex settings.',
          });
          return;
        }
        try {
          const plexService = new PlexService(url, token);
          testResult = await plexService.testConnection();
          if (!testResult) {
            res.status(400).json({
              success: false,
              error: `Cannot connect to Plex at ${url}`,
              details: 'Check that: 1) Plex is running, 2) The URL is correct (e.g., http://192.168.1.x:32400), 3) The token is correct',
            });
            return;
          }
        } catch (err) {
          const errorMsg = err instanceof Error ? err.message : 'Unknown error';
          res.status(400).json({
            success: false,
            error: `Plex connection error: ${errorMsg}`,
            details: `URL: ${url} - Make sure Plex is accessible from this server`,
          });
          return;
        }
        break;
      }
      case 'jellyfin':
      case 'emby': {
        const label = service === 'emby' ? 'Emby' : 'Jellyfin';
        const defaultPort = service === 'emby' ? '8096' : '8096';
        // The Settings UI posts the key as `apiKey`; fall back to the stored
        // value, which lives under the shared `jellyfin_*` namespace for both.
        const jellyfinKey = apiKey || settingsRepo.getValue('jellyfin_apiKey');

        if (!jellyfinKey) {
          res.status(400).json({
            success: false,
            error: `No API key configured for ${label}`,
            details: `Please enter your ${label} API key. You can create one in ${label} under Dashboard > Advanced > API Keys.`,
          });
          return;
        }
        try {
          const jellyfinService = new JellyfinService(url, jellyfinKey, service);
          testResult = await jellyfinService.testConnection();
          if (!testResult) {
            res.status(400).json({
              success: false,
              error: `Cannot connect to ${label} at ${url}`,
              details: `Check that: 1) ${label} is running, 2) The URL is correct (e.g., http://192.168.1.x:${defaultPort}), 3) The API key is correct`,
            });
            return;
          }
        } catch (err) {
          const errorMsg = err instanceof Error ? err.message : 'Unknown error';
          res.status(400).json({
            success: false,
            error: `${label} connection error: ${errorMsg}`,
            details: `URL: ${url} - Make sure ${label} is accessible from this server`,
          });
          return;
        }
        break;
      }
      case 'tautulli': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API key configured for Tautulli',
            details: 'Please enter your Tautulli API key. You can find it in Tautulli Settings > Web Interface > API Key',
          });
          return;
        }
        try {
          const tautulliService = new TautulliService(url, apiKey);
          testResult = await tautulliService.testConnection();
          if (!testResult) {
            res.status(400).json({
              success: false,
              error: `Cannot connect to Tautulli at ${url}`,
              details: 'Check that: 1) Tautulli is running, 2) The URL is correct (e.g., http://192.168.1.x:8181), 3) The API key is correct',
            });
            return;
          }
        } catch (err) {
          const errorMsg = err instanceof Error ? err.message : 'Unknown error';
          res.status(400).json({
            success: false,
            error: `Tautulli connection error: ${errorMsg}`,
            details: `URL: ${url} - Make sure Tautulli is accessible from this server`,
          });
          return;
        }
        break;
      }
      case 'tracearr': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API token configured for Tracearr',
            details: 'Please enter your Tracearr API token. You can find it in Tracearr Settings > API',
          });
          return;
        }
        try {
          const tracearrService = new TracearrService(url, apiKey);
          testResult = await tracearrService.testConnection();
          if (!testResult) {
            res.status(400).json({
              success: false,
              error: `Cannot connect to Tracearr at ${url}`,
              details: 'Check that: 1) Tracearr is running, 2) The URL is correct (e.g., http://192.168.1.x:3004), 3) The API token is correct',
            });
            return;
          }
        } catch (err) {
          const errorMsg = err instanceof Error ? err.message : 'Unknown error';
          res.status(400).json({
            success: false,
            error: `Tracearr connection error: ${errorMsg}`,
            details: `URL: ${url} - Make sure Tracearr is accessible from this server`,
          });
          return;
        }
        break;
      }
      case 'sonarr': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API key configured for Sonarr',
          });
          return;
        }
        const sonarrService = new SonarrService(url, apiKey);
        testResult = await sonarrService.testConnection();
        break;
      }
      case 'radarr': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API key configured for Radarr',
          });
          return;
        }
        const radarrService = new RadarrService(url, apiKey);
        testResult = await radarrService.testConnection();
        break;
      }
      case 'overseerr': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API key configured for Overseerr',
          });
          return;
        }
        const overseerrService = new OverseerrService(url, apiKey);
        testResult = await overseerrService.testConnection();
        break;
      }
      case 'unraid': {
        if (!apiKey) {
          res.status(400).json({
            success: false,
            error: 'No API key configured for Unraid',
            details: 'Please enter your Unraid API key. You can generate one in Unraid Settings > Management Access > API Keys',
          });
          return;
        }
        try {
          const unraidService = new UnraidService(url, apiKey);
          testResult = await unraidService.testConnection();
          if (!testResult) {
            res.status(400).json({
              success: false,
              error: `Cannot connect to Unraid at ${url}`,
              details: 'Check that: 1) Unraid is running, 2) The URL is correct (e.g., https://tower.local or http://192.168.1.x), 3) The API key is correct',
            });
            return;
          }
        } catch (err) {
          const errorMsg = err instanceof Error ? err.message : 'Unknown error';
          res.status(400).json({
            success: false,
            error: `Unraid connection error: ${errorMsg}`,
            details: `URL: ${url} - Make sure Unraid is accessible from this server and the GraphQL API is enabled`,
          });
          return;
        }
        break;
      }
    }

    if (testResult) {
      res.json({
        success: true,
        message: `Successfully connected to ${service}`,
        data: {
          service,
          connected: true,
          version,
          serverName,
        },
      });
    } else {
      res.status(400).json({
        success: false,
        error: `Failed to connect to ${service}`,
        data: {
          service,
          connected: false,
        },
      });
    }
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';
    logger.error(`Failed to test ${service} connection:`, error);
    res.status(500).json({
      success: false,
      error: `Failed to test ${service} connection: ${errorMessage}`,
      data: {
        service,
        connected: false,
      },
    });
  }
});

export default router;