7b9c895888
Build and Push Docker Image / build (push) Successful in 2m4s
Docs Check / Markdown lint (push) Successful in 2m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m3s
CI / Security audit (push) Successful in 3m42s
Docs Check / Mermaid diagram parse check (push) Failing after 3m58s
CI / Tests & coverage (push) Successful in 4m21s
CI / Swagger Validation & Coverage (push) Successful in 4m33s
509 lines
16 KiB
JavaScript
509 lines
16 KiB
JavaScript
// 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(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 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?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 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;
|