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:
+196
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user