Compare commits
35 Commits
| 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 |
@@ -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
|
||||
|
||||
### 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!
|
||||
|
||||
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
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+1501
-48
File diff suppressed because one or more lines are too long
+184
-10
@@ -1,14 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Media Download Dashboard</title>
|
||||
<script type="module" crossorigin src="app.js"></script>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sofarr - Your Downloads Dashboard</title>
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash Screen -->
|
||||
<div id="splash-screen" class="splash-screen">
|
||||
<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>
|
||||
|
||||
+1753
-1
File diff suppressed because one or more lines are too long
@@ -38,9 +38,10 @@ class PollingRadarrRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getQueue() {
|
||||
try {
|
||||
// Fetch with large page size to get all items (Radarr has pagination)
|
||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: { includeMovie: true }
|
||||
params: { includeMovie: true, pageSize: 1000 }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -61,7 +62,7 @@ class PollingRadarrRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
const {
|
||||
pageSize = 10,
|
||||
pageSize = 100,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeMovie = true,
|
||||
|
||||
@@ -38,9 +38,10 @@ class PollingSonarrRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getQueue() {
|
||||
try {
|
||||
// Fetch with large page size to get all items (Sonarr has pagination)
|
||||
const response = await axios.get(`${this.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
params: { includeSeries: true, includeEpisode: true, pageSize: 1000 }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -62,7 +63,7 @@ class PollingSonarrRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getHistory(options = {}) {
|
||||
const {
|
||||
pageSize = 10,
|
||||
pageSize = 100,
|
||||
sortKey,
|
||||
sortDir,
|
||||
includeSeries = true,
|
||||
|
||||
@@ -167,7 +167,7 @@ class SABnzbdClient extends DownloadClient {
|
||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||
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,
|
||||
addedOn: slot.added ? new Date(slot.added * 1000).toISOString() : undefined,
|
||||
arrQueueId: arrInfo.queueId,
|
||||
|
||||
+175
-10
@@ -772,7 +772,7 @@ router.get('/user-summary', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Admin-only status page with cache stats
|
||||
router.get('/status', requireAuth, (req, res) => {
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user.isAdmin) {
|
||||
@@ -782,6 +782,69 @@ router.get('/status', requireAuth, (req, res) => {
|
||||
const cacheStats = cache.getStats();
|
||||
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({
|
||||
server: {
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
@@ -796,7 +859,11 @@ router.get('/status', requireAuth, (req, res) => {
|
||||
lastPoll: getLastPollTimings()
|
||||
},
|
||||
cache: cacheStats,
|
||||
clients: getActiveClients()
|
||||
clients: getActiveClients(),
|
||||
webhooks: {
|
||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
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();
|
||||
}
|
||||
|
||||
console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`);
|
||||
|
||||
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||
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 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 sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||
const sonarrQueue = { data: sonarrQueueData };
|
||||
@@ -930,18 +1003,107 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// SABnzbd queue
|
||||
let sabSlotsChecked = 0;
|
||||
let sabSlotsMatched = 0;
|
||||
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
||||
for (const slot of sabnzbdQueue.data.queue.slots) {
|
||||
const nzbName = slot.filename || slot.nzbname;
|
||||
if (!nzbName) continue;
|
||||
sabSlotsChecked++;
|
||||
const slotState = getSlotStatusAndSpeed(slot);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
// Normalize SAB name (dots to spaces) for better matching
|
||||
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
||||
|
||||
// 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) {
|
||||
sabSlotsMatched++;
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
@@ -957,11 +1119,9 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const radarrMatch = radarrQueue.data.records.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
// Handle Radarr match (radarrMatch already declared above)
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
sabSlotsMatched++;
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
@@ -1094,6 +1254,11 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// 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`);
|
||||
} catch (err) {
|
||||
console.error('[SSE] Error building payload:', sanitizeError(err));
|
||||
|
||||
+47
-14
@@ -4,7 +4,16 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
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);
|
||||
|
||||
@@ -60,12 +69,17 @@ router.get('/movies', async (req, res) => {
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/radarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Radarr] Failed to fetch notifications:', error.message);
|
||||
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
|
||||
router.post('/notifications/test', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} 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) });
|
||||
}
|
||||
});
|
||||
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
|
||||
|
||||
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const instance = getFirstRadarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Radarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||
|
||||
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
configContract: 'WebhookSettings',
|
||||
fields: [
|
||||
{ name: 'url', value: webhookUrl },
|
||||
{ name: 'method', value: 'POST' },
|
||||
{ name: 'method', value: 1 },
|
||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||
],
|
||||
onGrab: true,
|
||||
onDownload: true,
|
||||
onImport: true,
|
||||
onUpgrade: true,
|
||||
onImport: true,
|
||||
onRename: false,
|
||||
onHealthIssue: false,
|
||||
onApplicationUpdate: false
|
||||
onApplicationUpdate: false,
|
||||
onManualInteractionRequired: false
|
||||
};
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification
|
||||
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 },
|
||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
} else {
|
||||
// Create new notification
|
||||
const response = await axios.post(
|
||||
`${process.env.RADARR_URL}/api/v3/notification`,
|
||||
`${instance.url}/api/v3/notification`,
|
||||
notificationPayload,
|
||||
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
}
|
||||
} 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) });
|
||||
}
|
||||
});
|
||||
|
||||
+47
-14
@@ -4,7 +4,16 @@ const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
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);
|
||||
|
||||
@@ -60,12 +69,17 @@ router.get('/series', async (req, res) => {
|
||||
// Notification proxy routes (Phase 3)
|
||||
// GET /api/sonarr/notifications - list all notifications
|
||||
router.get('/notifications', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('[Sonarr] Failed to fetch notifications:', error.message);
|
||||
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
|
||||
router.post('/notifications/test', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const response = await axios.post(`${instance.url}/api/v3/notification/test`, req.body, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
res.json(response.data);
|
||||
} 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) });
|
||||
}
|
||||
});
|
||||
@@ -144,6 +167,10 @@ router.get('/notifications/schema', async (req, res) => {
|
||||
|
||||
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const instance = getFirstSonarrInstance();
|
||||
if (!instance) {
|
||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||
}
|
||||
try {
|
||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||
const webhookSecret = getWebhookSecret();
|
||||
@@ -158,8 +185,8 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||
|
||||
// Check if Sofarr webhook already exists
|
||||
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||
headers: { 'X-Api-Key': instance.apiKey }
|
||||
});
|
||||
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||
|
||||
@@ -169,36 +196,42 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||
configContract: 'WebhookSettings',
|
||||
fields: [
|
||||
{ name: 'url', value: webhookUrl },
|
||||
{ name: 'method', value: 'POST' },
|
||||
{ name: 'method', value: 1 },
|
||||
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||
],
|
||||
onGrab: true,
|
||||
onDownload: true,
|
||||
onImport: true,
|
||||
onUpgrade: true,
|
||||
onImport: true,
|
||||
onRename: false,
|
||||
onHealthIssue: false,
|
||||
onApplicationUpdate: false
|
||||
onApplicationUpdate: false,
|
||||
onManualInteractionRequired: false
|
||||
};
|
||||
|
||||
if (existingNotification) {
|
||||
// Update existing notification
|
||||
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 },
|
||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
} else {
|
||||
// Create new notification
|
||||
const response = await axios.post(
|
||||
`${process.env.SONARR_URL}/api/v3/notification`,
|
||||
`${instance.url}/api/v3/notification`,
|
||||
notificationPayload,
|
||||
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
||||
{ headers: { 'X-Api-Key': instance.apiKey } }
|
||||
);
|
||||
res.json(response.data);
|
||||
}
|
||||
} 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) });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -247,10 +247,14 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
|
||||
|
||||
// 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 instance = sonarrInstances.find(i => i.name === instanceName);
|
||||
if (instance) {
|
||||
cache.updateWebhookMetrics(instance.url);
|
||||
if (sonarrInstances.length > 0) {
|
||||
for (const inst of sonarrInstances) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
}
|
||||
logToFile(`[Webhook] Updated metrics for ${sonarrInstances.length} Sonarr instance(s)`);
|
||||
}
|
||||
|
||||
// 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)}`);
|
||||
|
||||
// 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 instance = radarrInstances.find(i => i.name === instanceName);
|
||||
if (instance) {
|
||||
cache.updateWebhookMetrics(instance.url);
|
||||
if (radarrInstances.length > 0) {
|
||||
for (const inst of radarrInstances) {
|
||||
cache.updateWebhookMetrics(inst.url);
|
||||
}
|
||||
logToFile(`[Webhook] Updated metrics for ${radarrInstances.length} Radarr instance(s)`);
|
||||
}
|
||||
|
||||
// Phase 2: background cache refresh + SSE broadcast (fire-and-forget)
|
||||
|
||||
@@ -51,8 +51,9 @@ const arrRetrieverRegistry = {
|
||||
}
|
||||
|
||||
const retriever = new RetrieverClass(config);
|
||||
this.retrievers.set(config.id, retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`);
|
||||
const uniqueKey = `${config.type}:${config.id}`;
|
||||
this.retrievers.set(uniqueKey, retriever);
|
||||
logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${uniqueKey})`);
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -63,8 +63,9 @@ class DownloadClientRegistry {
|
||||
}
|
||||
|
||||
const client = new ClientClass(config);
|
||||
this.clients.set(config.id, client);
|
||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${config.id})`);
|
||||
const uniqueKey = `${config.type}:${config.id}`;
|
||||
this.clients.set(uniqueKey, client);
|
||||
logToFile(`[DownloadClientRegistry] Created ${config.type} client: ${config.name} (${uniqueKey})`);
|
||||
} catch (error) {
|
||||
logToFile(`[DownloadClientRegistry] Failed to create client ${config.id}: ${error.message}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user