// Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const rateLimit = require('express-rate-limit'); const { logToFile } = require('../utils/logger'); const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config'); const cache = require('../utils/cache'); const arrRetrieverRegistry = require('../utils/arrRetrievers'); const { buildArrQueueCache } = require('../utils/arrQueueHelpers'); const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller'); const { extractRequestedUser } = require('../utils/ombiHelpers'); const requireAuth = require('../middleware/requireAuth'); const router = express.Router(); /** * @openapi * /api/webhook/config: * get: * tags: [Webhook] * summary: Get webhook configuration status * description: | * Returns whether the required webhook configuration (SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET) * is properly configured. Used by the webhooks panel to determine if webhooks can be enabled. * * **Authentication:** Requires valid `emby_user` cookie. * security: * - CookieAuth: [] * responses: * '200': * description: Webhook configuration status * content: * application/json: * schema: * type: object * properties: * valid: * type: boolean * description: true if both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured * example: true * missing: * type: array * items: * type: string * description: List of missing configuration items * example: [] * '401': * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' */ router.get('/config', requireAuth, (req, res) => { const sofarrBaseUrl = getSofarrBaseUrl(); const webhookSecret = getWebhookSecret(); const missing = []; if (!sofarrBaseUrl) { missing.push('SOFARR_BASE_URL'); } if (!webhookSecret) { missing.push('SOFARR_WEBHOOK_SECRET'); } res.json({ valid: missing.length === 0, missing }); }); // Dedicated rate limiter for webhook endpoints — stricter than the global API limiter. // Sonarr/Radarr send at most one event per action; 60/min per IP is generous. // In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited. const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many webhook requests' } }); // Valid *arr eventType strings — used for strict input validation. const VALID_EVENT_TYPES = new Set([ 'Test', 'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired', 'DownloadFolderImported', 'ImportFailed', 'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries', 'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete', 'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored', // Ombi notification types 'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing' ]); // Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys. // *arr sends a `date` field on every event; we use it as the replay key component. // TTL = 5 minutes; an event replayed after that window is considered fresh. const REPLAY_WINDOW_MS = 5 * 60 * 1000; const recentEvents = new Map(); function pruneReplayCache() { const cutoff = Date.now() - REPLAY_WINDOW_MS; for (const [key, ts] of recentEvents) { if (ts < cutoff) recentEvents.delete(key); } } // Prune the replay cache once per minute setInterval(pruneReplayCache, 60 * 1000).unref(); function isReplay(eventType, instanceName, eventDate, contentId) { if (!eventDate) return false; // Content-aware replay key: incorporates downloadId / series.id / movie.id when // available so that distinct events sharing the same `date` (e.g. multiple // Grab events for episodes in a season pack fired in the same second) do not // falsely collide. Falls back to the prior shape when contentId is absent // (e.g. Test events) so existing behaviour is preserved. const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`; if (recentEvents.has(key)) return true; recentEvents.set(key, Date.now()); return false; } // Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; // Event classification — determines which cache keys to refresh const QUEUE_EVENTS = new Set([ 'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired' ]); const HISTORY_EVENTS = new Set([ 'DownloadFolderImported', 'ImportFailed', 'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries' ]); // Ombi event types — all Ombi events refresh the requests cache const OMBI_EVENTS = new Set([ 'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing' ]); /** * Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter * @param {Object} req - Express request object * @returns {boolean} True if secret is valid, false otherwise */ function validateWebhookSecret(req) { const expectedSecret = getWebhookSecret(); const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret; if (!expectedSecret) { logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook'); return false; } if (!providedSecret) { logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter'); return false; } if (providedSecret !== expectedSecret) { logToFile('[Webhook] WARNING: Invalid webhook secret provided'); return false; } return true; } /** * Process a webhook event by refreshing the affected cache and broadcasting SSE. * This is a fire-and-forget background task — callers must respond to the webhook * sender before awaiting this function. * * Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast. * * @param {string} serviceType - 'sonarr', 'radarr', or 'ombi' * @param {string} eventType - the eventType from the webhook payload */ async function processWebhookEvent(serviceType, eventType, payload = null) { const affectsQueue = QUEUE_EVENTS.has(eventType); const affectsHistory = HISTORY_EVENTS.has(eventType); const affectsOmbi = OMBI_EVENTS.has(eventType); if (!affectsQueue && !affectsHistory && !affectsOmbi) { logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`); return; } logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`); // Ensure retrievers are initialized (idempotent) await arrRetrieverRegistry.initialize(); if (serviceType === 'sonarr') { const sonarrInstances = getSonarrInstances(); if (affectsQueue) { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); const sonarrQueues = queuesByType.sonarr || []; cache.set('poll:sonarr-queue', { records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series') }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`); } if (affectsHistory) { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); const sonarrHistories = historyByType.sonarr || []; cache.set('poll:sonarr-history', { records: sonarrHistories.flatMap(h => h.data.records || []) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`); } } else if (serviceType === 'radarr') { const radarrInstances = getRadarrInstances(); if (affectsQueue) { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); const radarrQueues = queuesByType.radarr || []; cache.set('poll:radarr-queue', { records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie') }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`); } if (affectsHistory) { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); const radarrHistories = historyByType.radarr || []; cache.set('poll:radarr-history', { records: radarrHistories.flatMap(h => h.data.records || []) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`); } } else if (serviceType === 'ombi') { const ombiInstances = getOmbiInstances(); if (affectsOmbi) { const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10); const initialDelay = !isNaN(delayMs) ? delayMs : 2000; logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`); await new Promise(r => setTimeout(r, initialDelay)); const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null; const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null; let ombiRequests = { movie: [], tv: [] }; let foundAndValid = false; const maxRetries = 3; const retryDelayMs = 1500; for (let attempt = 1; attempt <= maxRetries; attempt++) { if (attempt > 1) { logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`); await new Promise(r => setTimeout(r, retryDelayMs)); } ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); if (!requestId) { // If no requestId was provided in payload, we can't search specifically, so just accept the fetch foundAndValid = true; break; } // Search in movie or tv lists const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []); // Also check both if mediaType not specified const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])]; const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId)); if (targetReq) { const user = extractRequestedUser(targetReq); if (user) { logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`); foundAndValid = true; break; } else { logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`); } } else { logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`); } } if (!foundAndValid && requestId) { logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`); // Try to log the raw target request if we found one ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])]; const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId)); if (targetReq) { logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`); } else { logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`); } } cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`); } } // Broadcast to all SSE subscribers using the same mechanism poller.js uses. // pollAllServices() refreshes all data, updates every cache key, and then // iterates pollSubscribers to push fresh payloads to every open SSE connection. // If a poll is already in progress this call is a no-op, but the cache keys // above were already updated so the next broadcast (or dashboard request) // will see fresh data. logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()'); await pollAllServices(); } /** * Validate and sanitize the incoming webhook payload. * Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }. */ function validatePayload(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) { return { valid: false, reason: 'Payload must be a JSON object' }; } const { eventType, instanceName } = body; if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) { return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' }; } if (!VALID_EVENT_TYPES.has(eventType)) { return { valid: false, reason: `Unknown eventType: ${eventType}` }; } if (instanceName !== undefined && typeof instanceName !== 'string') { return { valid: false, reason: 'instanceName must be a string if provided' }; } const eventDate = body.date || null; return { valid: true, eventType, instanceName: instanceName || null, eventDate }; } /** * @openapi * /api/webhook/sonarr: * post: * tags: [Webhook] * summary: Sonarr webhook receiver * description: | * Receives webhook events from Sonarr instances. Validates the secret, logs the event, * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`. * No cookie authentication required (webhooks come from Sonarr, not browsers). * * **Rate Limiting:** 60 requests per minute per IP. * * **Validation:** * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter * - Payload validation (must be JSON object with eventType, instanceName, date) * - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.) * - Replay protection: rejects duplicate events within 5-minute window * * **Event Classification:** * - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired): * Refreshes `poll:sonarr-queue` cache * - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, EpisodeFileRenamed, etc.): * Refreshes `poll:sonarr-history` cache * - Informational events (Test, Rename, Health, etc.): * Logged but no cache refresh * * **Processing Flow:** * 1. Validate secret → 401 if invalid * 2. Validate payload → 400 if invalid * 3. Check replay cache → 200 with duplicate=true if replay * 4. Update webhook metrics (enables smart polling skip) * 5. Return 200 immediately (don't wait for background processing) * 6. Background: fetch fresh data from Sonarr, update cache, broadcast SSE * * **x-integration-notes:** Configure Sonarr webhook: * - URL: `{SOFARR_BASE_URL}/api/webhook/sonarr` * - Method: POST * - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}` * - Events: onGrab, onDownload, onUpgrade, onImport * security: [] * parameters: * - name: secret * in: query * required: false * schema: * type: string * description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header) * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/WebhookPayload' * example: * eventType: "Grab" * instanceName: "Main Sonarr" * date: "2026-05-21T10:00:00.000Z" * responses: * '200': * description: Event received and accepted * content: * application/json: * schema: * type: object * properties: * received: * type: boolean * example: true * duplicate: * type: boolean * description: True if this event was already processed (replay protection) * example: false * examples: * newEvent: * received: true * duplicate: false * duplicateEvent: * received: true * duplicate: true * '401': * description: Invalid or missing webhook secret * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Unauthorized" * '400': * description: Invalid payload or unknown event type * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * examples: * invalidPayload: * error: "Payload must be a JSON object" * unknownEventType: * error: "Unknown eventType: InvalidEvent" * x-code-samples: * - lang: curl * label: cURL (from Sonarr) * source: | * curl -X POST http://sofarr:3001/api/webhook/sonarr \ * -H "Content-Type: application/json" \ * -H "X-Sofarr-Webhook-Secret: your-secret-here" \ * -d '{"eventType":"Grab","instanceName":"Main Sonarr","date":"2026-05-21T10:00:00.000Z"}' */ router.post('/sonarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } const validation = validatePayload(req.body); if (!validation.valid) { logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`); return res.status(400).json({ error: validation.reason }); } const { eventType, instanceName, eventDate } = validation; const sonarrInstances = getSonarrInstances(); const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName); const inst = matchedInst || sonarrInstances[0]; if (!matchedInst && instanceName) { logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`); } const resolvedInstanceName = inst ? inst.name : instanceName; // Content-aware replay key components (Issue #62) const contentId = req.body.downloadId || req.body.series?.id || null; // Skip replay protection for Test events if (eventType === "Test") { logToFile(`[Webhook] Sonarr Test event received — skipping replay protection`); } else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); return res.status(200).json({ received: true, duplicate: true }); } try { logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`); logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization if (inst) { cache.updateWebhookMetrics(inst.url); logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.url})`); } // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('sonarr', eventType).catch(err => { logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`); }); res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Sonarr error: ${error.message}`); res.status(200).json({ received: true }); } }); /** * @openapi * /api/webhook/radarr: * post: * tags: [Webhook] * summary: Radarr webhook receiver * description: | * Receives webhook events from Radarr instances. Validates the secret, logs the event, * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`. * No cookie authentication required (webhooks come from Radarr, not browsers). * * **Rate Limiting:** 60 requests per minute per IP. * * **Validation:** * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter * - Payload validation (must be JSON object with eventType, instanceName, date) * - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.) * - Replay protection: rejects duplicate events within 5-minute window * * **Event Classification:** * - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired): * Refreshes `poll:radarr-queue` cache * - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, MovieFileRenamed, etc.): * Refreshes `poll:radarr-history` cache * - Informational events (Test, Rename, Health, etc.): * Logged but no cache refresh * * **Processing Flow:** * 1. Validate secret → 401 if invalid * 2. Validate payload → 400 if invalid * 3. Check replay cache → 200 with duplicate=true if replay * 4. Update webhook metrics (enables smart polling skip) * 5. Return 200 immediately (don't wait for background processing) * 6. Background: fetch fresh data from Radarr, update cache, broadcast SSE * * **x-integration-notes:** Configure Radarr webhook: * - URL: `{SOFARR_BASE_URL}/api/webhook/radarr` * - Method: POST * - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}` * - Events: onGrab, onDownload, onUpgrade, onImport * security: [] * parameters: * - name: secret * in: query * required: false * schema: * type: string * description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header) * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/WebhookPayload' * example: * eventType: "Grab" * instanceName: "Main Radarr" * date: "2026-05-21T10:00:00.000Z" * responses: * '200': * description: Event received and accepted * content: * application/json: * schema: * type: object * properties: * received: * type: boolean * example: true * duplicate: * type: boolean * description: True if this event was already processed (replay protection) * example: false * examples: * newEvent: * received: true * duplicate: false * duplicateEvent: * received: true * duplicate: true * '401': * description: Invalid or missing webhook secret * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Unauthorized" * '400': * description: Invalid payload or unknown event type * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * examples: * invalidPayload: * error: "Payload must be a JSON object" * unknownEventType: * error: "Unknown eventType: InvalidEvent" * x-code-samples: * - lang: curl * label: cURL (from Radarr) * source: | * curl -X POST http://sofarr:3001/api/webhook/radarr \ * -H "Content-Type: application/json" \ * -H "X-Sofarr-Webhook-Secret: your-secret-here" \ * -d '{"eventType":"Grab","instanceName":"Main Radarr","date":"2026-05-21T10:00:00.000Z"}' */ router.post('/radarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } const validation = validatePayload(req.body); if (!validation.valid) { logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`); return res.status(400).json({ error: validation.reason }); } const { eventType, instanceName, eventDate } = validation; const radarrInstances = getRadarrInstances(); const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName); const inst = matchedInst || radarrInstances[0]; if (!matchedInst && instanceName) { logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`); } const resolvedInstanceName = inst ? inst.name : instanceName; // Content-aware replay key components (Issue #62) const contentId = req.body.downloadId || req.body.movie?.id || null; // Skip replay protection for Test events if (eventType === "Test") { logToFile(`[Webhook] Radarr Test event received — skipping replay protection`); } else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); return res.status(200).json({ received: true, duplicate: true }); } try { logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${resolvedInstanceName || 'unknown'}`); logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization if (inst) { cache.updateWebhookMetrics(inst.url); logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.url})`); } // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('radarr', eventType).catch(err => { logToFile(`[Webhook] Radarr background refresh error: ${err.message}`); }); res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Radarr error: ${error.message}`); res.status(200).json({ received: true }); } }); /** * @openapi * /api/webhook/ombi: * post: * tags: [Webhook] * summary: Ombi webhook receiver * description: | * Receives webhook events from Ombi instances. Validates the secret, logs the event, * refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing). * * **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`. * No cookie authentication required (webhooks come from Ombi, not browsers). * * **Rate Limiting:** 60 requests per minute per IP. * * **Validation:** * - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter * - Payload validation (must be JSON object with notificationType, requestId) * - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing) * - Replay protection: rejects duplicate events within 5-minute window * * **Event Classification:** * - OMBI_EVENTS (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing): * Refreshes `poll:ombi-requests` cache * * **Processing Flow:** * 1. Validate secret → 401 if invalid * 2. Validate payload → 400 if invalid * 3. Check replay cache → 200 with duplicate=true if replay * 4. Update webhook metrics (enables smart polling skip) * 5. Return 200 immediately (don't wait for background processing) * 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE * * **x-integration-notes:** Configure Ombi webhook: * - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}` * - Method: POST * - Application Token: OMBI_API_KEY * security: [] * parameters: * - name: secret * in: query * required: false * schema: * type: string * description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header) * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * notificationType: * type: string * example: "RequestAvailable" * requestId: * type: integer * example: 123 * requestedUser: * type: string * example: "username" * title: * type: string * example: "Movie Title" * type: * type: string * example: "Movie" * requestStatus: * type: string * example: "Available" * example: * notificationType: "RequestAvailable" * requestId: 123 * requestedUser: "username" * title: "Movie Title" * type: "Movie" * requestStatus: "Available" * responses: * '200': * description: Event received and accepted * content: * application/json: * schema: * type: object * properties: * received: * type: boolean * example: true * duplicate: * type: boolean * description: True if this event was already processed (replay protection) * example: false * examples: * newEvent: * received: true * duplicate: false * duplicateEvent: * received: true * duplicate: true * '401': * description: Invalid or missing webhook secret * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Unauthorized" * '400': * description: Invalid payload or unknown event type * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * examples: * invalidPayload: * error: "Payload must be a JSON object" * unknownEventType: * error: "Unknown notificationType: InvalidEvent" * x-code-samples: * - lang: curl * label: cURL (from Ombi) * source: | * curl -X POST http://sofarr:3001/api/webhook/ombi \ * -H "Content-Type: application/json" \ * -H "X-Sofarr-Webhook-Secret: your-secret-here" \ * -d '{"notificationType":"RequestAvailable","requestId":123,"requestedUser":"username","title":"Movie Title","type":"Movie","requestStatus":"Available"}' */ router.post('/ombi', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } // Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps const notificationType = req.body.notificationType || req.body.NotificationType; const requestId = req.body.requestId || req.body.RequestId; const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl; const eventType = notificationType || req.body.eventType || req.body.EventType; // Extract username from requestedUser (handles both object and string formats) const username = extractRequestedUser(req.body); if (!eventType || !OMBI_EVENTS.has(eventType)) { logToFile(`[Webhook] Ombi payload rejected: invalid or missing notificationType`); return res.status(400).json({ error: 'Invalid or missing notificationType' }); } // Use applicationUrl as instance identifier for replay protection const instanceName = applicationUrl || 'ombi'; const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString(); const contentId = requestId || null; if (isReplay(eventType, instanceName, eventDate, contentId)) { logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`); return res.status(200).json({ received: true, duplicate: true }); } try { logToFile(`[Webhook] Ombi event received - Type: ${eventType}, RequestId: ${requestId}, User: ${username}`); logToFile(`[Webhook] Ombi payload: ${JSON.stringify(req.body)}`); // Update webhook metrics for polling optimization const ombiInstances = getOmbiInstances(); const inst = ombiInstances[0]; // Use first Ombi instance if (inst) { cache.updateWebhookMetrics(inst.url); logToFile(`[Webhook] Updated metrics for Ombi instance: ${inst.name} (${inst.url})`); } // Background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('ombi', eventType, req.body).catch(err => { logToFile(`[Webhook] Ombi background refresh error: ${err.message}`); }); res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Ombi error: ${error.message}`); res.status(200).json({ received: true }); } }); module.exports = router;