Compare commits
41 Commits
v1.5.0a
...
release/1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
| ca1c136d4f | |||
| a04f2c9b25 | |||
| 743b169989 | |||
| 794cb7268e | |||
| d310d101ed | |||
| 96f24eb3b7 | |||
| abcb9bfded | |||
| e5920b207f | |||
| d3483f3be7 | |||
| 252cc50aa4 | |||
| 57908e2b9e | |||
| e2757768c7 | |||
| 2469c3e3f4 | |||
| 6c8c333c6a | |||
| 5dfe0b1216 | |||
| 77beef787f | |||
| 235a866ec8 | |||
| f1d9de2a92 | |||
| 9d0e31ec9a | |||
| 42c3eebf18 | |||
| f295e1c90d | |||
| c5e8281440 | |||
| f22dd0d1f6 | |||
| 5159a83475 | |||
| ccc3b6ffec | |||
| 4ec7d734b8 | |||
| 2e85fae57a | |||
| aeacadbe68 | |||
| 3ef35a8c43 | |||
| 0f3c02e52d | |||
| 9fd60bcfed | |||
| af58e1bf2a | |||
| 2d04402284 | |||
| 0310f10e5d | |||
| 5ab8cc96a3 | |||
| a7363fcb3a | |||
| d06e24dbb6 | |||
| 6df94e5ad2 | |||
| 015e07ae7a | |||
| eeab314a08 | |||
| 603f444c33 |
@@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook endpoints not reachable in production** — `server/index.js` (the production entry point) was missing the `webhookRoutes` import and mount. Only `server/app.js` (the test factory) had the routes registered. As a result every `POST /api/webhook/*` request in a running container fell through to the `verifyCsrf` middleware and was rejected with `403 CSRF token missing`. Added `app.use('/api/webhook', webhookRoutes)` in `index.js` immediately after `authRoutes` and before `verifyCsrf`, matching the order in `app.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.5.0a] - 2026-05-19
|
## [1.5.0a] - 2026-05-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -459,3 +459,41 @@ body {
|
|||||||
.trigger-value.inactive {
|
.trigger-value.inactive {
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webhook-stats {
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stats-title {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat-label {
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat-value {
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
+94
-14
@@ -11,8 +11,9 @@ function App() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
||||||
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
||||||
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
||||||
|
const [webhookMetrics, setWebhookMetrics] = useState(null);
|
||||||
const [webhookLoading, setWebhookLoading] = useState(false);
|
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,43 +73,82 @@ function App() {
|
|||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTimeAgo = (timestamp) => {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWebhookMetrics = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/dashboard/webhook-metrics');
|
||||||
|
setWebhookMetrics(response.data);
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
// Not fatal — stats just won't display
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchWebhookStatus = async () => {
|
const fetchWebhookStatus = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Fetch metrics in parallel with notification status
|
||||||
|
const metricsPromise = fetchWebhookMetrics();
|
||||||
|
|
||||||
// Fetch Sonarr notifications
|
// Fetch Sonarr notifications
|
||||||
|
let sonarrEnabled = false;
|
||||||
|
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
try {
|
try {
|
||||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
setSonarrWebhook({
|
sonarrEnabled = !!sonarrSofarr;
|
||||||
enabled: !!sonarrSofarr,
|
if (sonarrSofarr) {
|
||||||
triggers: sonarrSofarr ? {
|
sonarrTriggers = {
|
||||||
onGrab: sonarrSofarr.onGrab,
|
onGrab: sonarrSofarr.onGrab,
|
||||||
onDownload: sonarrSofarr.onDownload,
|
onDownload: sonarrSofarr.onDownload,
|
||||||
onImport: sonarrSofarr.onImport,
|
onImport: sonarrSofarr.onImport,
|
||||||
onUpgrade: sonarrSofarr.onUpgrade
|
onUpgrade: sonarrSofarr.onUpgrade
|
||||||
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
|
};
|
||||||
});
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Sonarr not configured or not accessible
|
// Sonarr not configured or not accessible
|
||||||
setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Radarr notifications
|
// Fetch Radarr notifications
|
||||||
|
let radarrEnabled = false;
|
||||||
|
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
try {
|
try {
|
||||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
const radarrResponse = await axios.get('/api/radarr/notifications');
|
||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
setRadarrWebhook({
|
radarrEnabled = !!radarrSofarr;
|
||||||
enabled: !!radarrSofarr,
|
if (radarrSofarr) {
|
||||||
triggers: radarrSofarr ? {
|
radarrTriggers = {
|
||||||
onGrab: radarrSofarr.onGrab,
|
onGrab: radarrSofarr.onGrab,
|
||||||
onDownload: radarrSofarr.onDownload,
|
onDownload: radarrSofarr.onDownload,
|
||||||
onImport: radarrSofarr.onImport,
|
onImport: radarrSofarr.onImport,
|
||||||
onUpgrade: radarrSofarr.onUpgrade
|
onUpgrade: radarrSofarr.onUpgrade
|
||||||
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
|
};
|
||||||
});
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Radarr not configured or not accessible
|
// Radarr not configured or not accessible
|
||||||
setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metrics = await metricsPromise;
|
||||||
|
|
||||||
|
// Attach per-instance stats from global metrics.
|
||||||
|
// The instances object is keyed by instance URL; we pick the first
|
||||||
|
// sonarr/radarr entry by matching env-configured URLs.
|
||||||
|
const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : [];
|
||||||
|
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||||
|
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||||
|
|
||||||
|
setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats });
|
||||||
|
setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch webhook status:', err);
|
console.error('Failed to fetch webhook status:', err);
|
||||||
}
|
}
|
||||||
@@ -147,6 +187,7 @@ function App() {
|
|||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
if (sonarrSofarr) {
|
if (sonarrSofarr) {
|
||||||
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
||||||
|
await fetchWebhookStatus();
|
||||||
alert('Sonarr webhook test sent successfully!');
|
alert('Sonarr webhook test sent successfully!');
|
||||||
} else {
|
} else {
|
||||||
alert('Sofarr webhook not configured for Sonarr.');
|
alert('Sofarr webhook not configured for Sonarr.');
|
||||||
@@ -166,6 +207,7 @@ function App() {
|
|||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
if (radarrSofarr) {
|
if (radarrSofarr) {
|
||||||
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
||||||
|
await fetchWebhookStatus();
|
||||||
alert('Radarr webhook test sent successfully!');
|
alert('Radarr webhook test sent successfully!');
|
||||||
} else {
|
} else {
|
||||||
alert('Sofarr webhook not configured for Radarr.');
|
alert('Sofarr webhook not configured for Radarr.');
|
||||||
@@ -342,6 +384,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{sonarrWebhook.stats && (
|
||||||
|
<div className="webhook-stats">
|
||||||
|
<div className="webhook-stats-title">Statistics</div>
|
||||||
|
<div className="webhook-stats-grid">
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Events Received</span>
|
||||||
|
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Polls Skipped</span>
|
||||||
|
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Last Event</span>
|
||||||
|
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="webhook-instance">
|
<div className="webhook-instance">
|
||||||
<h3>Radarr</h3>
|
<h3>Radarr</h3>
|
||||||
@@ -388,6 +449,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{radarrWebhook.stats && (
|
||||||
|
<div className="webhook-stats">
|
||||||
|
<div className="webhook-stats-title">Statistics</div>
|
||||||
|
<div className="webhook-stats-grid">
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Events Received</span>
|
||||||
|
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Polls Skipped</span>
|
||||||
|
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="webhook-stat">
|
||||||
|
<span className="webhook-stat-label">Last Event</span>
|
||||||
|
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.5.0a",
|
"version": "1.5.3",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+355
-6
@@ -30,6 +30,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initThemeSwitcher();
|
initThemeSwitcher();
|
||||||
initTabs();
|
initTabs();
|
||||||
initHistoryControls();
|
initHistoryControls();
|
||||||
|
initWebhooks();
|
||||||
loadAppVersion();
|
loadAppVersion();
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
@@ -299,8 +300,11 @@ function showDashboard() {
|
|||||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||||
const sp = document.getElementById('status-panel');
|
const sp = document.getElementById('status-panel');
|
||||||
sp.style.display = 'none';
|
sp.style.display = 'none';
|
||||||
sp.innerHTML = '';
|
// Also hide webhooks-section to keep them in sync (both show/hide together)
|
||||||
|
const webhooksSection = document.getElementById('webhooks-section');
|
||||||
|
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||||
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||||
|
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
|
||||||
// Initialise days input from saved value
|
// Initialise days input from saved value
|
||||||
const daysInput = document.getElementById('history-days');
|
const daysInput = document.getElementById('history-days');
|
||||||
if (daysInput) daysInput.value = historyDays;
|
if (daysInput) daysInput.value = historyDays;
|
||||||
@@ -768,12 +772,26 @@ const STATUS_REFRESH_MS = 5000;
|
|||||||
|
|
||||||
async function toggleStatusPanel() {
|
async function toggleStatusPanel() {
|
||||||
const panel = document.getElementById('status-panel');
|
const panel = document.getElementById('status-panel');
|
||||||
|
const webhooksSection = document.getElementById('webhooks-section');
|
||||||
if (panel.style.display !== 'none') {
|
if (panel.style.display !== 'none') {
|
||||||
|
// Close both panels (webhooks is a sibling, hide it too)
|
||||||
panel.style.display = 'none';
|
panel.style.display = 'none';
|
||||||
|
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Open status panel and webhooks section (siblings)
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
|
// Show webhooks section for admin users (collapsed by default)
|
||||||
|
if (webhooksSection && isAdmin) {
|
||||||
|
webhooksSection.style.display = 'block';
|
||||||
|
webhookSectionExpanded = false;
|
||||||
|
document.getElementById('webhooks-content').style.display = 'none';
|
||||||
|
document.getElementById('webhooks-toggle').classList.remove('expanded');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
} else if (webhooksSection) {
|
||||||
|
webhooksSection.style.display = 'none';
|
||||||
|
}
|
||||||
await refreshStatusPanel();
|
await refreshStatusPanel();
|
||||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||||
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||||
@@ -781,26 +799,34 @@ async function toggleStatusPanel() {
|
|||||||
|
|
||||||
function closeStatusPanel() {
|
function closeStatusPanel() {
|
||||||
document.getElementById('status-panel').style.display = 'none';
|
document.getElementById('status-panel').style.display = 'none';
|
||||||
|
const webhooksSection = document.getElementById('webhooks-section');
|
||||||
|
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatusPanel() {
|
async function refreshStatusPanel() {
|
||||||
const panel = document.getElementById('status-panel');
|
const panel = document.getElementById('status-panel');
|
||||||
|
const contentDiv = document.getElementById('status-content');
|
||||||
|
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
|
||||||
if (!panel || panel.style.display === 'none') return;
|
if (!panel || panel.style.display === 'none') return;
|
||||||
|
console.log('[Status] Refreshing status panel...');
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/dashboard/status');
|
const res = await fetch('/api/dashboard/status');
|
||||||
if (!res.ok) throw new Error('Failed to fetch status');
|
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
console.log('[Status] Got status data, rendering...');
|
||||||
renderStatusPanel(data, panel);
|
renderStatusPanel(data, panel);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[Status] Error fetching status:', err);
|
||||||
// Don't overwrite panel on transient error during auto-refresh
|
// Don't overwrite panel on transient error during auto-refresh
|
||||||
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
|
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
|
||||||
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
|
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatusPanel(data, panel) {
|
function renderStatusPanel(data, panel) {
|
||||||
|
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
|
||||||
const s = data.server;
|
const s = data.server;
|
||||||
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
||||||
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
||||||
@@ -848,6 +874,26 @@ function renderStatusPanel(data, panel) {
|
|||||||
|
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Webhook metrics card (admin only)
|
||||||
|
if (isAdmin && data.webhooks) {
|
||||||
|
const wh = data.webhooks;
|
||||||
|
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||||
|
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||||
|
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||||
|
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||||
|
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||||
|
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-card-title">Webhooks</div>
|
||||||
|
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
|
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
|
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||||
|
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Poll timings card
|
// Poll timings card
|
||||||
const lp = data.polling.lastPoll;
|
const lp = data.polling.lastPoll;
|
||||||
if (lp) {
|
if (lp) {
|
||||||
@@ -885,9 +931,21 @@ function renderStatusPanel(data, panel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += `</tbody></table></div></div>`;
|
html += `</tbody></table></div></div>`;
|
||||||
panel.innerHTML = html;
|
// Render into status-content div, not the whole panel (preserves webhooks section)
|
||||||
|
const contentDiv = document.getElementById('status-content');
|
||||||
|
const panelCheck = document.getElementById('status-panel');
|
||||||
|
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
|
||||||
|
if (panelCheck) {
|
||||||
|
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
|
||||||
|
}
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.innerHTML = html;
|
||||||
|
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
|
||||||
|
} else {
|
||||||
|
console.error('[Status] contentDiv not found!');
|
||||||
|
}
|
||||||
// Wire close button — addEventListener avoids CSP inline handler restrictions
|
// Wire close button — addEventListener avoids CSP inline handler restrictions
|
||||||
const closeBtn = panel.querySelector('#status-close-btn');
|
const closeBtn = document.getElementById('status-close-btn');
|
||||||
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
|
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
|
||||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||||
@@ -1150,3 +1208,294 @@ function createHistoryCard(item) {
|
|||||||
card.appendChild(info);
|
card.appendChild(info);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Webhooks Configuration =====
|
||||||
|
let webhookSectionExpanded = false;
|
||||||
|
let webhookLoading = false;
|
||||||
|
let sonarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null };
|
||||||
|
let radarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null };
|
||||||
|
let webhookMetrics = null;
|
||||||
|
|
||||||
|
function initWebhooks() {
|
||||||
|
const webhooksSection = document.getElementById('webhooks-section');
|
||||||
|
if (!webhooksSection) return;
|
||||||
|
|
||||||
|
// Note: visibility is controlled by showDashboard() based on isAdmin
|
||||||
|
|
||||||
|
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
|
||||||
|
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
|
||||||
|
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
|
||||||
|
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
|
||||||
|
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWebhookSection() {
|
||||||
|
webhookSectionExpanded = !webhookSectionExpanded;
|
||||||
|
const content = document.getElementById('webhooks-content');
|
||||||
|
const toggle = document.getElementById('webhooks-toggle');
|
||||||
|
|
||||||
|
content.style.display = webhookSectionExpanded ? '' : 'none';
|
||||||
|
toggle.classList.toggle('expanded', webhookSectionExpanded);
|
||||||
|
|
||||||
|
if (webhookSectionExpanded) {
|
||||||
|
fetchWebhookStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWebhookMetrics() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/webhook-metrics');
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWebhookStatus() {
|
||||||
|
const loadingEl = document.getElementById('webhook-loading');
|
||||||
|
loadingEl.style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metrics in parallel
|
||||||
|
const metricsPromise = fetchWebhookMetrics();
|
||||||
|
|
||||||
|
// Fetch Sonarr notifications
|
||||||
|
let sonarrEnabled = false;
|
||||||
|
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
|
try {
|
||||||
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||||
|
if (sonarrRes.ok) {
|
||||||
|
const sonarrData = await sonarrRes.json();
|
||||||
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||||
|
sonarrEnabled = !!sonarrSofarr;
|
||||||
|
if (sonarrSofarr) {
|
||||||
|
sonarrTriggers = {
|
||||||
|
onGrab: sonarrSofarr.onGrab,
|
||||||
|
onDownload: sonarrSofarr.onDownload,
|
||||||
|
onImport: sonarrSofarr.onImport,
|
||||||
|
onUpgrade: sonarrSofarr.onUpgrade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Sonarr not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Radarr notifications
|
||||||
|
let radarrEnabled = false;
|
||||||
|
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
|
try {
|
||||||
|
const radarrRes = await fetch('/api/radarr/notifications');
|
||||||
|
if (radarrRes.ok) {
|
||||||
|
const radarrData = await radarrRes.json();
|
||||||
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||||
|
radarrEnabled = !!radarrSofarr;
|
||||||
|
if (radarrSofarr) {
|
||||||
|
radarrTriggers = {
|
||||||
|
onGrab: radarrSofarr.onGrab,
|
||||||
|
onDownload: radarrSofarr.onDownload,
|
||||||
|
onImport: radarrSofarr.onImport,
|
||||||
|
onUpgrade: radarrSofarr.onUpgrade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Radarr not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookMetrics = await metricsPromise;
|
||||||
|
|
||||||
|
// Find instance stats
|
||||||
|
const instanceEntries = webhookMetrics ? Object.entries(webhookMetrics.instances || {}) : [];
|
||||||
|
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||||
|
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||||
|
|
||||||
|
sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
||||||
|
radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
||||||
|
|
||||||
|
renderWebhookStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch webhook status:', err);
|
||||||
|
} finally {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWebhookStatus() {
|
||||||
|
// Sonarr
|
||||||
|
const sonarrStatus = document.getElementById('sonarr-status');
|
||||||
|
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
|
||||||
|
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
|
||||||
|
const sonarrTriggers = document.getElementById('sonarr-triggers');
|
||||||
|
const sonarrStats = document.getElementById('sonarr-stats');
|
||||||
|
|
||||||
|
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||||
|
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||||
|
sonarrEnableBtn.style.display = sonarrWebhook.enabled ? 'none' : '';
|
||||||
|
sonarrTestBtn.style.display = sonarrWebhook.enabled ? '' : 'none';
|
||||||
|
sonarrTriggers.style.display = sonarrWebhook.enabled ? '' : 'none';
|
||||||
|
|
||||||
|
if (sonarrWebhook.enabled) {
|
||||||
|
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||||
|
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||||
|
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||||
|
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||||
|
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||||
|
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||||
|
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||||
|
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sonarrWebhook.stats) {
|
||||||
|
sonarrStats.style.display = '';
|
||||||
|
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
|
||||||
|
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
|
||||||
|
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
|
||||||
|
} else {
|
||||||
|
sonarrStats.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radarr
|
||||||
|
const radarrStatus = document.getElementById('radarr-status');
|
||||||
|
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
|
||||||
|
const radarrTestBtn = document.getElementById('test-radarr-webhook');
|
||||||
|
const radarrTriggers = document.getElementById('radarr-triggers');
|
||||||
|
const radarrStats = document.getElementById('radarr-stats');
|
||||||
|
|
||||||
|
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||||
|
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||||
|
radarrEnableBtn.style.display = radarrWebhook.enabled ? 'none' : '';
|
||||||
|
radarrTestBtn.style.display = radarrWebhook.enabled ? '' : 'none';
|
||||||
|
radarrTriggers.style.display = radarrWebhook.enabled ? '' : 'none';
|
||||||
|
|
||||||
|
if (radarrWebhook.enabled) {
|
||||||
|
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||||
|
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||||
|
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||||
|
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||||
|
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||||
|
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||||
|
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||||
|
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (radarrWebhook.stats) {
|
||||||
|
radarrStats.style.display = '';
|
||||||
|
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
|
||||||
|
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
|
||||||
|
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
|
||||||
|
} else {
|
||||||
|
radarrStats.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp) {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (seconds < 60) return seconds + 's ago';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return minutes + 'm ago';
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return hours + 'h ago';
|
||||||
|
return Math.floor(hours / 24) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableSonarrWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Sonarr webhook:', err);
|
||||||
|
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableRadarrWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken || '' }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to enable');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable Radarr webhook:', err);
|
||||||
|
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSonarrWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||||
|
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||||
|
const sonarrData = await sonarrRes.json();
|
||||||
|
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||||
|
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
|
||||||
|
|
||||||
|
const res = await fetch('/api/sonarr/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(sonarrSofarr)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
alert('Sonarr webhook test sent successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Sonarr webhook:', err);
|
||||||
|
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRadarrWebhook() {
|
||||||
|
setWebhookLoading(true);
|
||||||
|
try {
|
||||||
|
const radarrRes = await fetch('/api/radarr/notifications');
|
||||||
|
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||||
|
const radarrData = await radarrRes.json();
|
||||||
|
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||||
|
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
|
||||||
|
|
||||||
|
const res = await fetch('/api/radarr/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify(radarrSofarr)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Test failed');
|
||||||
|
await fetchWebhookStatus();
|
||||||
|
alert('Radarr webhook test sent successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to test Radarr webhook:', err);
|
||||||
|
alert('Failed to test Radarr webhook. Check console for details.');
|
||||||
|
} finally {
|
||||||
|
setWebhookLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWebhookLoading(loading) {
|
||||||
|
webhookLoading = loading;
|
||||||
|
document.getElementById('enable-sonarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('enable-radarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('test-sonarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('test-radarr-webhook').disabled = loading;
|
||||||
|
document.getElementById('webhook-loading').style.display = loading ? '' : 'none';
|
||||||
|
}
|
||||||
|
|||||||
+63
-1
@@ -68,7 +68,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
<div id="status-panel" class="status-panel" style="display: none;">
|
||||||
|
<!-- Status content gets rendered here -->
|
||||||
|
<div id="status-content"><p class="status-loading">Loading status...</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Webhooks Configuration Panel (sibling to status-panel) -->
|
||||||
|
<div class="webhooks-section" id="webhooks-section" style="display: none;">
|
||||||
|
<div class="webhooks-header" id="webhooks-header">
|
||||||
|
<h2>⚡ Webhooks Configuration</h2>
|
||||||
|
<span class="webhooks-toggle" id="webhooks-toggle">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhooks-content" id="webhooks-content" style="display: none;">
|
||||||
|
<div id="webhook-loading" class="webhook-loading" style="display: none;">Loading webhook status...</div>
|
||||||
|
|
||||||
|
<!-- Sonarr Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Sonarr</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="sonarr-status">○ Disabled</span>
|
||||||
|
<button id="enable-sonarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-sonarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers" id="sonarr-triggers" style="display: none;">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="sonarr-onGrab">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="sonarr-onDownload">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="sonarr-onImport">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="sonarr-onUpgrade">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats" id="sonarr-stats" style="display: none;">
|
||||||
|
<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="sonarr-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="sonarr-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="sonarr-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radarr Webhook -->
|
||||||
|
<div class="webhook-instance">
|
||||||
|
<h3>Radarr</h3>
|
||||||
|
<div class="webhook-status">
|
||||||
|
<span class="status-indicator" id="radarr-status">○ Disabled</span>
|
||||||
|
<button id="enable-radarr-webhook" class="enable-webhook-btn" style="display: none;">Enable Sofarr Webhooks</button>
|
||||||
|
<button id="test-radarr-webhook" class="test-webhook-btn" style="display: none;">Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-triggers" id="radarr-triggers" style="display: none;">
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Grab</span><span class="trigger-value" id="radarr-onGrab">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Download</span><span class="trigger-value" id="radarr-onDownload">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Import</span><span class="trigger-value" id="radarr-onImport">✗</span></div>
|
||||||
|
<div class="trigger-item"><span class="trigger-label">On Upgrade</span><span class="trigger-value" id="radarr-onUpgrade">✗</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-stats" id="radarr-stats" style="display: none;">
|
||||||
|
<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="radarr-events">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Polls Skipped</span><span class="webhook-stat-value" id="radarr-polls">0</span></div>
|
||||||
|
<div class="webhook-stat"><span class="webhook-stat-label">Last Event</span><span class="webhook-stat-value" id="radarr-last">Never</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
--bg-gradient-end: #d4dee8;
|
--bg-gradient-end: #d4dee8;
|
||||||
|
|
||||||
/* Surfaces */
|
/* Surfaces */
|
||||||
|
--background: #f5f7f9;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-alt: #f0f4f7;
|
--surface-alt: #f0f4f7;
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg-gradient-start: #1a1a2e;
|
--bg-gradient-start: #1a1a2e;
|
||||||
--bg-gradient-end: #16213e;
|
--bg-gradient-end: #16213e;
|
||||||
|
--background: #161622;
|
||||||
--surface: #1e1e2f;
|
--surface: #1e1e2f;
|
||||||
--surface-alt: #2a2a3d;
|
--surface-alt: #2a2a3d;
|
||||||
--text-primary: #e0e0e0;
|
--text-primary: #e0e0e0;
|
||||||
@@ -136,6 +138,7 @@
|
|||||||
[data-theme="mono"] {
|
[data-theme="mono"] {
|
||||||
--bg-gradient-start: #222222;
|
--bg-gradient-start: #222222;
|
||||||
--bg-gradient-end: #333333;
|
--bg-gradient-end: #333333;
|
||||||
|
--background: #141414;
|
||||||
--surface: #1a1a1a;
|
--surface: #1a1a1a;
|
||||||
--surface-alt: #252525;
|
--surface-alt: #252525;
|
||||||
--text-primary: #d0d0d0;
|
--text-primary: #d0d0d0;
|
||||||
@@ -1232,6 +1235,22 @@ body {
|
|||||||
box-shadow: 0 2px 4px var(--shadow);
|
box-shadow: 0 2px 4px var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#status-content {
|
||||||
|
min-height: 150px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--background);
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-header {
|
.status-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -1516,3 +1535,219 @@ body {
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Webhooks Configuration ===== */
|
||||||
|
.webhooks-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 4px var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-header:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-header h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webhooks metrics styling to match status cards */
|
||||||
|
.webhook-stats {
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-toggle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-toggle.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhooks-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-instance {
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-instance:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-instance h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.enabled {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.disabled {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-webhook-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-webhook-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.enable-webhook-btn:disabled {
|
||||||
|
background: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-webhook-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: var(--info);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-webhook-btn:hover {
|
||||||
|
background: var(--info-hover, var(--info));
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-webhook-btn:disabled {
|
||||||
|
background: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-triggers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-value.active {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-value.inactive {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stats-title {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-stat-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ class PollingRadarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getQueue() {
|
async getQueue() {
|
||||||
try {
|
try {
|
||||||
|
// Fetch with large page size to get all items (Radarr has pagination)
|
||||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: { includeMovie: true }
|
params: { includeMovie: true, pageSize: 1000 }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -61,7 +62,7 @@ class PollingRadarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getHistory(options = {}) {
|
async getHistory(options = {}) {
|
||||||
const {
|
const {
|
||||||
pageSize = 10,
|
pageSize = 100,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDir,
|
sortDir,
|
||||||
includeMovie = true,
|
includeMovie = true,
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getQueue() {
|
async getQueue() {
|
||||||
try {
|
try {
|
||||||
|
// Fetch with large page size to get all items (Sonarr has pagination)
|
||||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: { includeSeries: true, includeEpisode: true }
|
params: { includeSeries: true, includeEpisode: true, pageSize: 1000 }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -62,7 +63,7 @@ class PollingSonarrRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getHistory(options = {}) {
|
async getHistory(options = {}) {
|
||||||
const {
|
const {
|
||||||
pageSize = 10,
|
pageSize = 100,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDir,
|
sortDir,
|
||||||
includeSeries = true,
|
includeSeries = true,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ class SABnzbdClient extends DownloadClient {
|
|||||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
||||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||||
category: slot.cat || undefined,
|
category: slot.cat || undefined,
|
||||||
tags: slot.labels ? slot.labels.split(',').filter(tag => tag.trim()) : [],
|
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
|
||||||
savePath: slot.final_name || undefined,
|
savePath: slot.final_name || undefined,
|
||||||
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
||||||
arrQueueId: arrInfo.queueId,
|
arrQueueId: arrInfo.queueId,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const embyRoutes = require('./routes/emby');
|
|||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
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 verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
@@ -252,6 +253,7 @@ function serveIndex(req, res) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/webhook', webhookRoutes);
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
// All routes below this point require CSRF validation on mutating methods
|
||||||
app.use('/api', verifyCsrf);
|
app.use('/api', verifyCsrf);
|
||||||
|
|||||||
+186
-10
@@ -772,7 +772,7 @@ router.get('/user-summary', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Admin-only status page with cache stats
|
// Admin-only status page with cache stats
|
||||||
router.get('/status', requireAuth, (req, res) => {
|
router.get('/status', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
if (!user.isAdmin) {
|
if (!user.isAdmin) {
|
||||||
@@ -782,6 +782,69 @@ router.get('/status', requireAuth, (req, res) => {
|
|||||||
const cacheStats = cache.getStats();
|
const cacheStats = cache.getStats();
|
||||||
const uptime = process.uptime();
|
const uptime = process.uptime();
|
||||||
|
|
||||||
|
// Get webhook metrics
|
||||||
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
|
const webhookMetrics = getGlobalWebhookMetrics();
|
||||||
|
|
||||||
|
// Check if Sofarr webhook is configured in Sonarr/Radarr
|
||||||
|
async function checkWebhookConfigured(instance, type) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
headers: { 'X-Api-Key': instance.apiKey },
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
const notifications = response.data || [];
|
||||||
|
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[Status] Failed to check ${type} webhook config: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check webhook configuration for each service
|
||||||
|
const sonarrInstances = getSonarrInstances();
|
||||||
|
const radarrInstances = getRadarrInstances();
|
||||||
|
|
||||||
|
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||||
|
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||||
|
: false;
|
||||||
|
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||||
|
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Find Sonarr and Radarr metrics from instances
|
||||||
|
const sonarrMetrics = {};
|
||||||
|
const radarrMetrics = {};
|
||||||
|
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||||
|
if (url.includes('sonarr')) {
|
||||||
|
sonarrMetrics[url] = metrics;
|
||||||
|
} else if (url.includes('radarr')) {
|
||||||
|
radarrMetrics[url] = metrics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate metrics for each service
|
||||||
|
const aggregateMetrics = (metricsMap, configured) => {
|
||||||
|
const values = Object.values(metricsMap);
|
||||||
|
if (values.length === 0) {
|
||||||
|
// Return default metrics if configured but no events yet
|
||||||
|
return configured ? {
|
||||||
|
enabled: true,
|
||||||
|
eventsReceived: 0,
|
||||||
|
pollsSkipped: 0,
|
||||||
|
lastEvent: null
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
|
||||||
|
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
|
||||||
|
lastEvent: values.reduce((latest, m) => {
|
||||||
|
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
server: {
|
server: {
|
||||||
uptimeSeconds: Math.floor(uptime),
|
uptimeSeconds: Math.floor(uptime),
|
||||||
@@ -796,13 +859,28 @@ router.get('/status', requireAuth, (req, res) => {
|
|||||||
lastPoll: getLastPollTimings()
|
lastPoll: getLastPollTimings()
|
||||||
},
|
},
|
||||||
cache: cacheStats,
|
cache: cacheStats,
|
||||||
clients: getActiveClients()
|
clients: getActiveClients(),
|
||||||
|
webhooks: {
|
||||||
|
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||||
|
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Webhook metrics — exposes global and per-instance webhook metrics for the
|
||||||
|
// Webhooks Configuration panel. Available to all authenticated users.
|
||||||
|
router.get('/webhook-metrics', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
|
res.json(getGlobalWebhookMetrics());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cover art proxy — fetches external poster images server-side so the
|
// Cover art proxy — fetches external poster images server-side so the
|
||||||
// browser loads them from 'self' and the CSP img-src stays tight.
|
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||||
// Requires authentication. Only proxies http/https URLs.
|
// Requires authentication. Only proxies http/https URLs.
|
||||||
@@ -868,6 +946,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
await pollAllServices();
|
await pollAllServices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`);
|
||||||
|
|
||||||
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||||
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||||
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
||||||
@@ -878,6 +958,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
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') || [];
|
||||||
|
|
||||||
|
console.log(`[SSE] Data sizes - SAB queue: ${sabQueueData.slots?.length || 0}, SAB history: ${sabHistoryData.slots?.length || 0}, qBit: ${qbittorrentTorrents.length}`);
|
||||||
|
console.log(`[SSE] Sonarr queue: ${sonarrQueueData.records?.length || 0}, history: ${sonarrHistoryData.records?.length || 0}`);
|
||||||
|
console.log(`[SSE] Radarr queue: ${radarrQueueData.records?.length || 0}, history: ${radarrHistoryData.records?.length || 0}`);
|
||||||
|
|
||||||
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
||||||
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||||
const sonarrQueue = { data: sonarrQueueData };
|
const sonarrQueue = { data: sonarrQueueData };
|
||||||
@@ -919,18 +1003,107 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SABnzbd queue
|
// SABnzbd queue
|
||||||
|
let sabSlotsChecked = 0;
|
||||||
|
let sabSlotsMatched = 0;
|
||||||
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
||||||
for (const slot of sabnzbdQueue.data.queue.slots) {
|
for (const slot of sabnzbdQueue.data.queue.slots) {
|
||||||
const nzbName = slot.filename || slot.nzbname;
|
const nzbName = slot.filename || slot.nzbname;
|
||||||
if (!nzbName) continue;
|
if (!nzbName) continue;
|
||||||
|
sabSlotsChecked++;
|
||||||
const slotState = getSlotStatusAndSpeed(slot);
|
const slotState = getSlotStatusAndSpeed(slot);
|
||||||
const nzbNameLower = nzbName.toLowerCase();
|
const nzbNameLower = nzbName.toLowerCase();
|
||||||
|
|
||||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
// Normalize SAB name (dots to spaces) for better matching
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
||||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
||||||
});
|
// Try to match by downloadId first (most reliable)
|
||||||
|
const sabDownloadId = slot.nzo_id || slot.id;
|
||||||
|
let sonarrMatch = sabDownloadId ? sonarrQueue.data.records.find(r => r.downloadId === sabDownloadId) : null;
|
||||||
|
let radarrMatch = sabDownloadId ? radarrQueue.data.records.find(r => r.downloadId === sabDownloadId) : null;
|
||||||
|
|
||||||
|
// Also check HISTORY by downloadId
|
||||||
|
if (!sonarrMatch && sabDownloadId) {
|
||||||
|
sonarrMatch = sonarrHistory.data.records.find(r => r.downloadId === sabDownloadId);
|
||||||
|
}
|
||||||
|
if (!radarrMatch && sabDownloadId) {
|
||||||
|
radarrMatch = radarrHistory.data.records.find(r => r.downloadId === sabDownloadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Check by title matching
|
||||||
|
if (!sonarrMatch) {
|
||||||
|
sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
return rTitle && (
|
||||||
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||||
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!radarrMatch) {
|
||||||
|
radarrMatch = radarrQueue.data.records.find(r => {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
return rTitle && (
|
||||||
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||||
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check HISTORY (completed downloads) if no queue match
|
||||||
|
if (!sonarrMatch) {
|
||||||
|
sonarrMatch = sonarrHistory.data.records.find(r => {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
return rTitle && (
|
||||||
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||||
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!radarrMatch) {
|
||||||
|
radarrMatch = radarrHistory.data.records.find(r => {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
return rTitle && (
|
||||||
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||||
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Debug first 5 items - show matches and non-matches
|
||||||
|
if (sabSlotsChecked <= 5) {
|
||||||
|
if (sonarrMatch) {
|
||||||
|
const source = sonarrQueue.data.records.includes(sonarrMatch) ? 'queue' : 'history';
|
||||||
|
const matchType = (sonarrMatch.downloadId === sabDownloadId) ? 'downloadId' : 'title';
|
||||||
|
console.log(`[SSE] ✓ Sonarr ${source} ${matchType} match: SAB:"${nzbNameLower.substring(0, 40)}" → Sonarr:"${(sonarrMatch.title || sonarrMatch.sourceTitle || '').substring(0, 40)}"`);
|
||||||
|
} else if (radarrMatch) {
|
||||||
|
const source = radarrQueue.data.records.includes(radarrMatch) ? 'queue' : 'history';
|
||||||
|
const matchType = (radarrMatch.downloadId === sabDownloadId) ? 'downloadId' : 'title';
|
||||||
|
console.log(`[SSE] ✓ Radarr ${source} ${matchType} match: SAB:"${nzbNameLower.substring(0, 40)}" → Radarr:"${(radarrMatch.title || radarrMatch.sourceTitle || '').substring(0, 40)}"`);
|
||||||
|
} else {
|
||||||
|
console.log(`[SSE] ✗ No match for SAB: "${nzbNameLower.substring(0, 60)}"`);
|
||||||
|
// Show counts
|
||||||
|
console.log(`[SSE] Queue: ${sonarrQueue.data.records.length}, History: ${sonarrHistory.data.records.length}`);
|
||||||
|
// Show Sonarr queue titles
|
||||||
|
if (sonarrQueue.data.records.length > 0) {
|
||||||
|
const queueTitles = sonarrQueue.data.records.slice(0, 3).map(r => (r.title || r.sourceTitle || 'NO_TITLE').substring(0, 40));
|
||||||
|
console.log(`[SSE] Queue titles: ${queueTitles.join(' | ')}`);
|
||||||
|
}
|
||||||
|
// Show history titles if there are any
|
||||||
|
if (sonarrHistory.data.records.length > 0) {
|
||||||
|
const histTitles = sonarrHistory.data.records.slice(0, 3).map(r => {
|
||||||
|
const title = (r.title || r.sourceTitle || 'NO_TITLE').substring(0, 35);
|
||||||
|
const dlId = r.downloadId ? r.downloadId.substring(0, 15) : 'no-dl-id';
|
||||||
|
return `${title}[${dlId}]`;
|
||||||
|
});
|
||||||
|
console.log(`[SSE] History titles: ${histTitles.join(' | ')}`);
|
||||||
|
}
|
||||||
|
// Also check if SAB slots have nzo_id we could use
|
||||||
|
if (slot.nzo_id) {
|
||||||
|
console.log(`[SSE] SAB nzo_id: ${slot.nzo_id.substring(0, 20)}...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||||
|
sabSlotsMatched++;
|
||||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||||
if (series) {
|
if (series) {
|
||||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
@@ -946,11 +1119,9 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
// Handle Radarr match (radarrMatch already declared above)
|
||||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
||||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
||||||
});
|
|
||||||
if (radarrMatch && radarrMatch.movieId) {
|
if (radarrMatch && radarrMatch.movieId) {
|
||||||
|
sabSlotsMatched++;
|
||||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||||
if (movie) {
|
if (movie) {
|
||||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
@@ -1083,6 +1254,11 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write SSE event
|
// Write SSE event
|
||||||
|
console.log(`[SSE] SAB matching: ${sabSlotsChecked} checked, ${sabSlotsMatched} matched to Sonarr/Radarr`);
|
||||||
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
||||||
|
if (userDownloads.length > 0) {
|
||||||
|
console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`);
|
||||||
|
}
|
||||||
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
|
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SSE] Error building payload:', sanitizeError(err));
|
console.error('[SSE] Error building payload:', sanitizeError(err));
|
||||||
|
|||||||
+47
-14
@@ -4,7 +4,16 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config');
|
||||||
|
|
||||||
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
|
function getFirstRadarrInstance() {
|
||||||
|
const instances = getRadarrInstances();
|
||||||
|
if (!instances || instances.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return instances[0];
|
||||||
|
}
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
@@ -60,12 +69,17 @@ router.get('/movies', async (req, res) => {
|
|||||||
// Notification proxy routes (Phase 3)
|
// Notification proxy routes (Phase 3)
|
||||||
// GET /api/radarr/notifications - list all notifications
|
// GET /api/radarr/notifications - list all notifications
|
||||||
router.get('/notifications', async (req, res) => {
|
router.get('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Radarr] Failed to fetch notifications:', error.message);
|
||||||
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -120,12 +134,21 @@ router.delete('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/radarr/notifications/test - test notification
|
// POST /api/radarr/notifications/test - test notification
|
||||||
router.post('/notifications/test', async (req, res) => {
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Radarr] Failed to test notification:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Radarr] Test response status:', error.response.status);
|
||||||
|
console.error('[Radarr] Test response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
const instance = getFirstRadarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
configContract: 'WebhookSettings',
|
configContract: 'WebhookSettings',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'url', value: webhookUrl },
|
{ name: 'url', value: webhookUrl },
|
||||||
{ name: 'method', value: 'POST' },
|
{ name: 'method', value: 1 },
|
||||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
],
|
],
|
||||||
onGrab: true,
|
onGrab: true,
|
||||||
onDownload: true,
|
onDownload: true,
|
||||||
onImport: true,
|
|
||||||
onUpgrade: true,
|
onUpgrade: true,
|
||||||
|
onImport: true,
|
||||||
onRename: false,
|
onRename: false,
|
||||||
onHealthIssue: false,
|
onHealthIssue: false,
|
||||||
onApplicationUpdate: false
|
onApplicationUpdate: false,
|
||||||
|
onManualInteractionRequired: false
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingNotification) {
|
if (existingNotification) {
|
||||||
// Update existing notification
|
// Update existing notification
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
|
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||||
{ ...notificationPayload, id: existingNotification.id },
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} else {
|
} else {
|
||||||
// Create new notification
|
// Create new notification
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${process.env.RADARR_URL}/api/v3/notification`,
|
`${instance.url}/api/v3/notification`,
|
||||||
notificationPayload,
|
notificationPayload,
|
||||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Radarr] Failed to configure webhook:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Radarr] Response status:', error.response.status);
|
||||||
|
console.error('[Radarr] Response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+47
-14
@@ -4,7 +4,16 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config');
|
||||||
|
|
||||||
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
|
function getFirstSonarrInstance() {
|
||||||
|
const instances = getSonarrInstances();
|
||||||
|
if (!instances || instances.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return instances[0];
|
||||||
|
}
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
@@ -60,12 +69,17 @@ router.get('/series', async (req, res) => {
|
|||||||
// Notification proxy routes (Phase 3)
|
// Notification proxy routes (Phase 3)
|
||||||
// GET /api/sonarr/notifications - list all notifications
|
// GET /api/sonarr/notifications - list all notifications
|
||||||
router.get('/notifications', async (req, res) => {
|
router.get('/notifications', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Sonarr] Failed to fetch notifications:', error.message);
|
||||||
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -120,12 +134,21 @@ router.delete('/notifications/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/sonarr/notifications/test - test notification
|
// POST /api/sonarr/notifications/test - test notification
|
||||||
router.post('/notifications/test', async (req, res) => {
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
|
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Sonarr] Failed to test notification:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Sonarr] Test response status:', error.response.status);
|
||||||
|
console.error('[Sonarr] Test response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
|
|||||||
|
|
||||||
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
const instance = getFirstSonarrInstance();
|
||||||
|
if (!instance) {
|
||||||
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
// Check if Sofarr webhook already exists
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
headers: { 'X-Api-Key': instance.apiKey }
|
||||||
});
|
});
|
||||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
configContract: 'WebhookSettings',
|
configContract: 'WebhookSettings',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'url', value: webhookUrl },
|
{ name: 'url', value: webhookUrl },
|
||||||
{ name: 'method', value: 'POST' },
|
{ name: 'method', value: 1 },
|
||||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
],
|
],
|
||||||
onGrab: true,
|
onGrab: true,
|
||||||
onDownload: true,
|
onDownload: true,
|
||||||
onImport: true,
|
|
||||||
onUpgrade: true,
|
onUpgrade: true,
|
||||||
|
onImport: true,
|
||||||
onRename: false,
|
onRename: false,
|
||||||
onHealthIssue: false,
|
onHealthIssue: false,
|
||||||
onApplicationUpdate: false
|
onApplicationUpdate: false,
|
||||||
|
onManualInteractionRequired: false
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingNotification) {
|
if (existingNotification) {
|
||||||
// Update existing notification
|
// Update existing notification
|
||||||
const response = await axios.put(
|
const response = await axios.put(
|
||||||
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
|
`${instance.url}/api/v3/notification/${existingNotification.id}`,
|
||||||
{ ...notificationPayload, id: existingNotification.id },
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
} else {
|
} else {
|
||||||
// Create new notification
|
// Create new notification
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${process.env.SONARR_URL}/api/v3/notification`,
|
`${instance.url}/api/v3/notification`,
|
||||||
notificationPayload,
|
notificationPayload,
|
||||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||||
);
|
);
|
||||||
res.json(response.data);
|
res.json(response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[Sonarr] Failed to configure webhook:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[Sonarr] Response status:', error.response.status);
|
||||||
|
console.error('[Sonarr] Response data:', error.response.data);
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -247,10 +247,14 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||||
|
|
||||||
// Phase 5.1: update webhook metrics for polling optimization
|
// Phase 5.1: update webhook metrics for polling optimization
|
||||||
|
// Note: instanceName from webhook is often generic (e.g., "Sonarr"), not the configured name
|
||||||
|
// Update metrics for all Sonarr instances since we can't reliably match
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const instance = sonarrInstances.find(i => i.name === instanceName);
|
if (sonarrInstances.length > 0) {
|
||||||
if (instance) {
|
for (const inst of sonarrInstances) {
|
||||||
cache.updateWebhookMetrics(instance.url);
|
cache.updateWebhookMetrics(inst.url);
|
||||||
|
}
|
||||||
|
logToFile(`[Webhook] Updated metrics for ${sonarrInstances.length} Sonarr instance(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
@@ -296,10 +300,14 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
|
||||||
|
|
||||||
// Phase 5.1: update webhook metrics for polling optimization
|
// Phase 5.1: update webhook metrics for polling optimization
|
||||||
|
// Note: instanceName from webhook is often generic (e.g., "Radarr"), not the configured name
|
||||||
|
// Update metrics for all Radarr instances since we can't reliably match
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
const instance = radarrInstances.find(i => i.name === instanceName);
|
if (radarrInstances.length > 0) {
|
||||||
if (instance) {
|
for (const inst of radarrInstances) {
|
||||||
cache.updateWebhookMetrics(instance.url);
|
cache.updateWebhookMetrics(inst.url);
|
||||||
|
}
|
||||||
|
logToFile(`[Webhook] Updated metrics for ${radarrInstances.length} Radarr instance(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ const arrRetrieverRegistry = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retriever = new RetrieverClass(config);
|
const retriever = new RetrieverClass(config);
|
||||||
this.retrievers.set(config.id, retriever);
|
const uniqueKey = `${config.type}:${config.id}`;
|
||||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
|
this.retrievers.set(uniqueKey, retriever);
|
||||||
|
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ class DownloadClientRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new ClientClass(config);
|
const client = new ClientClass(config);
|
||||||
this.clients.set(config.id, client);
|
const uniqueKey = `${config.type}:${config.id}`;
|
||||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
|
this.clients.set(uniqueKey, client);
|
||||||
|
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user