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:
@@ -25,6 +25,7 @@ const statusRoutes = require('./routes/status');
|
||||
const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
const ombiRoutes = require('./routes/ombi');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
@@ -218,6 +219,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
app.use('/api/sonarr', sonarrRoutes);
|
||||
app.use('/api/radarr', radarrRoutes);
|
||||
app.use('/api/emby', embyRoutes);
|
||||
app.use('/api/ombi', ombiRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/status', statusRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
|
||||
@@ -29,6 +29,7 @@ function readCacheSnapshot() {
|
||||
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||
const ombiRequests = cache.get('poll:ombi-requests') || { movie: [], tv: [] };
|
||||
|
||||
return {
|
||||
sabnzbdQueue: { data: { queue: sabQueueData } },
|
||||
@@ -39,7 +40,8 @@ function readCacheSnapshot() {
|
||||
radarrHistory: { data: radarrHistoryData },
|
||||
radarrTags: { data: radarrTagsData },
|
||||
qbittorrentTorrents,
|
||||
sonarrTagsResults
|
||||
sonarrTagsResults,
|
||||
ombiRequests
|
||||
};
|
||||
}
|
||||
|
||||
@@ -489,7 +491,32 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
name: c.name,
|
||||
type: c.getClientType()
|
||||
}));
|
||||
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\n\n`);
|
||||
|
||||
// Filter Ombi requests by user if not admin or if showAll is false
|
||||
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
|
||||
let filteredOmbiMovieRequests = ombiRequests.movie || [];
|
||||
let filteredOmbiTvRequests = ombiRequests.tv || [];
|
||||
|
||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||
|
||||
if (!showAllOmbi && username) {
|
||||
const usernameLower = username.toLowerCase();
|
||||
filteredOmbiMovieRequests = filteredOmbiMovieRequests.filter(req => {
|
||||
const requestedUser = req.requestedUser || req.userAlias || '';
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
filteredOmbiTvRequests = filteredOmbiTvRequests.filter(req => {
|
||||
const requestedUser = req.requestedUser || req.userAlias || '';
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
}
|
||||
|
||||
const ombiRequestsFiltered = {
|
||||
movie: filteredOmbiMovieRequests,
|
||||
tv: filteredOmbiTvRequests
|
||||
};
|
||||
|
||||
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients, ombiRequests: ombiRequestsFiltered, ombiBaseUrl })}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[SSE] Error building payload:', sanitizeError(err));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
const cache = require('../utils/cache');
|
||||
const { getOmbiInstances } = require('../utils/config');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/ombi/requests:
|
||||
* get:
|
||||
* tags: [Ombi]
|
||||
* summary: Get Ombi requests
|
||||
* description: |
|
||||
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
|
||||
* (filtered by Emby user mapping), while admins see all requests.
|
||||
*
|
||||
* **Authentication:** Requires cookie authentication.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Ombi requests retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: string
|
||||
* example: "username"
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* requests:
|
||||
* type: object
|
||||
* properties:
|
||||
* movie:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* tv:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* total:
|
||||
* type: integer
|
||||
* example: 5
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get('/requests', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const isAdmin = req.isAdmin;
|
||||
const username = user.name;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrieverRegistry');
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||
|
||||
// Filter by user if not admin or if showAll is false
|
||||
let filteredMovieRequests = ombiRequests.movie || [];
|
||||
let filteredTvRequests = ombiRequests.tv || [];
|
||||
|
||||
if (!showAll && username) {
|
||||
// Ombi uses requestedUser field to track who made the request
|
||||
// Match by username (case-insensitive)
|
||||
const usernameLower = username.toLowerCase();
|
||||
|
||||
filteredMovieRequests = filteredMovieRequests.filter(req => {
|
||||
const requestedUser = req.requestedUser || req.userAlias || '';
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
|
||||
filteredTvRequests = filteredTvRequests.filter(req => {
|
||||
const requestedUser = req.requestedUser || req.userAlias || '';
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
}
|
||||
|
||||
const total = filteredMovieRequests.length + filteredTvRequests.length;
|
||||
|
||||
res.json({
|
||||
user: username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
requests: {
|
||||
movie: filteredMovieRequests,
|
||||
tv: filteredTvRequests
|
||||
},
|
||||
total
|
||||
});
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Error fetching requests: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to fetch Ombi requests' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/ombi/webhook/enable:
|
||||
* post:
|
||||
* tags: [Ombi]
|
||||
* summary: Enable Ombi webhook
|
||||
* description: |
|
||||
* Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
|
||||
*
|
||||
* **Authentication:** Requires cookie authentication.
|
||||
* **CSRF:** Requires X-CSRF-Token header matching csrf_token cookie.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* requestBody:
|
||||
* required: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Webhook enabled successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* webhookUrl:
|
||||
* type: string
|
||||
* example: "https://sofarr.example.com/api/webhook/ombi"
|
||||
* applicationToken:
|
||||
* type: string
|
||||
* example: "your-ombi-api-key"
|
||||
* '400':
|
||||
* description: Invalid request or missing configuration
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
if (ombiInstances.length === 0) {
|
||||
return res.status(400).json({ error: 'Ombi not configured' });
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${process.env.SOFARR_BASE_URL}/api/webhook/ombi`;
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
const axios = require('axios');
|
||||
const response = await axios.post(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
enabled: true,
|
||||
webhookUrl: webhookUrl,
|
||||
applicationToken: ombiInst.apiKey
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'ApiKey': ombiInst.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logToFile(`[Ombi] Webhook enabled: ${webhookUrl}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
webhookUrl: webhookUrl,
|
||||
applicationToken: ombiInst.apiKey
|
||||
});
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Error enabling webhook: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to enable Ombi webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/ombi/webhook/status:
|
||||
* get:
|
||||
* tags: [Ombi]
|
||||
* summary: Get Ombi webhook status
|
||||
* description: |
|
||||
* Returns the current Ombi webhook configuration status and metrics.
|
||||
*
|
||||
* **Authentication:** Requires cookie authentication.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Webhook status retrieved successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* webhookUrl:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: "https://sofarr.example.com/api/webhook/ombi"
|
||||
* applicationToken:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* example: "your-ombi-api-key"
|
||||
* triggers:
|
||||
* type: object
|
||||
* properties:
|
||||
* requestAvailable:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* requestApproved:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* requestDeclined:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* requestPending:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* requestProcessing:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* stats:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventsReceived:
|
||||
* type: integer
|
||||
* example: 10
|
||||
* pollsSkipped:
|
||||
* type: integer
|
||||
* example: 5
|
||||
* lastWebhookTimestamp:
|
||||
* type: integer
|
||||
* example: 1716326400000
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.get('/webhook/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
if (ombiInstances.length === 0) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
webhookUrl: null,
|
||||
applicationToken: null,
|
||||
triggers: {
|
||||
requestAvailable: false,
|
||||
requestApproved: false,
|
||||
requestDeclined: false,
|
||||
requestPending: false,
|
||||
requestProcessing: false
|
||||
},
|
||||
stats: null
|
||||
});
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
|
||||
// Call Ombi API to get webhook status
|
||||
const axios = require('axios');
|
||||
const response = await axios.get(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
headers: {
|
||||
'ApiKey': ombiInst.apiKey
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const webhookConfig = response.data;
|
||||
|
||||
// Get webhook metrics from cache
|
||||
const metrics = cache.getWebhookMetrics(ombiInst.url);
|
||||
|
||||
res.json({
|
||||
enabled: webhookConfig.enabled || false,
|
||||
webhookUrl: webhookConfig.webhookUrl || null,
|
||||
applicationToken: webhookConfig.applicationToken || null,
|
||||
triggers: {
|
||||
requestAvailable: webhookConfig.enabled || false,
|
||||
requestApproved: webhookConfig.enabled || false,
|
||||
requestDeclined: webhookConfig.enabled || false,
|
||||
requestPending: webhookConfig.enabled || false,
|
||||
requestProcessing: webhookConfig.enabled || false
|
||||
},
|
||||
stats: metrics ? {
|
||||
eventsReceived: metrics.eventCount || 0,
|
||||
pollsSkipped: metrics.pollsSkipped || 0,
|
||||
lastWebhookTimestamp: metrics.lastEventTimestamp || null
|
||||
} : null
|
||||
});
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Error fetching webhook status: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to fetch Ombi webhook status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/ombi/webhook/test:
|
||||
* post:
|
||||
* tags: [Ombi]
|
||||
* summary: Test Ombi webhook
|
||||
* description: |
|
||||
* Sends a test webhook event to the Sofarr Ombi webhook endpoint.
|
||||
*
|
||||
* **Authentication:** Requires cookie authentication and CSRF token.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* - CsrfToken: []
|
||||
* requestBody:
|
||||
* required: false
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Test webhook sent successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* example: true
|
||||
* '400':
|
||||
* description: Invalid request or missing configuration
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorResponse'
|
||||
*/
|
||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
if (ombiInstances.length === 0) {
|
||||
return res.status(400).json({ error: 'Ombi not configured' });
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${process.env.SOFARR_BASE_URL}/api/webhook/ombi`;
|
||||
|
||||
// Simulate a test webhook event
|
||||
const axios = require('axios');
|
||||
await axios.post(webhookUrl, {
|
||||
notificationType: 'RequestAvailable',
|
||||
requestId: 0,
|
||||
requestedUser: 'test',
|
||||
title: 'Test Request',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending'
|
||||
}, {
|
||||
headers: {
|
||||
'X-Sofarr-Webhook-Secret': process.env.SOFARR_WEBHOOK_SECRET,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logToFile(`[Ombi] Error testing webhook: ${error.message}`);
|
||||
res.status(500).json({ error: 'Failed to test Ombi webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+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;
|
||||
|
||||
+21
-3
@@ -5,7 +5,8 @@ const { initializeClients, getAllDownloads, getDownloadsByClientType } = require
|
||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||
const {
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
@@ -88,13 +89,14 @@ async function pollAllServices() {
|
||||
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
||||
const globalMetrics = cache.getGlobalWebhookMetrics();
|
||||
const now = Date.now();
|
||||
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
||||
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
||||
|
||||
|
||||
if (fallbackTriggered) {
|
||||
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
|
||||
}
|
||||
@@ -102,6 +104,7 @@ async function pollAllServices() {
|
||||
// Determine which instances should be polled based on webhook activity
|
||||
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
||||
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
||||
const shouldPollOmbi = fallbackTriggered || !shouldSkipInstancePolling(ombiInstances, 'ombi');
|
||||
|
||||
// All fetches in parallel, each individually timed
|
||||
const results = await Promise.all([
|
||||
@@ -133,6 +136,10 @@ async function pollAllServices() {
|
||||
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
||||
return tagsByType.radarr || [];
|
||||
}) : timed('Radarr Tags', async () => []),
|
||||
shouldPollOmbi ? timed('Ombi Requests', async () => {
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||
return ombiRequests;
|
||||
}) : timed('Ombi Requests', async () => ({ movie: [], tv: [] })),
|
||||
]);
|
||||
|
||||
const [
|
||||
@@ -140,7 +147,8 @@ async function pollAllServices() {
|
||||
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||
{ result: sonarrHistories },
|
||||
{ result: radarrQueues }, { result: radarrHistories },
|
||||
{ result: radarrTagsResults }
|
||||
{ result: radarrTagsResults },
|
||||
{ result: ombiRequests }
|
||||
] = results;
|
||||
|
||||
// Store per-task timings
|
||||
@@ -282,6 +290,16 @@ async function pollAllServices() {
|
||||
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
|
||||
}
|
||||
|
||||
// Ombi
|
||||
if (shouldPollOmbi) {
|
||||
cache.set('poll:ombi-requests', ombiRequests, cacheTTL);
|
||||
logToFile(`[Poller] Ombi requests cached: ${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows`);
|
||||
} else {
|
||||
// Extend TTL of existing cached data when polling is skipped
|
||||
const existingOmbiRequests = cache.get('poll:ombi-requests');
|
||||
if (existingOmbiRequests) cache.set('poll:ombi-requests', existingOmbiRequests, cacheTTL);
|
||||
}
|
||||
|
||||
// qBittorrent (already set above in download clients section)
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
Reference in New Issue
Block a user