feat: add Ombi requests tab and webhook panel integration

- Add Ombi requests tab UI with movie/TV request display
- Add showAll parameter support for Ombi requests (API and SSE)
- Add Ombi webhook panel with enable/test functionality
- Add Ombi webhook status endpoint with metrics
- Add Ombi webhook test endpoint
- Change GET /api/ombi/requests to use OmbiRetriever instead of cache
- Add Ombi webhook state and API functions to frontend
- Update SSE payload to include Ombi baseUrl and requests
This commit is contained in:
2026-05-21 20:59:06 +01:00
parent 884fb5285f
commit 1dccda529a
13 changed files with 1186 additions and 54 deletions
+196 -7
View File
@@ -2,7 +2,7 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const { logToFile } = require('../utils/logger');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
@@ -27,7 +27,9 @@ const VALID_EVENT_TYPES = new Set([
'DownloadFolderImported', 'ImportFailed',
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored'
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
// Ombi notification types
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
]);
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
@@ -73,6 +75,15 @@ const HISTORY_EVENTS = new Set([
'EpisodeFileRenamedBySeries'
]);
// Ombi event types — all Ombi events refresh the requests cache
const OMBI_EVENTS = new Set([
'RequestAvailable',
'RequestApproved',
'RequestDeclined',
'RequestPending',
'RequestProcessing'
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* @param {Object} req - Express request object
@@ -107,19 +118,20 @@ function validateWebhookSecret(req) {
*
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
*
* @param {string} serviceType - 'sonarr' or 'radarr'
* @param {string} eventType - the eventType from the *arr webhook payload
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
* @param {string} eventType - the eventType from the webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
const affectsQueue = QUEUE_EVENTS.has(eventType);
const affectsHistory = HISTORY_EVENTS.has(eventType);
const affectsOmbi = OMBI_EVENTS.has(eventType);
if (!affectsQueue && !affectsHistory) {
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
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}`);
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`);
// Ensure retrievers are initialized (idempotent)
await arrRetrieverRegistry.initialize();
@@ -184,6 +196,14 @@ async function processWebhookEvent(serviceType, eventType) {
}, CACHE_TTL);
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
}
} else if (serviceType === 'ombi') {
const ombiInstances = getOmbiInstances();
if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
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.
@@ -512,4 +532,173 @@ router.post('/radarr', webhookLimiter, (req, res) => {
}
});
/**
* @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 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
* - 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`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Application Token: OMBI_API_KEY
* security: []
* 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
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
const eventType = notificationType || req.body.eventType;
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';
// Use requestId + eventType + current time as replay key
const eventDate = req.body.requestedDate || new Date().toISOString();
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
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: ${requestedUser}`);
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).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;