Merge branch 'develop'
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.5.2",
|
"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": {
|
||||||
|
|||||||
1549
public/app.js
1549
public/app.js
File diff suppressed because one or more lines are too long
@@ -1,14 +1,188 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Media Download Dashboard</title>
|
<title>sofarr - Your Downloads Dashboard</title>
|
||||||
<script type="module" crossorigin src="app.js"></script>
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||||
|
<meta name="theme-color" content="#1a1a2e">
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<!-- Splash Screen -->
|
||||||
|
<div id="splash-screen" class="splash-screen">
|
||||||
</body>
|
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<!-- Login Form -->
|
||||||
|
<div id="login-container" class="login-container" style="display: none;">
|
||||||
|
<div class="login-box">
|
||||||
|
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
||||||
|
<p class="login-subtitle">Login with your Emby credentials</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input type="text" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group form-group--checkbox">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="remember-me" name="rememberMe">
|
||||||
|
<span>Keep me logged in</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="login-btn">Login</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||||
|
<div class="header-controls">
|
||||||
|
<div class="theme-switcher">
|
||||||
|
<button class="theme-btn active" data-theme="light">Light</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="mono">Mono</button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-controls" class="admin-controls" style="display: none;">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="show-all-toggle">
|
||||||
|
<span>Show all users</span>
|
||||||
|
</label>
|
||||||
|
<button id="status-btn" class="status-btn">Status</button>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-label">Current User:</span>
|
||||||
|
<span class="user-name" id="currentUser">-</span>
|
||||||
|
<button id="logout-btn" class="logout-btn">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||||
|
|
||||||
|
<div class="main-tabs">
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||||
|
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-downloads">
|
||||||
|
<div class="downloads-container">
|
||||||
|
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||||
|
<p>No downloads found for your user.</p>
|
||||||
|
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-list" class="downloads-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||||
|
<div class="history-container" id="history-container">
|
||||||
|
<div class="history-header">
|
||||||
|
<div class="history-controls">
|
||||||
|
<label class="history-days-label" for="history-days">Last</label>
|
||||||
|
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||||
|
<span class="history-days-label">days</span>
|
||||||
|
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||||
|
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
||||||
|
<input type="checkbox" id="ignore-available-toggle">
|
||||||
|
<span>Hide upgrade failures</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||||
|
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||||
|
<div id="no-history" class="no-history" style="display: none;">
|
||||||
|
<p>No completed downloads found in this period.</p>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||||
|
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1754
public/style.css
1754
public/style.css
File diff suppressed because one or more lines are too long
@@ -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,
|
||||||
|
|||||||
@@ -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,7 +859,11 @@ 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 });
|
||||||
@@ -879,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') || [];
|
||||||
@@ -889,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 };
|
||||||
@@ -930,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);
|
||||||
@@ -957,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);
|
||||||
@@ -1094,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));
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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