Merge branch 'develop'
All checks were successful
Build and Push Docker Image / build (push) Successful in 43s
CI / Security audit (push) Successful in 1m23s
Create Release / release (push) Successful in 11s
CI / Tests & coverage (push) Successful in 1m42s

This commit is contained in:
2026-05-19 23:09:23 +01:00
13 changed files with 3735 additions and 113 deletions

View File

@@ -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": {

File diff suppressed because one or more lines are too long

View File

@@ -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">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
</body> <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">&#8635;</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>

File diff suppressed because one or more lines are too long

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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));

View File

@@ -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) });
} }
}); });

View File

@@ -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) });
} }
}); });

View File

@@ -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)

View File

@@ -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}`);
} }

View File

@@ -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}`);
} }