// 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 } = 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(); // 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 sofarrBaseUrl = getSofarrBaseUrl(); const webhookSecret = getWebhookSecret(); if (!sofarrBaseUrl) { 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 = `${sofarrBaseUrl}/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 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 sofarrBaseUrl = getSofarrBaseUrl(); const webhookSecret = getWebhookSecret(); if (!sofarrBaseUrl) { 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 = `${sofarrBaseUrl}/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': webhookSecret, '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;