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:
@@ -181,6 +181,22 @@ export async function fetchWebhookStatus() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Radarr not configured
|
// Radarr not configured
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch Ombi webhook status
|
||||||
|
let ombiEnabled = false;
|
||||||
|
let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
||||||
|
let ombiStats = null;
|
||||||
|
try {
|
||||||
|
const ombiRes = await fetch('/api/ombi/webhook/status');
|
||||||
|
if (ombiRes.ok) {
|
||||||
|
const ombiData = await ombiRes.json();
|
||||||
|
ombiEnabled = ombiData.enabled || false;
|
||||||
|
ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false };
|
||||||
|
ombiStats = ombiData.stats || null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ombi not configured
|
||||||
|
}
|
||||||
|
|
||||||
state.webhookMetrics = await metricsPromise;
|
state.webhookMetrics = await metricsPromise;
|
||||||
|
|
||||||
@@ -191,6 +207,7 @@ export async function fetchWebhookStatus() {
|
|||||||
|
|
||||||
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
||||||
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
||||||
|
state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats };
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -279,6 +296,36 @@ export async function testRadarrWebhook() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enableOmbiWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ombi/webhook/enable', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Ombi webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testOmbiWebhook() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ombi/webhook/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Ombi webhook:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshStatusPanel() {
|
export async function refreshStatusPanel() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
const res = await fetch('/api/status');
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export function startSSE() {
|
|||||||
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
|
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
|
||||||
document.dispatchEvent(filterUpdateEvent);
|
document.dispatchEvent(filterUpdateEvent);
|
||||||
}
|
}
|
||||||
|
// Store Ombi requests and base URL
|
||||||
|
if (data.ombiRequests) {
|
||||||
|
state.ombiRequests = data.ombiRequests;
|
||||||
|
// Trigger requests update event
|
||||||
|
const requestsUpdateEvent = new CustomEvent('ombiRequestsUpdated');
|
||||||
|
document.dispatchEvent(requestsUpdateEvent);
|
||||||
|
}
|
||||||
|
if (data.ombiBaseUrl) {
|
||||||
|
state.ombiBaseUrl = data.ombiBaseUrl;
|
||||||
|
}
|
||||||
document.getElementById('currentUser').textContent = state.currentUser || '-';
|
document.getElementById('currentUser').textContent = state.currentUser || '-';
|
||||||
renderDownloads();
|
renderDownloads();
|
||||||
hideError();
|
hideError();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const state = {
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
showAll: false,
|
showAll: false,
|
||||||
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||||
|
ombiBaseUrl: null, // Ombi base URL for generating links
|
||||||
|
ombiRequests: null, // Ombi requests data
|
||||||
|
|
||||||
// History section state
|
// History section state
|
||||||
historyDays: 7, // Default value, will be loaded from localStorage
|
historyDays: 7, // Default value, will be loaded from localStorage
|
||||||
@@ -28,6 +30,7 @@ export const state = {
|
|||||||
webhookLoading: false,
|
webhookLoading: false,
|
||||||
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||||
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||||
|
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
|
||||||
webhookMetrics: null
|
webhookMetrics: null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import { escapeHtml } from '../utils/format.js';
|
||||||
|
|
||||||
|
export function renderRequests() {
|
||||||
|
const requestsList = document.getElementById('requests-list');
|
||||||
|
const noRequests = document.getElementById('no-requests');
|
||||||
|
|
||||||
|
if (!requestsList) return;
|
||||||
|
|
||||||
|
const ombiRequests = state.ombiRequests || { movie: [], tv: [] };
|
||||||
|
const allRequests = [
|
||||||
|
...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })),
|
||||||
|
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
requestsList.innerHTML = '';
|
||||||
|
|
||||||
|
if (allRequests.length === 0) {
|
||||||
|
if (noRequests) noRequests.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noRequests) noRequests.style.display = 'none';
|
||||||
|
|
||||||
|
allRequests.forEach(request => {
|
||||||
|
const card = createRequestCard(request);
|
||||||
|
requestsList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestCard(request) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'request-card';
|
||||||
|
|
||||||
|
const typeIcon = document.createElement('span');
|
||||||
|
typeIcon.className = `request-type-icon ${request.mediaType}`;
|
||||||
|
typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'request-content';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'request-title';
|
||||||
|
title.textContent = request.title || 'Unknown Title';
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'request-meta';
|
||||||
|
|
||||||
|
const statusBadge = createStatusBadge(request);
|
||||||
|
meta.appendChild(statusBadge);
|
||||||
|
|
||||||
|
if (request.year) {
|
||||||
|
const year = document.createElement('span');
|
||||||
|
year.className = 'request-year';
|
||||||
|
year.textContent = request.year;
|
||||||
|
meta.appendChild(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requestedUser || request.requestedByAlias) {
|
||||||
|
const user = document.createElement('span');
|
||||||
|
user.className = 'request-user';
|
||||||
|
user.textContent = `Requested by: ${request.requestedUser || request.requestedByAlias}`;
|
||||||
|
meta.appendChild(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.quality) {
|
||||||
|
const quality = document.createElement('span');
|
||||||
|
quality.className = 'request-quality';
|
||||||
|
quality.textContent = request.quality;
|
||||||
|
meta.appendChild(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.appendChild(title);
|
||||||
|
content.appendChild(meta);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'request-actions';
|
||||||
|
|
||||||
|
if (request.theMovieDbId) {
|
||||||
|
const ombiLink = document.createElement('a');
|
||||||
|
ombiLink.className = 'request-link ombi-link';
|
||||||
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`;
|
||||||
|
ombiLink.target = '_blank';
|
||||||
|
ombiLink.title = 'View in Ombi';
|
||||||
|
|
||||||
|
const ombiIcon = document.createElement('img');
|
||||||
|
ombiIcon.src = '/images/ombi.svg';
|
||||||
|
ombiIcon.alt = 'Ombi';
|
||||||
|
ombiIcon.className = 'request-icon';
|
||||||
|
|
||||||
|
ombiLink.appendChild(ombiIcon);
|
||||||
|
actions.appendChild(ombiLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(typeIcon);
|
||||||
|
card.appendChild(content);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBadge(request) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'request-status-badge';
|
||||||
|
|
||||||
|
let status = 'unknown';
|
||||||
|
let text = 'Unknown';
|
||||||
|
|
||||||
|
if (request.available) {
|
||||||
|
status = 'available';
|
||||||
|
text = 'Available';
|
||||||
|
} else if (request.approved) {
|
||||||
|
status = 'approved';
|
||||||
|
text = 'Approved';
|
||||||
|
} else if (request.denied) {
|
||||||
|
status = 'denied';
|
||||||
|
text = `Denied: ${request.deniedReason || 'No reason'}`;
|
||||||
|
} else if (request.requested) {
|
||||||
|
status = 'pending';
|
||||||
|
text = 'Pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.classList.add(status);
|
||||||
|
badge.textContent = text;
|
||||||
|
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupRequestsTab() {
|
||||||
|
// Listen for SSE updates
|
||||||
|
if (state.sseSource) {
|
||||||
|
state.sseSource.addEventListener('message', (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.ombiRequests) {
|
||||||
|
state.ombiRequests = data.ombiRequests;
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also listen for custom event triggered from sse.js
|
||||||
|
document.addEventListener('ombiRequestsUpdated', () => {
|
||||||
|
renderRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
+29
-9
@@ -2,42 +2,62 @@
|
|||||||
|
|
||||||
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
||||||
import { loadHistory } from './history.js';
|
import { loadHistory } from './history.js';
|
||||||
|
import { setupRequestsTab } from './requests.js';
|
||||||
|
|
||||||
export function initTabs() {
|
export function initTabs() {
|
||||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||||
|
const requestsTab = document.querySelector('[data-tab="requests"]');
|
||||||
const historyTab = document.querySelector('[data-tab="history"]');
|
const historyTab = document.querySelector('[data-tab="history"]');
|
||||||
|
|
||||||
if (!downloadsTab || !historyTab) return;
|
if (!downloadsTab || !historyTab) return;
|
||||||
|
|
||||||
// Load saved tab
|
// Load saved tab
|
||||||
const savedTab = getActiveTab();
|
const savedTab = getActiveTab();
|
||||||
if (savedTab === 'history') {
|
if (savedTab === 'requests') {
|
||||||
|
activateTab('requests');
|
||||||
|
} else if (savedTab === 'history') {
|
||||||
activateTab('history');
|
activateTab('history');
|
||||||
} else {
|
} else {
|
||||||
activateTab('downloads');
|
activateTab('downloads');
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadsTab.addEventListener('click', () => activateTab('downloads'));
|
downloadsTab.addEventListener('click', () => activateTab('downloads'));
|
||||||
|
if (requestsTab) {
|
||||||
|
requestsTab.addEventListener('click', () => activateTab('requests'));
|
||||||
|
}
|
||||||
historyTab.addEventListener('click', () => activateTab('history'));
|
historyTab.addEventListener('click', () => activateTab('history'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activateTab(tab) {
|
export function activateTab(tab) {
|
||||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||||
|
const requestsTab = document.querySelector('[data-tab="requests"]');
|
||||||
const historyTab = document.querySelector('[data-tab="history"]');
|
const historyTab = document.querySelector('[data-tab="history"]');
|
||||||
const downloadsSection = document.getElementById('tab-downloads');
|
const downloadsSection = document.getElementById('tab-downloads');
|
||||||
|
const requestsSection = document.getElementById('tab-requests');
|
||||||
const historySection = document.getElementById('tab-history');
|
const historySection = document.getElementById('tab-history');
|
||||||
|
|
||||||
|
// Remove active class from all tabs
|
||||||
|
if (downloadsTab) downloadsTab.classList.remove('active');
|
||||||
|
if (requestsTab) requestsTab.classList.remove('active');
|
||||||
|
if (historyTab) historyTab.classList.remove('active');
|
||||||
|
|
||||||
|
// Hide all sections
|
||||||
|
if (downloadsSection) downloadsSection.classList.add('hidden');
|
||||||
|
if (requestsSection) requestsSection.classList.add('hidden');
|
||||||
|
if (historySection) historySection.classList.add('hidden');
|
||||||
|
|
||||||
if (tab === 'downloads') {
|
if (tab === 'downloads') {
|
||||||
downloadsTab.classList.add('active');
|
if (downloadsTab) downloadsTab.classList.add('active');
|
||||||
historyTab.classList.remove('active');
|
if (downloadsSection) downloadsSection.classList.remove('hidden');
|
||||||
downloadsSection.classList.remove('hidden');
|
|
||||||
historySection.classList.add('hidden');
|
|
||||||
saveActiveTab('downloads');
|
saveActiveTab('downloads');
|
||||||
|
} else if (tab === 'requests') {
|
||||||
|
if (requestsTab) requestsTab.classList.add('active');
|
||||||
|
if (requestsSection) requestsSection.classList.remove('hidden');
|
||||||
|
saveActiveTab('requests');
|
||||||
|
setupRequestsTab();
|
||||||
} else if (tab === 'history') {
|
} else if (tab === 'history') {
|
||||||
historyTab.classList.add('active');
|
if (historyTab) historyTab.classList.add('active');
|
||||||
downloadsTab.classList.remove('active');
|
if (historySection) historySection.classList.remove('hidden');
|
||||||
historySection.classList.remove('hidden');
|
|
||||||
downloadsSection.classList.add('hidden');
|
|
||||||
saveActiveTab('history');
|
saveActiveTab('history');
|
||||||
loadHistory();
|
loadHistory();
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-33
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js';
|
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, enableOmbiWebhook as apiEnableOmbiWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../api.js';
|
||||||
import { formatTimeAgo } from '../utils/format.js';
|
import { formatTimeAgo } from '../utils/format.js';
|
||||||
|
|
||||||
export function initWebhooks() {
|
export function initWebhooks() {
|
||||||
@@ -13,8 +13,10 @@ export function initWebhooks() {
|
|||||||
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
|
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
|
||||||
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
|
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
|
||||||
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
|
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
|
||||||
|
document.getElementById('enable-ombi-webhook').addEventListener('click', enableOmbiWebhook);
|
||||||
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
|
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
|
||||||
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
|
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
|
||||||
|
document.getElementById('test-ombi-webhook').addEventListener('click', testOmbiWebhook);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleWebhookSection() {
|
export function toggleWebhookSection() {
|
||||||
@@ -58,9 +60,9 @@ export function renderWebhookStatus() {
|
|||||||
const sonarrTriggers = document.getElementById('sonarr-triggers');
|
const sonarrTriggers = document.getElementById('sonarr-triggers');
|
||||||
const sonarrStats = document.getElementById('sonarr-stats');
|
const sonarrStats = document.getElementById('sonarr-stats');
|
||||||
|
|
||||||
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
sonarrStatus.textContent = state.sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||||
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
|
sonarrStatus.className = 'status-indicator ' + (state.sonarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||||
if (sonarrWebhook.enabled) {
|
if (state.sonarrWebhook.enabled) {
|
||||||
sonarrEnableBtn.classList.add('hidden');
|
sonarrEnableBtn.classList.add('hidden');
|
||||||
sonarrTestBtn.classList.remove('hidden');
|
sonarrTestBtn.classList.remove('hidden');
|
||||||
sonarrTriggers.classList.remove('hidden');
|
sonarrTriggers.classList.remove('hidden');
|
||||||
@@ -70,22 +72,22 @@ export function renderWebhookStatus() {
|
|||||||
sonarrTriggers.classList.add('hidden');
|
sonarrTriggers.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sonarrWebhook.enabled) {
|
if (state.sonarrWebhook.enabled) {
|
||||||
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
|
document.getElementById('sonarr-onGrab').textContent = state.sonarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||||
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||||
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
|
document.getElementById('sonarr-onDownload').textContent = state.sonarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||||
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||||
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
|
document.getElementById('sonarr-onImport').textContent = state.sonarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||||
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||||
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
document.getElementById('sonarr-onUpgrade').textContent = state.sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||||
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sonarrWebhook.stats) {
|
if (state.sonarrWebhook.stats) {
|
||||||
sonarrStats.classList.remove('hidden');
|
sonarrStats.classList.remove('hidden');
|
||||||
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
|
document.getElementById('sonarr-events').textContent = state.sonarrWebhook.stats.eventsReceived ?? 0;
|
||||||
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
|
document.getElementById('sonarr-polls').textContent = state.sonarrWebhook.stats.pollsSkipped ?? 0;
|
||||||
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
|
document.getElementById('sonarr-last').textContent = formatTimeAgo(state.sonarrWebhook.stats.lastWebhookTimestamp);
|
||||||
} else {
|
} else {
|
||||||
sonarrStats.classList.add('hidden');
|
sonarrStats.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -97,9 +99,9 @@ export function renderWebhookStatus() {
|
|||||||
const radarrTriggers = document.getElementById('radarr-triggers');
|
const radarrTriggers = document.getElementById('radarr-triggers');
|
||||||
const radarrStats = document.getElementById('radarr-stats');
|
const radarrStats = document.getElementById('radarr-stats');
|
||||||
|
|
||||||
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
radarrStatus.textContent = state.radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||||
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
|
radarrStatus.className = 'status-indicator ' + (state.radarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||||
if (radarrWebhook.enabled) {
|
if (state.radarrWebhook.enabled) {
|
||||||
radarrEnableBtn.classList.add('hidden');
|
radarrEnableBtn.classList.add('hidden');
|
||||||
radarrTestBtn.classList.remove('hidden');
|
radarrTestBtn.classList.remove('hidden');
|
||||||
radarrTriggers.classList.remove('hidden');
|
radarrTriggers.classList.remove('hidden');
|
||||||
@@ -109,25 +111,66 @@ export function renderWebhookStatus() {
|
|||||||
radarrTriggers.classList.add('hidden');
|
radarrTriggers.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radarrWebhook.enabled) {
|
if (state.radarrWebhook.enabled) {
|
||||||
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
|
document.getElementById('radarr-onGrab').textContent = state.radarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||||
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (state.radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||||
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
|
document.getElementById('radarr-onDownload').textContent = state.radarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||||
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (state.radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||||
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
|
document.getElementById('radarr-onImport').textContent = state.radarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||||
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
document.getElementById('radarr-onImport').className = 'trigger-value ' + (state.radarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||||
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
document.getElementById('radarr-onUpgrade').textContent = state.radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||||
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (state.radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (radarrWebhook.stats) {
|
if (state.radarrWebhook.stats) {
|
||||||
radarrStats.classList.remove('hidden');
|
radarrStats.classList.remove('hidden');
|
||||||
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
|
document.getElementById('radarr-events').textContent = state.radarrWebhook.stats.eventsReceived ?? 0;
|
||||||
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
|
document.getElementById('radarr-polls').textContent = state.radarrWebhook.stats.pollsSkipped ?? 0;
|
||||||
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
|
document.getElementById('radarr-last').textContent = formatTimeAgo(state.radarrWebhook.stats.lastWebhookTimestamp);
|
||||||
} else {
|
} else {
|
||||||
radarrStats.classList.add('hidden');
|
radarrStats.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ombi
|
||||||
|
const ombiStatus = document.getElementById('ombi-status');
|
||||||
|
const ombiEnableBtn = document.getElementById('enable-ombi-webhook');
|
||||||
|
const ombiTestBtn = document.getElementById('test-ombi-webhook');
|
||||||
|
const ombiTriggers = document.getElementById('ombi-triggers');
|
||||||
|
const ombiStats = document.getElementById('ombi-stats');
|
||||||
|
|
||||||
|
ombiStatus.textContent = state.ombiWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||||
|
ombiStatus.className = 'status-indicator ' + (state.ombiWebhook.enabled ? 'enabled' : 'disabled');
|
||||||
|
if (state.ombiWebhook.enabled) {
|
||||||
|
ombiEnableBtn.classList.add('hidden');
|
||||||
|
ombiTestBtn.classList.remove('hidden');
|
||||||
|
ombiTriggers.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
ombiEnableBtn.classList.remove('hidden');
|
||||||
|
ombiTestBtn.classList.add('hidden');
|
||||||
|
ombiTriggers.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.ombiWebhook.enabled) {
|
||||||
|
document.getElementById('ombi-requestAvailable').textContent = state.ombiWebhook.triggers.requestAvailable ? '✓' : '✗';
|
||||||
|
document.getElementById('ombi-requestAvailable').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestAvailable ? 'active' : 'inactive');
|
||||||
|
document.getElementById('ombi-requestApproved').textContent = state.ombiWebhook.triggers.requestApproved ? '✓' : '✗';
|
||||||
|
document.getElementById('ombi-requestApproved').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestApproved ? 'active' : 'inactive');
|
||||||
|
document.getElementById('ombi-requestDeclined').textContent = state.ombiWebhook.triggers.requestDeclined ? '✓' : '✗';
|
||||||
|
document.getElementById('ombi-requestDeclined').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestDeclined ? 'active' : 'inactive');
|
||||||
|
document.getElementById('ombi-requestPending').textContent = state.ombiWebhook.triggers.requestPending ? '✓' : '✗';
|
||||||
|
document.getElementById('ombi-requestPending').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestPending ? 'active' : 'inactive');
|
||||||
|
document.getElementById('ombi-requestProcessing').textContent = state.ombiWebhook.triggers.requestProcessing ? '✓' : '✗';
|
||||||
|
document.getElementById('ombi-requestProcessing').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestProcessing ? 'active' : 'inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.ombiWebhook.stats) {
|
||||||
|
ombiStats.classList.remove('hidden');
|
||||||
|
document.getElementById('ombi-events').textContent = state.ombiWebhook.stats.eventsReceived ?? 0;
|
||||||
|
document.getElementById('ombi-polls').textContent = state.ombiWebhook.stats.pollsSkipped ?? 0;
|
||||||
|
document.getElementById('ombi-last').textContent = formatTimeAgo(state.ombiWebhook.stats.lastWebhookTimestamp);
|
||||||
|
} else {
|
||||||
|
ombiStats.classList.add('hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableSonarrWebhook() {
|
export async function enableSonarrWebhook() {
|
||||||
@@ -198,12 +241,48 @@ export async function testRadarrWebhook() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enableOmbiWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await apiEnableOmbiWebhook();
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Failed to enable Ombi webhook:', result.error);
|
||||||
|
alert('Failed to enable Ombi webhook. Check console for details.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Ombi webhook:', err);
|
||||||
|
alert('Failed to enable Ombi webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testOmbiWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await apiTestOmbiWebhook();
|
||||||
|
if (result.success) {
|
||||||
|
alert('Ombi webhook test sent successfully!');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to test Ombi webhook:', result.error);
|
||||||
|
alert('Failed to test Ombi webhook. Check console for details.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Ombi webhook:', err);
|
||||||
|
alert('Failed to test Ombi webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setWebhookLoading(loading) {
|
export function setWebhookLoading(loading) {
|
||||||
state.webhookLoading = loading;
|
state.webhookLoading = loading;
|
||||||
document.getElementById('enable-sonarr-webhook').disabled = loading;
|
document.getElementById('enable-sonarr-webhook').disabled = loading;
|
||||||
document.getElementById('enable-radarr-webhook').disabled = loading;
|
document.getElementById('enable-radarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('enable-ombi-webhook').disabled = loading;
|
||||||
document.getElementById('test-sonarr-webhook').disabled = loading;
|
document.getElementById('test-sonarr-webhook').disabled = loading;
|
||||||
document.getElementById('test-radarr-webhook').disabled = loading;
|
document.getElementById('test-radarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('test-ombi-webhook').disabled = loading;
|
||||||
const loadingEl = document.getElementById('webhook-loading');
|
const loadingEl = document.getElementById('webhook-loading');
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loadingEl.classList.remove('hidden');
|
loadingEl.classList.remove('hidden');
|
||||||
|
|||||||
@@ -129,6 +129,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Ombi Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Ombi</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="ombi-status">○ Disabled</span>
|
||||||
|
<button id="enable-ombi-webhook" class="enable-webhook-btn hidden">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-ombi-webhook" class="test-webhook-btn hidden">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers hidden" id="ombi-triggers">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Available</span><span class="trigger-value" id="ombi-requestAvailable">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Approved</span><span class="trigger-value" id="ombi-requestApproved">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Declined</span><span class="trigger-value" id="ombi-requestDeclined">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Pending</span><span class="trigger-value" id="ombi-requestPending">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">Request Processing</span><span class="trigger-value" id="ombi-requestProcessing">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats hidden" id="ombi-stats">
|
||||||
|
<div class="webhook-stats-title">Statistics</div>
|
||||||
|
<div class="webhook-stats-grid">
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Events Received</span><span class="webhook-stat-value" id="ombi-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="ombi-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="ombi-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,6 +164,7 @@
|
|||||||
<div class="main-tabs">
|
<div class="main-tabs">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||||
|
<button class="tab-btn" data-tab="requests">Requests</button>
|
||||||
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,6 +198,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel hidden" id="tab-requests">
|
||||||
|
<div class="requests-container">
|
||||||
|
<div class="requests-header">
|
||||||
|
<h2>Ombi Requests</h2>
|
||||||
|
</div>
|
||||||
|
<div id="no-requests" class="no-requests hidden">
|
||||||
|
<p>No requests found.</p>
|
||||||
|
</div>
|
||||||
|
<div id="requests-list" class="requests-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel hidden" id="tab-history">
|
<div class="tab-panel hidden" id="tab-history">
|
||||||
<div class="history-container" id="history-container">
|
<div class="history-container" id="history-container">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
|
|||||||
@@ -2029,3 +2029,157 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Requests Tab ===== */
|
||||||
|
.requests-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-requests {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-type-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge.available {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge.approved {
|
||||||
|
background: var(--info-bg);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge.denied {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge.pending {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-status-badge.unknown {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-year {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-user {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-quality {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-link:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-icon {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const statusRoutes = require('./routes/status');
|
|||||||
const historyRoutes = require('./routes/history');
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
|
const ombiRoutes = require('./routes/ombi');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
function createApp({ skipRateLimits = false } = {}) {
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
@@ -218,6 +219,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/sonarr', sonarrRoutes);
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
|
app.use('/api/ombi', ombiRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function readCacheSnapshot() {
|
|||||||
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||||
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||||
|
const ombiRequests = cache.get('poll:ombi-requests') || { movie: [], tv: [] };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sabnzbdQueue: { data: { queue: sabQueueData } },
|
sabnzbdQueue: { data: { queue: sabQueueData } },
|
||||||
@@ -39,7 +40,8 @@ function readCacheSnapshot() {
|
|||||||
radarrHistory: { data: radarrHistoryData },
|
radarrHistory: { data: radarrHistoryData },
|
||||||
radarrTags: { data: radarrTagsData },
|
radarrTags: { data: radarrTagsData },
|
||||||
qbittorrentTorrents,
|
qbittorrentTorrents,
|
||||||
sonarrTagsResults
|
sonarrTagsResults,
|
||||||
|
ombiRequests
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,7 +491,32 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
type: c.getClientType()
|
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) {
|
} catch (err) {
|
||||||
console.error('[SSE] Error building payload:', sanitizeError(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 express = require('express');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const { logToFile } = require('../utils/logger');
|
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 cache = require('../utils/cache');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||||
@@ -27,7 +27,9 @@ const VALID_EVENT_TYPES = new Set([
|
|||||||
'DownloadFolderImported', 'ImportFailed',
|
'DownloadFolderImported', 'ImportFailed',
|
||||||
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries',
|
||||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
'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.
|
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||||
@@ -73,6 +75,15 @@ const HISTORY_EVENTS = new Set([
|
|||||||
'EpisodeFileRenamedBySeries'
|
'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
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
||||||
* @param {Object} req - Express request object
|
* @param {Object} req - Express request object
|
||||||
@@ -107,19 +118,20 @@ function validateWebhookSecret(req) {
|
|||||||
*
|
*
|
||||||
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
* Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast.
|
||||||
*
|
*
|
||||||
* @param {string} serviceType - 'sonarr' or 'radarr'
|
* @param {string} serviceType - 'sonarr', 'radarr', or 'ombi'
|
||||||
* @param {string} eventType - the eventType from the *arr webhook payload
|
* @param {string} eventType - the eventType from the webhook payload
|
||||||
*/
|
*/
|
||||||
async function processWebhookEvent(serviceType, eventType) {
|
async function processWebhookEvent(serviceType, eventType) {
|
||||||
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
||||||
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
||||||
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
||||||
|
|
||||||
if (!affectsQueue && !affectsHistory) {
|
if (!affectsQueue && !affectsHistory && !affectsOmbi) {
|
||||||
logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`);
|
logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`);
|
||||||
return;
|
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)
|
// Ensure retrievers are initialized (idempotent)
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
@@ -184,6 +196,14 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`);
|
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.
|
// 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;
|
module.exports = router;
|
||||||
|
|||||||
+21
-3
@@ -5,7 +5,8 @@ const { initializeClients, getAllDownloads, getDownloadsByClientType } = require
|
|||||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances
|
getRadarrInstances,
|
||||||
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
|
||||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||||
@@ -88,13 +89,14 @@ async function pollAllServices() {
|
|||||||
|
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
// Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll
|
||||||
const globalMetrics = cache.getGlobalWebhookMetrics();
|
const globalMetrics = cache.getGlobalWebhookMetrics();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp;
|
||||||
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS;
|
||||||
|
|
||||||
if (fallbackTriggered) {
|
if (fallbackTriggered) {
|
||||||
console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`);
|
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
|
// Determine which instances should be polled based on webhook activity
|
||||||
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr');
|
||||||
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr');
|
||||||
|
const shouldPollOmbi = fallbackTriggered || !shouldSkipInstancePolling(ombiInstances, 'ombi');
|
||||||
|
|
||||||
// All fetches in parallel, each individually timed
|
// All fetches in parallel, each individually timed
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
@@ -133,6 +136,10 @@ async function pollAllServices() {
|
|||||||
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
const tagsByType = await arrRetrieverRegistry.getTagsByType();
|
||||||
return tagsByType.radarr || [];
|
return tagsByType.radarr || [];
|
||||||
}) : timed('Radarr Tags', async () => []),
|
}) : timed('Radarr Tags', async () => []),
|
||||||
|
shouldPollOmbi ? timed('Ombi Requests', async () => {
|
||||||
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||||
|
return ombiRequests;
|
||||||
|
}) : timed('Ombi Requests', async () => ({ movie: [], tv: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -140,7 +147,8 @@ async function pollAllServices() {
|
|||||||
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||||
{ result: sonarrHistories },
|
{ result: sonarrHistories },
|
||||||
{ result: radarrQueues }, { result: radarrHistories },
|
{ result: radarrQueues }, { result: radarrHistories },
|
||||||
{ result: radarrTagsResults }
|
{ result: radarrTagsResults },
|
||||||
|
{ result: ombiRequests }
|
||||||
] = results;
|
] = results;
|
||||||
|
|
||||||
// Store per-task timings
|
// Store per-task timings
|
||||||
@@ -282,6 +290,16 @@ async function pollAllServices() {
|
|||||||
if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL);
|
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)
|
// qBittorrent (already set above in download clients section)
|
||||||
|
|
||||||
const elapsed = Date.now() - start;
|
const elapsed = Date.now() - start;
|
||||||
|
|||||||
Reference in New Issue
Block a user