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(schema: z.ZodSchema) { 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> = {}; const notifications: Record = {}; const schedule: Record = {}; const plexSync: Record = {}; const display: Record = {}; const watchHistory: Record = {}; const diskPressure: Record = {}; // 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), }; 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; 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; 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;