// Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const { logToFile } = require('../utils/logger'); const cache = require('../utils/cache'); const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config'); const requireAuth = require('../middleware/requireAuth'); const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers'); const { applyRequestFilters } = require('../utils/ombiFilters'); 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. * * Supports server-side filtering by media type, request status, title search, * and sorting by requested date or title. * * **Authentication:** Requires cookie authentication. * security: * - cookieAuth: [] * parameters: * - name: type * in: query * schema: * type: array * items: * type: string * enum: [movie, tv, all] * default: [all] * description: Filter by media type. Omit or use `all` for both. * style: form * explode: true * - name: status * in: query * schema: * type: array * items: * type: string * enum: [pending, approved, available, denied] * description: Filter by request status. Omit for all statuses. * style: form * explode: true * - name: sort * in: query * schema: * type: string * enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc] * default: requestedDate_desc * description: Sort mode. * - name: search * in: query * schema: * type: string * description: Case-insensitive substring match on title. * - name: showAll * in: query * schema: * type: string * enum: ['true', 'false'] * description: Admin only. Show all users' requests. * responses: * '200': * description: Ombi requests retrieved successfully * content: * application/json: * schema: * type: object * properties: * user: * type: string * example: "username" * isAdmin: * type: boolean * example: false * showAll: * type: boolean * example: false * requests: * type: object * properties: * movie: * type: array * items: * $ref: '#/components/schemas/OmbiRequest' * tv: * type: array * items: * $ref: '#/components/schemas/OmbiRequest' * 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 = user.isAdmin; const username = user.name; const showAll = isAdmin && req.query.showAll === 'true'; const arrRetrieverRegistry = require('../utils/arrRetrievers'); // initialize() is idempotent - cheap no-op if already initialized await arrRetrieverRegistry.initialize(); const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true); // Filter by user if not admin or if showAll is false const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll); const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll); // Tag with mediaType and flatten for filtering/sorting const allRequests = [ ...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })), ...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' })) ]; // Parse query params let types = req.query.type; let statuses = req.query.status; const sort = req.query.sort || 'requestedDate_desc'; const search = req.query.search || ''; // Normalise to arrays if (typeof types === 'string') types = [types]; if (typeof statuses === 'string') statuses = [statuses]; // Apply filters and sorting const filtered = applyRequestFilters(allRequests, { types, statuses, sort, search }); // Split back into movie/tv const movie = filtered.filter(r => r.mediaType === 'movie'); const tv = filtered.filter(r => r.mediaType === 'tv'); const total = filtered.length; res.json({ user: username, isAdmin, showAll, requests: { movie, tv }, 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 webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); } const ombiInstances = getOmbiInstances(); if (ombiInstances.length === 0) { return res.status(400).json({ error: 'Ombi not configured' }); } const ombiInst = ombiInstances[0]; const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`; // Call Ombi API to register webhook const axios = require('axios'); // Get existing settings to retrieve the database ID const currentRes = await axios.get( `${ombiInst.url}/api/v1/Settings/notifications/webhook`, { headers: { 'ApiKey': ombiInst.apiKey } } ).catch(err => { logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`); return { data: {} }; }); const currentConfig = currentRes.data || {}; const settingsId = currentConfig.id || 0; const response = await axios.post( `${ombiInst.url}/api/v1/Settings/notifications/webhook`, { id: settingsId, 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 sofarrBaseUrl = getSofarrBaseUrl(); const webhookSecret = getWebhookSecret(); // Webhooks require SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET to be configured if (!sofarrBaseUrl || !webhookSecret) { return res.json({ enabled: false, webhookUrl: null, applicationToken: null, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null }); } 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, // Note: Ombi may support per-trigger toggles, but we currently treat // them as all-on or all-off based on webhookConfig.enabled 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.eventsReceived || 0, pollsSkipped: metrics.pollsSkipped || 0, lastWebhookTimestamp: metrics.lastWebhookTimestamp || 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 webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); } const ombiInstances = getOmbiInstances(); if (ombiInstances.length === 0) { return res.status(400).json({ error: 'Ombi not configured' }); } const ombiInst = ombiInstances[0]; const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`; // Simulate a test webhook event const axios = require('axios'); try { await axios.post(webhookUrl, { notificationType: 'RequestAvailable', requestId: 0, requestedUser: 'test', title: 'Test Request', type: 'Movie', requestStatus: 'Pending' }, { headers: { 'X-Sofarr-Webhook-Secret': webhookSecret, 'Content-Type': 'application/json' } }); logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`); } catch (error) { logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`); const port = process.env.PORT || 3001; const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; let useHttps = false; if (tlsEnabled) { const fs = require('fs'); const path = require('path'); const certsDir = path.join(__dirname, '../../certs'); const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt'); const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key'); try { fs.readFileSync(tlsCertPath); fs.readFileSync(tlsKeyPath); useHttps = true; } catch { useHttps = false; } } const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`; const https = require('https'); const agent = new https.Agent({ rejectUnauthorized: false }); await axios.post(localUrl, { notificationType: 'RequestAvailable', requestId: 0, requestedUser: 'test', title: 'Test Request', type: 'Movie', requestStatus: 'Pending' }, { headers: { 'X-Sofarr-Webhook-Secret': webhookSecret, 'Content-Type': 'application/json' }, httpsAgent: useHttps ? agent : undefined }); logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`); } 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;