Compare commits
48 Commits
release/1.5.0a
...
v1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e06bdf8cd | |||
| 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 | |||
| 740b03ac85 | |||
| 575688dab7 | |||
| 84658102e0 | |||
| ae9e877445 | |||
| 853b205c46 | |||
| 65b9f0f395 | |||
| 20dfe06866 | |||
| 3bb9e936c3 |
@@ -6,6 +6,25 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.5.3] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Status panel rendering regression** — the status panel was rendering as a small blank box due to an undefined `--background` CSS variable. Added the missing variable to all three themes (light, dark, mono).
|
||||||
|
- **Status panel content destruction** — `showDashboard()` was calling `sp.innerHTML = ''` which destroyed the `status-content` div inside the status panel. Removed this destructive line.
|
||||||
|
- **Webhooks panel visibility sync** — the webhooks panel was incorrectly visible on app load. Added explicit hiding of `webhooks-section` in `showDashboard()` to keep it in sync with the status panel (both show/hide together via `toggleStatusPanel()`).
|
||||||
|
- **Webhooks panel DOM structure** — reverted webhooks-section to be a sibling of status-panel (not nested inside it), preventing innerHTML operations from affecting webhook elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.2] - 2026-05-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Status panel close button CSP compliance** — replaced inline `onclick` handler with `addEventListener` to comply with CSP nonce policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.5.1] - 2026-05-19
|
## [1.5.1] - 2026-05-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
|
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
|
||||||
|
|
||||||
Version 1.4.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
Version 1.5.x adds **real-time webhook integration**: Sonarr and Radarr can push events directly to sofarr, eliminating polling latency and automatically reducing background API calls when webhooks are active.
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
|
|||||||
@@ -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.1",
|
"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,
|
||||||
|
|||||||
+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