From 627329df2f66204f7c616c425e940b9cbb9dd5b4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 14:43:28 +0100 Subject: [PATCH 01/12] feat: implement Pluggable Abstraction Layer for Data Retrieval (PALDRA) - #19 - Create ArrRetriever abstract base class defining pluggable interface - Implement PollingSonarrRetriever and PollingRadarrRetriever with HTTP polling - Add ArrRetrieverRegistry for managing retriever instances - Refactor poller.js to use retriever registry instead of direct Axios calls - Update historyFetcher.js to use retriever registry - Preserve all cache keys, TTLs, timing logs, SSE broadcasts, error handling - Enable future webhook listeners without touching poller logic --- server/clients/ArrRetriever.js | 81 ++++++ server/clients/PollingRadarrRetriever.js | 96 +++++++ server/clients/PollingSonarrRetriever.js | 99 +++++++ server/utils/arrRetrievers.js | 327 +++++++++++++++++++++++ server/utils/historyFetcher.js | 58 ++-- server/utils/poller.js | 80 ++---- 6 files changed, 664 insertions(+), 77 deletions(-) create mode 100644 server/clients/ArrRetriever.js create mode 100644 server/clients/PollingRadarrRetriever.js create mode 100644 server/clients/PollingSonarrRetriever.js create mode 100644 server/utils/arrRetrievers.js diff --git a/server/clients/ArrRetriever.js b/server/clients/ArrRetriever.js new file mode 100644 index 0000000..e96eb22 --- /dev/null +++ b/server/clients/ArrRetriever.js @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +/** + * Abstract base class for all *arr data retrievers. + * Defines the common interface that all retrievers must implement. + * This pluggable layer enables future retrieval strategies (e.g., webhook listeners) + * to push normalized data directly into the existing cache and SSE system + * without touching the poller logic. + */ +class ArrRetriever { + /** + * @param {Object} instanceConfig - Configuration for this retriever instance + * @param {string} instanceConfig.id - Unique identifier for this instance + * @param {string} instanceConfig.name - Display name for this instance + * @param {string} instanceConfig.url - Base URL for the *arr API + * @param {string} instanceConfig.apiKey - API key for authentication + */ + constructor(instanceConfig) { + if (this.constructor === ArrRetriever) { + throw new Error('ArrRetriever is an abstract class and cannot be instantiated directly'); + } + + this.id = instanceConfig.id; + this.name = instanceConfig.name; + this.url = instanceConfig.url; + this.apiKey = instanceConfig.apiKey; + } + + /** + * Get the retriever type identifier (e.g., 'sonarr', 'radarr') + * @returns {string} The retriever type + */ + getRetrieverType() { + throw new Error('getRetrieverType() must be implemented by subclass'); + } + + /** + * Get the unique instance ID + * @returns {string} The instance ID + */ + getInstanceId() { + return this.id; + } + + /** + * Get tags from this *arr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Array of tag objects + */ + async getTags(instanceConfig) { + throw new Error('getTags() must be implemented by subclass'); + } + + /** + * Get queue from this *arr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Queue object with records array + */ + async getQueue(instanceConfig) { + throw new Error('getQueue() must be implemented by subclass'); + } + + /** + * Get history from this *arr instance + * @param {Object} instanceConfig - Configuration for the instance + * @param {Object} options - Optional parameters for history fetch + * @param {number} [options.pageSize] - Number of records to fetch + * @param {string} [options.sortKey] - Field to sort by + * @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending') + * @param {boolean} [options.includeSeries] - Include series data (Sonarr) + * @param {boolean} [options.includeEpisode] - Include episode data (Sonarr) + * @param {boolean} [options.includeMovie] - Include movie data (Radarr) + * @param {string} [options.startDate] - ISO date string for filtering + * @returns {Promise} History object with records array + */ + async getHistory(instanceConfig, options = {}) { + throw new Error('getHistory() must be implemented by subclass'); + } +} + +module.exports = ArrRetriever; diff --git a/server/clients/PollingRadarrRetriever.js b/server/clients/PollingRadarrRetriever.js new file mode 100644 index 0000000..16ad9f8 --- /dev/null +++ b/server/clients/PollingRadarrRetriever.js @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +const axios = require('axios'); +const ArrRetriever = require('./ArrRetriever'); +const { logToFile } = require('../utils/logger'); + +/** + * Polling-based Radarr data retriever. + * Implements the ArrRetriever interface using direct HTTP polling. + */ +class PollingRadarrRetriever extends ArrRetriever { + constructor(instanceConfig) { + super(instanceConfig); + } + + getRetrieverType() { + return 'radarr'; + } + + /** + * Get tags from Radarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Array of tag objects + */ + async getTags(instanceConfig) { + try { + const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { + headers: { 'X-Api-Key': instanceConfig.apiKey } + }); + return response.data; + } catch (error) { + logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + return []; + } + } + + /** + * Get queue from Radarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Queue object with records array + */ + async getQueue(instanceConfig) { + try { + const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { + headers: { 'X-Api-Key': instanceConfig.apiKey }, + params: { includeMovie: true } + }); + return response.data; + } catch (error) { + logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + return { records: [] }; + } + } + + /** + * Get history from Radarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @param {Object} options - Optional parameters for history fetch + * @param {number} [options.pageSize=10] - Number of records to fetch + * @param {string} [options.sortKey] - Field to sort by + * @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending') + * @param {boolean} [options.includeMovie=true] - Include movie data + * @param {string} [options.startDate] - ISO date string for filtering + * @returns {Promise} History object with records array + */ + async getHistory(instanceConfig, options = {}) { + const { + pageSize = 10, + sortKey, + sortDir, + includeMovie = true, + startDate + } = options; + + try { + const params = { + pageSize, + includeMovie + }; + + if (sortKey) params.sortKey = sortKey; + if (sortDir) params.sortDir = sortDir; + if (startDate) params.startDate = startDate; + + const response = await axios.get(`${instanceConfig.url}/api/v3/history`, { + headers: { 'X-Api-Key': instanceConfig.apiKey }, + params + }); + return response.data; + } catch (error) { + logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + return { records: [] }; + } + } +} + +module.exports = PollingRadarrRetriever; diff --git a/server/clients/PollingSonarrRetriever.js b/server/clients/PollingSonarrRetriever.js new file mode 100644 index 0000000..1211038 --- /dev/null +++ b/server/clients/PollingSonarrRetriever.js @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +const axios = require('axios'); +const ArrRetriever = require('./ArrRetriever'); +const { logToFile } = require('../utils/logger'); + +/** + * Polling-based Sonarr data retriever. + * Implements the ArrRetriever interface using direct HTTP polling. + */ +class PollingSonarrRetriever extends ArrRetriever { + constructor(instanceConfig) { + super(instanceConfig); + } + + getRetrieverType() { + return 'sonarr'; + } + + /** + * Get tags from Sonarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Array of tag objects + */ + async getTags(instanceConfig) { + try { + const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { + headers: { 'X-Api-Key': instanceConfig.apiKey } + }); + return response.data; + } catch (error) { + logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + return []; + } + } + + /** + * Get queue from Sonarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @returns {Promise} Queue object with records array + */ + async getQueue(instanceConfig) { + try { + const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { + headers: { 'X-Api-Key': instanceConfig.apiKey }, + params: { includeSeries: true, includeEpisode: true } + }); + return response.data; + } catch (error) { + logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + return { records: [] }; + } + } + + /** + * Get history from Sonarr instance + * @param {Object} instanceConfig - Configuration for the instance + * @param {Object} options - Optional parameters for history fetch + * @param {number} [options.pageSize=10] - Number of records to fetch + * @param {string} [options.sortKey] - Field to sort by + * @param {string} [options.sortDir] - Sort direction ('ascending' or 'descending') + * @param {boolean} [options.includeSeries=true] - Include series data + * @param {boolean} [options.includeEpisode=true] - Include episode data + * @param {string} [options.startDate] - ISO date string for filtering + * @returns {Promise} History object with records array + */ + async getHistory(instanceConfig, options = {}) { + const { + pageSize = 10, + sortKey, + sortDir, + includeSeries = true, + includeEpisode = true, + startDate + } = options; + + try { + const params = { + pageSize, + includeSeries, + includeEpisode + }; + + if (sortKey) params.sortKey = sortKey; + if (sortDir) params.sortDir = sortDir; + if (startDate) params.startDate = startDate; + + const response = await axios.get(`${instanceConfig.url}/api/v3/history`, { + headers: { 'X-Api-Key': instanceConfig.apiKey }, + params + }); + return response.data; + } catch (error) { + logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + return { records: [] }; + } + } +} + +module.exports = PollingSonarrRetriever; diff --git a/server/utils/arrRetrievers.js b/server/utils/arrRetrievers.js new file mode 100644 index 0000000..4a995f2 --- /dev/null +++ b/server/utils/arrRetrievers.js @@ -0,0 +1,327 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +const { logToFile } = require('./logger'); +const { + getSonarrInstances, + getRadarrInstances +} = require('./config'); + +// Import retriever classes +const PollingSonarrRetriever = require('../clients/PollingSonarrRetriever'); +const PollingRadarrRetriever = require('../clients/PollingRadarrRetriever'); + +// Retriever type mapping +const retrieverClasses = { + sonarr: PollingSonarrRetriever, + radarr: PollingRadarrRetriever +}; + +/** + * Registry and factory for *arr data retrievers + */ +class ArrRetrieverRegistry { + constructor() { + this.retrievers = new Map(); + this.initialized = false; + } + + /** + * Initialize all configured *arr retrievers + */ + async initialize() { + if (this.initialized) { + return; + } + + logToFile('[ArrRetrieverRegistry] Initializing *arr retrievers...'); + + // Get all instance configurations + const sonarrInstances = getSonarrInstances(); + const radarrInstances = getRadarrInstances(); + + // Create retriever instances + const instanceConfigs = [ + ...sonarrInstances.map(inst => ({ ...inst, type: 'sonarr' })), + ...radarrInstances.map(inst => ({ ...inst, type: 'radarr' })) + ]; + + for (const config of instanceConfigs) { + try { + const RetrieverClass = retrieverClasses[config.type]; + if (!RetrieverClass) { + logToFile(`[ArrRetrieverRegistry] Unknown retriever type: ${config.type}`); + continue; + } + + const retriever = new RetrieverClass(config); + this.retrievers.set(config.id, retriever); + logToFile(`[ArrRetrieverRegistry] Created ${config.type} retriever: ${config.name} (${config.id})`); + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Failed to create retriever ${config.id}: ${error.message}`); + } + } + + this.initialized = true; + logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`); + } + + /** + * Get all registered retrievers + * @returns {Array} Array of retriever instances + */ + getAllRetrievers() { + return Array.from(this.retrievers.values()); + } + + /** + * Get retriever by instance ID + * @param {string} instanceId - The instance ID + * @returns {ArrRetriever|null} Retriever instance or null if not found + */ + getRetriever(instanceId) { + return this.retrievers.get(instanceId) || null; + } + + /** + * Get retrievers by type + * @param {string} type - Retriever type ('sonarr', 'radarr') + * @returns {Array} Array of retriever instances + */ + getRetrieversByType(type) { + return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type); + } + + /** + * Get tags from all retrievers + * @returns {Promise>} Array of tag results with instance info + */ + async getAllTags() { + const retrievers = this.getAllRetrievers(); + if (retrievers.length === 0) { + return []; + } + + // Fetch tags from all retrievers in parallel + const results = await Promise.allSettled( + retrievers.map(async (retriever) => { + try { + const tags = await retriever.getTags(retriever); + logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`); + return { instance: retriever.getInstanceId(), data: tags }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: [] }; + } + }) + ); + + return results + .filter(result => result.status === 'fulfilled') + .map(result => result.value); + } + + /** + * Get queue from all retrievers + * @returns {Promise>} Array of queue results with instance info + */ + async getAllQueues() { + const retrievers = this.getAllRetrievers(); + if (retrievers.length === 0) { + return []; + } + + // Fetch queues from all retrievers in parallel + const results = await Promise.allSettled( + retrievers.map(async (retriever) => { + try { + const queue = await retriever.getQueue(retriever); + logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`); + return { instance: retriever.getInstanceId(), data: queue }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + return results + .filter(result => result.status === 'fulfilled') + .map(result => result.value); + } + + /** + * Get history from all retrievers + * @param {Object} options - Optional parameters for history fetch + * @returns {Promise>} Array of history results with instance info + */ + async getAllHistory(options = {}) { + const retrievers = this.getAllRetrievers(); + if (retrievers.length === 0) { + return []; + } + + // Fetch history from all retrievers in parallel + const results = await Promise.allSettled( + retrievers.map(async (retriever) => { + try { + const history = await retriever.getHistory(retriever, options); + logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`); + return { instance: retriever.getInstanceId(), data: history }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + return results + .filter(result => result.status === 'fulfilled') + .map(result => result.value); + } + + /** + * Get tags grouped by retriever type + * @returns {Promise} Tags grouped by retriever type (array of { instance, data } objects) + */ + async getTagsByType() { + const sonarrRetrievers = this.getRetrieversByType('sonarr'); + const radarrRetrievers = this.getRetrieversByType('radarr'); + + const sonarrTags = await Promise.allSettled( + sonarrRetrievers.map(async (retriever) => { + try { + const tags = await retriever.getTags(retriever); + return { instance: retriever.getInstanceId(), data: tags }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: [] }; + } + }) + ); + + const radarrTags = await Promise.allSettled( + radarrRetrievers.map(async (retriever) => { + try { + const tags = await retriever.getTags(retriever); + return { instance: retriever.getInstanceId(), data: tags }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: [] }; + } + }) + ); + + return { + sonarr: sonarrTags + .filter(result => result.status === 'fulfilled') + .map(result => result.value), + radarr: radarrTags + .filter(result => result.status === 'fulfilled') + .map(result => result.value) + }; + } + + /** + * Get queue grouped by retriever type + * @returns {Promise} Queue grouped by retriever type + */ + async getQueuesByType() { + const sonarrRetrievers = this.getRetrieversByType('sonarr'); + const radarrRetrievers = this.getRetrieversByType('radarr'); + + const sonarrQueues = await Promise.allSettled( + sonarrRetrievers.map(async (retriever) => { + try { + const queue = await retriever.getQueue(retriever); + return { instance: retriever.getInstanceId(), data: queue }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + const radarrQueues = await Promise.allSettled( + radarrRetrievers.map(async (retriever) => { + try { + const queue = await retriever.getQueue(retriever); + return { instance: retriever.getInstanceId(), data: queue }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + return { + sonarr: sonarrQueues + .filter(result => result.status === 'fulfilled') + .map(result => result.value), + radarr: radarrQueues + .filter(result => result.status === 'fulfilled') + .map(result => result.value) + }; + } + + /** + * Get history grouped by retriever type + * @param {Object} options - Optional parameters for history fetch + * @returns {Promise} History grouped by retriever type + */ + async getHistoryByType(options = {}) { + const sonarrRetrievers = this.getRetrieversByType('sonarr'); + const radarrRetrievers = this.getRetrieversByType('radarr'); + + const sonarrHistory = await Promise.allSettled( + sonarrRetrievers.map(async (retriever) => { + try { + const history = await retriever.getHistory(retriever, options); + return { instance: retriever.getInstanceId(), data: history }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + const radarrHistory = await Promise.allSettled( + radarrRetrievers.map(async (retriever) => { + try { + const history = await retriever.getHistory(retriever, options); + return { instance: retriever.getInstanceId(), data: history }; + } catch (error) { + logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); + return { instance: retriever.getInstanceId(), data: { records: [] } }; + } + }) + ); + + return { + sonarr: sonarrHistory + .filter(result => result.status === 'fulfilled') + .map(result => result.value), + radarr: radarrHistory + .filter(result => result.status === 'fulfilled') + .map(result => result.value) + }; + } +} + +// Create singleton instance +const registry = new ArrRetrieverRegistry(); + +module.exports = { + ArrRetrieverRegistry, + registry, + + // Convenience functions + initializeRetrievers: () => registry.initialize(), + getAllRetrievers: () => registry.getAllRetrievers(), + getRetriever: (instanceId) => registry.getRetriever(instanceId), + getRetrieversByType: (type) => registry.getRetrieversByType(type), + getAllTags: () => registry.getAllTags(), + getAllQueues: () => registry.getAllQueues(), + getAllHistory: (options) => registry.getAllHistory(options), + getTagsByType: () => registry.getTagsByType(), + getQueuesByType: () => registry.getQueuesByType(), + getHistoryByType: (options) => registry.getHistoryByType(options) +}; diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js index 8f5066d..5e9f6ce 100644 --- a/server/utils/historyFetcher.js +++ b/server/utils/historyFetcher.js @@ -1,7 +1,7 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. -const axios = require('axios'); const cache = require('./cache'); const { getSonarrInstances, getRadarrInstances } = require('./config'); +const { initializeRetrievers, getRetrieversByType } = require('./arrRetrievers'); // Cache TTL for recent-history data: 5 minutes. // History changes slowly compared to active downloads. @@ -26,21 +26,26 @@ async function fetchSonarrHistory(since) { const cached = cache.get(cacheKey); if (cached) return cached; + // Ensure retrievers are initialized + await initializeRetrievers(); + const instances = getSonarrInstances(); - const results = await Promise.all(instances.map(async inst => { + const sonarrRetrievers = getRetrieversByType('sonarr'); + + const results = await Promise.all(sonarrRetrievers.map(async (retriever) => { + const inst = instances.find(i => i.id === retriever.getInstanceId()); + if (!inst) return []; + try { - const response = await axios.get(`${inst.url}/api/v3/history`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { - pageSize: 100, - sortKey: 'date', - sortDir: 'descending', - includeSeries: true, - includeEpisode: true, - startDate: since.toISOString() - } + const response = await retriever.getHistory(retriever, { + pageSize: 100, + sortKey: 'date', + sortDir: 'descending', + includeSeries: true, + includeEpisode: true, + startDate: since.toISOString() }); - const records = (response.data && response.data.records) || []; + const records = (response && response.records) || []; return records.map(r => { if (r.series) r.series._instanceUrl = inst.url; if (r.series) r.series._instanceName = inst.name || inst.id; @@ -70,20 +75,25 @@ async function fetchRadarrHistory(since) { const cached = cache.get(cacheKey); if (cached) return cached; + // Ensure retrievers are initialized + await initializeRetrievers(); + const instances = getRadarrInstances(); - const results = await Promise.all(instances.map(async inst => { + const radarrRetrievers = getRetrieversByType('radarr'); + + const results = await Promise.all(radarrRetrievers.map(async (retriever) => { + const inst = instances.find(i => i.id === retriever.getInstanceId()); + if (!inst) return []; + try { - const response = await axios.get(`${inst.url}/api/v3/history`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { - pageSize: 100, - sortKey: 'date', - sortDir: 'descending', - includeMovie: true, - startDate: since.toISOString() - } + const response = await retriever.getHistory(retriever, { + pageSize: 100, + sortKey: 'date', + sortDir: 'descending', + includeMovie: true, + startDate: since.toISOString() }); - const records = (response.data && response.data.records) || []; + const records = (response && response.records) || []; return records.map(r => { if (r.movie) r.movie._instanceUrl = inst.url; if (r.movie) r.movie._instanceName = inst.name || inst.id; diff --git a/server/utils/poller.js b/server/utils/poller.js index f10c1dd..6526b12 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -2,6 +2,7 @@ const axios = require('axios'); const cache = require('./cache'); const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients'); +const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = require('./arrRetrievers'); const { getSonarrInstances, getRadarrInstances @@ -38,8 +39,9 @@ async function pollAllServices() { const start = Date.now(); try { - // Ensure download clients are initialized + // Ensure download clients and *arr retrievers are initialized await initializeClients(); + await initializeRetrievers(); const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); @@ -50,58 +52,30 @@ async function pollAllServices() { const downloadsByType = await getDownloadsByClientType(); return downloadsByType; }), - timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/tag`, { - headers: { 'X-Api-Key': inst.apiKey } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message); - return { instance: inst.id, data: [] }; - }) - ))), - timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/queue`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { includeSeries: true, includeEpisode: true } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message); - return { instance: inst.id, data: { records: [] } }; - }) - ))), - timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/history`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { pageSize: 10, includeEpisode: true } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message); - return { instance: inst.id, data: { records: [] } }; - }) - ))), - timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/queue`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { includeMovie: true } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message); - return { instance: inst.id, data: { records: [] } }; - }) - ))), - timed('Radarr History', () => Promise.all(radarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/history`, { - headers: { 'X-Api-Key': inst.apiKey }, - params: { pageSize: 10 } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Radarr ${inst.id} history error:`, err.message); - return { instance: inst.id, data: { records: [] } }; - }) - ))), - timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst => - axios.get(`${inst.url}/api/v3/tag`, { - headers: { 'X-Api-Key': inst.apiKey } - }).then(res => ({ instance: inst.id, data: res.data })).catch(err => { - console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message); - return { instance: inst.id, data: [] }; - }) - ))), + timed('Sonarr Tags', async () => { + const tagsByType = await getTagsByType(); + return tagsByType.sonarr || []; + }), + timed('Sonarr Queue', async () => { + const queuesByType = await getQueuesByType(); + return queuesByType.sonarr || []; + }), + timed('Sonarr History', async () => { + const historyByType = await getHistoryByType({ pageSize: 10 }); + return historyByType.sonarr || []; + }), + timed('Radarr Queue', async () => { + const queuesByType = await getQueuesByType(); + return queuesByType.radarr || []; + }), + timed('Radarr History', async () => { + const historyByType = await getHistoryByType({ pageSize: 10 }); + return historyByType.radarr || []; + }), + timed('Radarr Tags', async () => { + const tagsByType = await getTagsByType(); + return tagsByType.radarr || []; + }), ]); const [ From 6e199925aa4b157dd820885525d253a31f84e546 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 14:51:22 +0100 Subject: [PATCH 02/12] refactor: make PALDRA match PDCA style exactly - remove redundant instanceConfig parameter and convert to pure singleton - Remove instanceConfig parameter from all retriever methods (getTags, getQueue, getHistory) - Retriever instances now use this.url, this.apiKey, this.id instead of passed parameter - Convert ArrRetrieverRegistry from class with convenience functions to pure singleton object - Export singleton instance directly instead of class + convenience functions - Update poller.js and historyFetcher.js to call methods on singleton directly - All 261 tests pass with zero behavior changes --- server/clients/ArrRetriever.js | 9 ++-- server/clients/PollingRadarrRetriever.js | 27 +++++----- server/clients/PollingSonarrRetriever.js | 27 +++++----- server/utils/arrRetrievers.js | 68 +++++++++--------------- server/utils/historyFetcher.js | 14 ++--- server/utils/poller.js | 16 +++--- 6 files changed, 66 insertions(+), 95 deletions(-) diff --git a/server/clients/ArrRetriever.js b/server/clients/ArrRetriever.js index e96eb22..16d45a4 100644 --- a/server/clients/ArrRetriever.js +++ b/server/clients/ArrRetriever.js @@ -44,25 +44,22 @@ class ArrRetriever { /** * Get tags from this *arr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { throw new Error('getTags() must be implemented by subclass'); } /** * Get queue from this *arr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { throw new Error('getQueue() must be implemented by subclass'); } /** * Get history from this *arr instance - * @param {Object} instanceConfig - Configuration for the instance * @param {Object} options - Optional parameters for history fetch * @param {number} [options.pageSize] - Number of records to fetch * @param {string} [options.sortKey] - Field to sort by @@ -73,7 +70,7 @@ class ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { throw new Error('getHistory() must be implemented by subclass'); } } diff --git a/server/clients/PollingRadarrRetriever.js b/server/clients/PollingRadarrRetriever.js index 16ad9f8..7739e3f 100644 --- a/server/clients/PollingRadarrRetriever.js +++ b/server/clients/PollingRadarrRetriever.js @@ -18,42 +18,39 @@ class PollingRadarrRetriever extends ArrRetriever { /** * Get tags from Radarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { - headers: { 'X-Api-Key': instanceConfig.apiKey } + const response = await axios.get(`${this.url}/api/v3/tag`, { + headers: { 'X-Api-Key': this.apiKey } }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.id} tags error: ${error.message}`); return []; } } /** * Get queue from Radarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/queue`, { + headers: { 'X-Api-Key': this.apiKey }, params: { includeMovie: true } }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.id} queue error: ${error.message}`); return { records: [] }; } } /** * Get history from Radarr instance - * @param {Object} instanceConfig - Configuration for the instance * @param {Object} options - Optional parameters for history fetch * @param {number} [options.pageSize=10] - Number of records to fetch * @param {string} [options.sortKey] - Field to sort by @@ -62,7 +59,7 @@ class PollingRadarrRetriever extends ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { const { pageSize = 10, sortKey, @@ -81,13 +78,13 @@ class PollingRadarrRetriever extends ArrRetriever { if (sortDir) params.sortDir = sortDir; if (startDate) params.startDate = startDate; - const response = await axios.get(`${instanceConfig.url}/api/v3/history`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/history`, { + headers: { 'X-Api-Key': this.apiKey }, params }); return response.data; } catch (error) { - logToFile(`[PollingRadarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + logToFile(`[PollingRadarrRetriever] ${this.id} history error: ${error.message}`); return { records: [] }; } } diff --git a/server/clients/PollingSonarrRetriever.js b/server/clients/PollingSonarrRetriever.js index 1211038..16a224a 100644 --- a/server/clients/PollingSonarrRetriever.js +++ b/server/clients/PollingSonarrRetriever.js @@ -18,42 +18,39 @@ class PollingSonarrRetriever extends ArrRetriever { /** * Get tags from Sonarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Array of tag objects */ - async getTags(instanceConfig) { + async getTags() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/tag`, { - headers: { 'X-Api-Key': instanceConfig.apiKey } + const response = await axios.get(`${this.url}/api/v3/tag`, { + headers: { 'X-Api-Key': this.apiKey } }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} tags error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.id} tags error: ${error.message}`); return []; } } /** * Get queue from Sonarr instance - * @param {Object} instanceConfig - Configuration for the instance * @returns {Promise} Queue object with records array */ - async getQueue(instanceConfig) { + async getQueue() { try { - const response = await axios.get(`${instanceConfig.url}/api/v3/queue`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/queue`, { + headers: { 'X-Api-Key': this.apiKey }, params: { includeSeries: true, includeEpisode: true } }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} queue error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.id} queue error: ${error.message}`); return { records: [] }; } } /** * Get history from Sonarr instance - * @param {Object} instanceConfig - Configuration for the instance * @param {Object} options - Optional parameters for history fetch * @param {number} [options.pageSize=10] - Number of records to fetch * @param {string} [options.sortKey] - Field to sort by @@ -63,7 +60,7 @@ class PollingSonarrRetriever extends ArrRetriever { * @param {string} [options.startDate] - ISO date string for filtering * @returns {Promise} History object with records array */ - async getHistory(instanceConfig, options = {}) { + async getHistory(options = {}) { const { pageSize = 10, sortKey, @@ -84,13 +81,13 @@ class PollingSonarrRetriever extends ArrRetriever { if (sortDir) params.sortDir = sortDir; if (startDate) params.startDate = startDate; - const response = await axios.get(`${instanceConfig.url}/api/v3/history`, { - headers: { 'X-Api-Key': instanceConfig.apiKey }, + const response = await axios.get(`${this.url}/api/v3/history`, { + headers: { 'X-Api-Key': this.apiKey }, params }); return response.data; } catch (error) { - logToFile(`[PollingSonarrRetriever] ${instanceConfig.id} history error: ${error.message}`); + logToFile(`[PollingSonarrRetriever] ${this.id} history error: ${error.message}`); return { records: [] }; } } diff --git a/server/utils/arrRetrievers.js b/server/utils/arrRetrievers.js index 4a995f2..c405bcb 100644 --- a/server/utils/arrRetrievers.js +++ b/server/utils/arrRetrievers.js @@ -16,13 +16,11 @@ const retrieverClasses = { }; /** - * Registry and factory for *arr data retrievers + * Singleton registry for *arr data retrievers */ -class ArrRetrieverRegistry { - constructor() { - this.retrievers = new Map(); - this.initialized = false; - } +const arrRetrieverRegistry = { + retrievers: new Map(), + initialized: false, /** * Initialize all configured *arr retrievers @@ -62,7 +60,7 @@ class ArrRetrieverRegistry { this.initialized = true; logToFile(`[ArrRetrieverRegistry] Initialized ${this.retrievers.size} *arr retrievers`); - } + }, /** * Get all registered retrievers @@ -70,7 +68,7 @@ class ArrRetrieverRegistry { */ getAllRetrievers() { return Array.from(this.retrievers.values()); - } + }, /** * Get retriever by instance ID @@ -79,7 +77,7 @@ class ArrRetrieverRegistry { */ getRetriever(instanceId) { return this.retrievers.get(instanceId) || null; - } + }, /** * Get retrievers by type @@ -88,7 +86,7 @@ class ArrRetrieverRegistry { */ getRetrieversByType(type) { return this.getAllRetrievers().filter(retriever => retriever.getRetrieverType() === type); - } + }, /** * Get tags from all retrievers @@ -104,7 +102,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${tags.length} tags`); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { @@ -117,7 +115,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get queue from all retrievers @@ -133,7 +131,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(queue.records || []).length} queue items`); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { @@ -146,7 +144,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get history from all retrievers @@ -163,7 +161,7 @@ class ArrRetrieverRegistry { const results = await Promise.allSettled( retrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); logToFile(`[ArrRetrieverRegistry] ${retriever.name}: ${(history.records || []).length} history records`); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { @@ -176,7 +174,7 @@ class ArrRetrieverRegistry { return results .filter(result => result.status === 'fulfilled') .map(result => result.value); - } + }, /** * Get tags grouped by retriever type @@ -189,7 +187,7 @@ class ArrRetrieverRegistry { const sonarrTags = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); @@ -201,7 +199,7 @@ class ArrRetrieverRegistry { const radarrTags = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const tags = await retriever.getTags(retriever); + const tags = await retriever.getTags(); return { instance: retriever.getInstanceId(), data: tags }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching tags from ${retriever.name}: ${error.message}`); @@ -218,7 +216,7 @@ class ArrRetrieverRegistry { .filter(result => result.status === 'fulfilled') .map(result => result.value) }; - } + }, /** * Get queue grouped by retriever type @@ -231,7 +229,7 @@ class ArrRetrieverRegistry { const sonarrQueues = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); @@ -243,7 +241,7 @@ class ArrRetrieverRegistry { const radarrQueues = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const queue = await retriever.getQueue(retriever); + const queue = await retriever.getQueue(); return { instance: retriever.getInstanceId(), data: queue }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching queue from ${retriever.name}: ${error.message}`); @@ -260,7 +258,7 @@ class ArrRetrieverRegistry { .filter(result => result.status === 'fulfilled') .map(result => result.value) }; - } + }, /** * Get history grouped by retriever type @@ -274,7 +272,7 @@ class ArrRetrieverRegistry { const sonarrHistory = await Promise.allSettled( sonarrRetrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); @@ -286,7 +284,7 @@ class ArrRetrieverRegistry { const radarrHistory = await Promise.allSettled( radarrRetrievers.map(async (retriever) => { try { - const history = await retriever.getHistory(retriever, options); + const history = await retriever.getHistory(options); return { instance: retriever.getInstanceId(), data: history }; } catch (error) { logToFile(`[ArrRetrieverRegistry] Error fetching history from ${retriever.name}: ${error.message}`); @@ -304,24 +302,6 @@ class ArrRetrieverRegistry { .map(result => result.value) }; } -} - -// Create singleton instance -const registry = new ArrRetrieverRegistry(); - -module.exports = { - ArrRetrieverRegistry, - registry, - - // Convenience functions - initializeRetrievers: () => registry.initialize(), - getAllRetrievers: () => registry.getAllRetrievers(), - getRetriever: (instanceId) => registry.getRetriever(instanceId), - getRetrieversByType: (type) => registry.getRetrieversByType(type), - getAllTags: () => registry.getAllTags(), - getAllQueues: () => registry.getAllQueues(), - getAllHistory: (options) => registry.getAllHistory(options), - getTagsByType: () => registry.getTagsByType(), - getQueuesByType: () => registry.getQueuesByType(), - getHistoryByType: (options) => registry.getHistoryByType(options) }; + +module.exports = arrRetrieverRegistry; diff --git a/server/utils/historyFetcher.js b/server/utils/historyFetcher.js index 5e9f6ce..01a8165 100644 --- a/server/utils/historyFetcher.js +++ b/server/utils/historyFetcher.js @@ -1,7 +1,7 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. const cache = require('./cache'); const { getSonarrInstances, getRadarrInstances } = require('./config'); -const { initializeRetrievers, getRetrieversByType } = require('./arrRetrievers'); +const arrRetrieverRegistry = require('./arrRetrievers'); // Cache TTL for recent-history data: 5 minutes. // History changes slowly compared to active downloads. @@ -27,17 +27,17 @@ async function fetchSonarrHistory(since) { if (cached) return cached; // Ensure retrievers are initialized - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const instances = getSonarrInstances(); - const sonarrRetrievers = getRetrieversByType('sonarr'); + const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr'); const results = await Promise.all(sonarrRetrievers.map(async (retriever) => { const inst = instances.find(i => i.id === retriever.getInstanceId()); if (!inst) return []; try { - const response = await retriever.getHistory(retriever, { + const response = await retriever.getHistory({ pageSize: 100, sortKey: 'date', sortDir: 'descending', @@ -76,17 +76,17 @@ async function fetchRadarrHistory(since) { if (cached) return cached; // Ensure retrievers are initialized - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const instances = getRadarrInstances(); - const radarrRetrievers = getRetrieversByType('radarr'); + const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr'); const results = await Promise.all(radarrRetrievers.map(async (retriever) => { const inst = instances.find(i => i.id === retriever.getInstanceId()); if (!inst) return []; try { - const response = await retriever.getHistory(retriever, { + const response = await retriever.getHistory({ pageSize: 100, sortKey: 'date', sortDir: 'descending', diff --git a/server/utils/poller.js b/server/utils/poller.js index 6526b12..8bc882f 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -2,7 +2,7 @@ const axios = require('axios'); const cache = require('./cache'); const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients'); -const { initializeRetrievers, getTagsByType, getQueuesByType, getHistoryByType } = require('./arrRetrievers'); +const arrRetrieverRegistry = require('./arrRetrievers'); const { getSonarrInstances, getRadarrInstances @@ -41,7 +41,7 @@ async function pollAllServices() { try { // Ensure download clients and *arr retrievers are initialized await initializeClients(); - await initializeRetrievers(); + await arrRetrieverRegistry.initialize(); const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); @@ -53,27 +53,27 @@ async function pollAllServices() { return downloadsByType; }), timed('Sonarr Tags', async () => { - const tagsByType = await getTagsByType(); + const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.sonarr || []; }), timed('Sonarr Queue', async () => { - const queuesByType = await getQueuesByType(); + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.sonarr || []; }), timed('Sonarr History', async () => { - const historyByType = await getHistoryByType({ pageSize: 10 }); + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.sonarr || []; }), timed('Radarr Queue', async () => { - const queuesByType = await getQueuesByType(); + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.radarr || []; }), timed('Radarr History', async () => { - const historyByType = await getHistoryByType({ pageSize: 10 }); + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.radarr || []; }), timed('Radarr Tags', async () => { - const tagsByType = await getTagsByType(); + const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.radarr || []; }), ]); From 21befa53567715abadb2d166ee4fe43d76d75fac Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:01:15 +0100 Subject: [PATCH 03/12] chore: align version with develop branch (1.4.0) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd34b7e..ff12476 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.3.1", + "version": "1.4.0", "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": { From 99ddb05dbe77562ed271d036e1d54fbd519f8560 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:15:53 +0100 Subject: [PATCH 04/12] feat(webhook): implement Phase 1 webhook receiver for Sonarr and Radarr - Added POST /api/webhook/sonarr and POST /api/webhook/radarr endpoints - Implemented webhook secret validation via SOFARR_WEBHOOK_SECRET environment variable - Added logging for all incoming webhook events using existing logToFile utility - Returns HTTP 200 immediately to prevent webhook retries - Mounted webhook routes before CSRF middleware (called by external services) - Non-breaking: no changes to polling, caching, SSE, or any existing behavior - Lays groundwork for Phase 2 (cache + SSE integration) without implementing it yet --- .env.sample | 10 +++++ server/app.js | 2 + server/routes/webhook.js | 89 ++++++++++++++++++++++++++++++++++++++++ server/utils/config.js | 5 +++ 4 files changed, 106 insertions(+) create mode 100644 server/routes/webhook.js diff --git a/.env.sample b/.env.sample index 66f84c3..dd41483 100644 --- a/.env.sample +++ b/.env.sample @@ -19,6 +19,16 @@ LOG_LEVEL=info # Generate with: openssl rand -hex 32 COOKIE_SECRET=your-cookie-secret-here +# ============================================================================= +# WEBHOOK SETTINGS +# ============================================================================= + +# Secret for validating incoming webhooks from Sonarr and Radarr +# Required for webhook endpoints to accept requests +# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header +# Generate with: openssl rand -hex 32 +SOFARR_WEBHOOK_SECRET=your-webhook-secret-here + # ============================================================================= # TLS / HTTPS # ============================================================================= diff --git a/server/app.js b/server/app.js index da9d319..8a67fb7 100644 --- a/server/app.js +++ b/server/app.js @@ -19,6 +19,7 @@ const embyRoutes = require('./routes/emby'); const dashboardRoutes = require('./routes/dashboard'); const historyRoutes = require('./routes/history'); const authRoutes = require('./routes/auth'); +const webhookRoutes = require('./routes/webhook'); const verifyCsrf = require('./middleware/verifyCsrf'); function createApp({ skipRateLimits = false } = {}) { @@ -94,6 +95,7 @@ function createApp({ skipRateLimits = false } = {}) { // API routes app.use('/api', apiLimiter); app.use('/api/auth', authRoutes); + app.use('/api/webhook', webhookRoutes); // CSRF protection for all state-changing API requests below app.use('/api', verifyCsrf); diff --git a/server/routes/webhook.js b/server/routes/webhook.js new file mode 100644 index 0000000..74a00cc --- /dev/null +++ b/server/routes/webhook.js @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +const express = require('express'); +const { logToFile } = require('../utils/logger'); +const { getWebhookSecret } = require('../utils/config'); + +const router = express.Router(); + +/** + * Validate webhook secret from the X-Sofarr-Webhook-Secret header + * @param {Object} req - Express request object + * @returns {boolean} True if secret is valid, false otherwise + */ +function validateWebhookSecret(req) { + const expectedSecret = getWebhookSecret(); + const providedSecret = req.get('X-Sofarr-Webhook-Secret'); + + if (!expectedSecret) { + logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook'); + return false; + } + + if (!providedSecret) { + logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header'); + return false; + } + + if (providedSecret !== expectedSecret) { + logToFile('[Webhook] WARNING: Invalid webhook secret provided'); + return false; + } + + return true; +} + +/** + * POST /api/webhook/sonarr + * Receives webhook events from Sonarr instances. + * Validates the secret, logs the event, and returns 200 immediately. + * + * Phase 1: Receiver only - no cache or SSE integration yet. + * Future phases will integrate with PALDRA registry for event-driven updates. + */ +router.post('/sonarr', (req, res) => { + if (!validateWebhookSecret(req)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const { eventType, instanceName } = req.body || {}; + logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); + logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); + + // Phase 1: Log and respond immediately + // Future phases will push to cache and trigger SSE + res.status(200).json({ received: true }); + } catch (error) { + logToFile(`[Webhook] Sonarr error: ${error.message}`); + res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries + } +}); + +/** + * POST /api/webhook/radarr + * Receives webhook events from Radarr instances. + * Validates the secret, logs the event, and returns 200 immediately. + * + * Phase 1: Receiver only - no cache or SSE integration yet. + * Future phases will integrate with PALDRA registry for event-driven updates. + */ +router.post('/radarr', (req, res) => { + if (!validateWebhookSecret(req)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + try { + const { eventType, instanceName } = req.body || {}; + logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); + logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); + + // Phase 1: Log and respond immediately + // Future phases will push to cache and trigger SSE + res.status(200).json({ received: true }); + } catch (error) { + logToFile(`[Webhook] Radarr error: ${error.message}`); + res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries + } +}); + +module.exports = router; diff --git a/server/utils/config.js b/server/utils/config.js index 35e1b71..1d95e80 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -114,6 +114,10 @@ function getRtorrentInstances() { ); } +function getWebhookSecret() { + return process.env.SOFARR_WEBHOOK_SECRET || ''; +} + module.exports = { getSABnzbdInstances, getSonarrInstances, @@ -121,6 +125,7 @@ module.exports = { getQbittorrentInstances, getTransmissionInstances, getRtorrentInstances, + getWebhookSecret, parseInstances, validateInstanceUrl }; From 1d61ea8d8303ba2aa496273b4e8666c53a2a470d Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:24:43 +0100 Subject: [PATCH 05/12] feat(webhooks): integrate receiver with cache + SSE (Phase 2) --- server/routes/webhook.js | 168 +++++++++++++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 23 deletions(-) diff --git a/server/routes/webhook.js b/server/routes/webhook.js index 74a00cc..bd8bd64 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -1,10 +1,32 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const { logToFile } = require('../utils/logger'); -const { getWebhookSecret } = require('../utils/config'); +const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config'); +const cache = require('../utils/cache'); +const arrRetrieverRegistry = require('../utils/arrRetrievers'); +const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller'); const router = express.Router(); +// Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand +const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; + +// Event classification — determines which cache keys to refresh +const QUEUE_EVENTS = new Set([ + 'Grab', + 'Download', + 'DownloadFailed', + 'ManualInteractionRequired' +]); + +const HISTORY_EVENTS = new Set([ + 'DownloadFolderImported', + 'ImportFailed', + 'EpisodeFileRenamed', + 'MovieFileRenamed', + 'EpisodeFileRenamedBySeries' +]); + /** * Validate webhook secret from the X-Sofarr-Webhook-Secret header * @param {Object} req - Express request object @@ -13,76 +35,176 @@ const router = express.Router(); function validateWebhookSecret(req) { const expectedSecret = getWebhookSecret(); const providedSecret = req.get('X-Sofarr-Webhook-Secret'); - + if (!expectedSecret) { logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook'); return false; } - + if (!providedSecret) { logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header'); return false; } - + if (providedSecret !== expectedSecret) { logToFile('[Webhook] WARNING: Invalid webhook secret provided'); return false; } - + return true; } +/** + * Process a webhook event by refreshing the affected cache and broadcasting SSE. + * This is a fire-and-forget background task — callers must respond to the webhook + * sender before awaiting this function. + * + * Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast. + * + * @param {string} serviceType - 'sonarr' or 'radarr' + * @param {string} eventType - the eventType from the *arr webhook payload + */ +async function processWebhookEvent(serviceType, eventType) { + const affectsQueue = QUEUE_EVENTS.has(eventType); + const affectsHistory = HISTORY_EVENTS.has(eventType); + + if (!affectsQueue && !affectsHistory) { + logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`); + return; + } + + logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`); + + // Ensure retrievers are initialized (idempotent) + await arrRetrieverRegistry.initialize(); + + if (serviceType === 'sonarr') { + const sonarrInstances = getSonarrInstances(); + + if (affectsQueue) { + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); + const sonarrQueues = queuesByType.sonarr || []; + cache.set('poll:sonarr-queue', { + records: sonarrQueues.flatMap(q => { + const inst = sonarrInstances.find(i => i.id === q.instance); + const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; + return (q.data.records || []).map(r => { + if (r.series) r.series._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; + return r; + }); + }) + }, CACHE_TTL); + logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`); + } + + if (affectsHistory) { + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); + const sonarrHistories = historyByType.sonarr || []; + cache.set('poll:sonarr-history', { + records: sonarrHistories.flatMap(h => h.data.records || []) + }, CACHE_TTL); + logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`); + } + } else if (serviceType === 'radarr') { + const radarrInstances = getRadarrInstances(); + + if (affectsQueue) { + const queuesByType = await arrRetrieverRegistry.getQueuesByType(); + const radarrQueues = queuesByType.radarr || []; + cache.set('poll:radarr-queue', { + records: radarrQueues.flatMap(q => { + const inst = radarrInstances.find(i => i.id === q.instance); + const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; + return (q.data.records || []).map(r => { + if (r.movie) r.movie._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; + return r; + }); + }) + }, CACHE_TTL); + logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`); + } + + if (affectsHistory) { + const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); + const radarrHistories = historyByType.radarr || []; + cache.set('poll:radarr-history', { + records: radarrHistories.flatMap(h => h.data.records || []) + }, CACHE_TTL); + logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`); + } + } + + // Broadcast to all SSE subscribers using the same mechanism poller.js uses. + // pollAllServices() refreshes all data, updates every cache key, and then + // iterates pollSubscribers to push fresh payloads to every open SSE connection. + // If a poll is already in progress this call is a no-op, but the cache keys + // above were already updated so the next broadcast (or dashboard request) + // will see fresh data. + logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()'); + await pollAllServices(); +} + /** * POST /api/webhook/sonarr * Receives webhook events from Sonarr instances. - * Validates the secret, logs the event, and returns 200 immediately. - * - * Phase 1: Receiver only - no cache or SSE integration yet. - * Future phases will integrate with PALDRA registry for event-driven updates. + * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. + * + * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. */ router.post('/sonarr', (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } - + try { const { eventType, instanceName } = req.body || {}; logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); - - // Phase 1: Log and respond immediately - // Future phases will push to cache and trigger SSE + + // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) + processWebhookEvent('sonarr', eventType).catch(err => { + logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`); + }); + res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Sonarr error: ${error.message}`); - res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries + res.status(200).json({ received: true }); } }); /** * POST /api/webhook/radarr * Receives webhook events from Radarr instances. - * Validates the secret, logs the event, and returns 200 immediately. - * - * Phase 1: Receiver only - no cache or SSE integration yet. - * Future phases will integrate with PALDRA registry for event-driven updates. + * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. + * + * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. */ router.post('/radarr', (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } - + try { const { eventType, instanceName } = req.body || {}; logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); - - // Phase 1: Log and respond immediately - // Future phases will push to cache and trigger SSE + + // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) + processWebhookEvent('radarr', eventType).catch(err => { + logToFile(`[Webhook] Radarr background refresh error: ${err.message}`); + }); + res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Radarr error: ${error.message}`); - res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries + res.status(200).json({ received: true }); } }); From e022db8ef540a3d5fa65e8ea77f9b4af5ff9f95c Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:31:50 +0100 Subject: [PATCH 06/12] feat(webhooks): add notification management API + one-click Sofarr webhook setup (Phase 3) --- .env.sample | 6 ++ server/routes/radarr.js | 147 ++++++++++++++++++++++++++++++++++++++++ server/routes/sonarr.js | 147 ++++++++++++++++++++++++++++++++++++++++ server/utils/config.js | 5 ++ 4 files changed, 305 insertions(+) diff --git a/.env.sample b/.env.sample index dd41483..fc95ae1 100644 --- a/.env.sample +++ b/.env.sample @@ -29,6 +29,12 @@ COOKIE_SECRET=your-cookie-secret-here # Generate with: openssl rand -hex 32 SOFARR_WEBHOOK_SECRET=your-webhook-secret-here +# Public base URL of Sofarr (for webhook configuration) +# Required for the one-click webhook setup endpoints +# Sonarr/Radarr need this URL to know where to send webhook events +# Example: https://sofarr.example.com or https://192.168.1.100:3001 +SOFARR_BASE_URL=https://your-sofarr-url + # ============================================================================= # TLS / HTTPS # ============================================================================= diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 6d74211..fcfc6e5 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -4,6 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); +const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config'); router.use(requireAuth); @@ -56,4 +57,150 @@ router.get('/movies', async (req, res) => { } }); +// Notification proxy routes (Phase 3) +// GET /api/radarr/notifications - list all notifications +router.get('/notifications', async (req, res) => { + try { + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) }); + } +}); + +// GET /api/radarr/notifications/:id - get specific notification +router.get('/notifications/:id', async (req, res) => { + try { + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) }); + } +}); + +// POST /api/radarr/notifications - create notification +router.post('/notifications', async (req, res) => { + try { + const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) }); + } +}); + +// PUT /api/radarr/notifications/:id - update notification +router.put('/notifications/:id', async (req, res) => { + try { + const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) }); + } +}); + +// DELETE /api/radarr/notifications/:id - delete notification +router.delete('/notifications/:id', async (req, res) => { + try { + const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) }); + } +}); + +// POST /api/radarr/notifications/test - test notification +router.post('/notifications/test', async (req, res) => { + 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 } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) }); + } +}); + +// GET /api/radarr/notifications/schema - get notification schema +router.get('/notifications/schema', async (req, res) => { + try { + const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, { + headers: { 'X-Api-Key': process.env.RADARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) }); + } +}); + +// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup +router.post('/notifications/sofarr-webhook', async (req, res) => { + try { + const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookSecret = getWebhookSecret(); + + if (!sofarrBaseUrl) { + return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); + } + if (!webhookSecret) { + return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); + } + + 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 existingNotification = listResponse.data.find(n => n.name === 'Sofarr'); + + const notificationPayload = { + name: 'Sofarr', + implementation: 'Webhook', + configContract: 'WebhookSettings', + fields: [ + { name: 'url', value: webhookUrl }, + { name: 'method', value: 'POST' }, + { name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] } + ], + onGrab: true, + onDownload: true, + onImport: true, + onUpgrade: true, + onRename: false, + onHealthIssue: false, + onApplicationUpdate: false + }; + + if (existingNotification) { + // Update existing notification + const response = await axios.put( + `${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`, + { ...notificationPayload, id: existingNotification.id }, + { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } } + ); + res.json(response.data); + } else { + // Create new notification + const response = await axios.post( + `${process.env.RADARR_URL}/api/v3/notification`, + notificationPayload, + { headers: { 'X-Api-Key': process.env.RADARR_API_KEY } } + ); + res.json(response.data); + } + } catch (error) { + res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) }); + } +}); + module.exports = router; diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index aba4bdc..9e4d4a4 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -4,6 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); +const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config'); router.use(requireAuth); @@ -56,4 +57,150 @@ router.get('/series', async (req, res) => { } }); +// Notification proxy routes (Phase 3) +// GET /api/sonarr/notifications - list all notifications +router.get('/notifications', async (req, res) => { + try { + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) }); + } +}); + +// GET /api/sonarr/notifications/:id - get specific notification +router.get('/notifications/:id', async (req, res) => { + try { + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) }); + } +}); + +// POST /api/sonarr/notifications - create notification +router.post('/notifications', async (req, res) => { + try { + const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) }); + } +}); + +// PUT /api/sonarr/notifications/:id - update notification +router.put('/notifications/:id', async (req, res) => { + try { + const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) }); + } +}); + +// DELETE /api/sonarr/notifications/:id - delete notification +router.delete('/notifications/:id', async (req, res) => { + try { + const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) }); + } +}); + +// POST /api/sonarr/notifications/test - test notification +router.post('/notifications/test', async (req, res) => { + 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 } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) }); + } +}); + +// GET /api/sonarr/notifications/schema - get notification schema +router.get('/notifications/schema', async (req, res) => { + try { + const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, { + headers: { 'X-Api-Key': process.env.SONARR_API_KEY } + }); + res.json(response.data); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) }); + } +}); + +// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup +router.post('/notifications/sofarr-webhook', async (req, res) => { + try { + const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookSecret = getWebhookSecret(); + + if (!sofarrBaseUrl) { + return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); + } + if (!webhookSecret) { + return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); + } + + 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 existingNotification = listResponse.data.find(n => n.name === 'Sofarr'); + + const notificationPayload = { + name: 'Sofarr', + implementation: 'Webhook', + configContract: 'WebhookSettings', + fields: [ + { name: 'url', value: webhookUrl }, + { name: 'method', value: 'POST' }, + { name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] } + ], + onGrab: true, + onDownload: true, + onImport: true, + onUpgrade: true, + onRename: false, + onHealthIssue: false, + onApplicationUpdate: false + }; + + if (existingNotification) { + // Update existing notification + const response = await axios.put( + `${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`, + { ...notificationPayload, id: existingNotification.id }, + { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } } + ); + res.json(response.data); + } else { + // Create new notification + const response = await axios.post( + `${process.env.SONARR_URL}/api/v3/notification`, + notificationPayload, + { headers: { 'X-Api-Key': process.env.SONARR_API_KEY } } + ); + res.json(response.data); + } + } catch (error) { + res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) }); + } +}); + module.exports = router; diff --git a/server/utils/config.js b/server/utils/config.js index 1d95e80..58c2c66 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -118,6 +118,10 @@ function getWebhookSecret() { return process.env.SOFARR_WEBHOOK_SECRET || ''; } +function getSofarrBaseUrl() { + return process.env.SOFARR_BASE_URL || ''; +} + module.exports = { getSABnzbdInstances, getSonarrInstances, @@ -126,6 +130,7 @@ module.exports = { getTransmissionInstances, getRtorrentInstances, getWebhookSecret, + getSofarrBaseUrl, parseInstances, validateInstanceUrl }; From 80e8b728788e6f112696ee46055cc70128c5d23b Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 15:52:44 +0100 Subject: [PATCH 07/12] feat(webhooks): add simple frontend webhook configuration UI (Phase 4) --- client/src/App.css | 155 ++++++++++++++++++++++++++++++++ client/src/App.jsx | 215 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) diff --git a/client/src/App.css b/client/src/App.css index 23206ae..1831a5c 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -304,3 +304,158 @@ body { grid-template-columns: 1fr; } } + +/* Webhooks Section Styles */ +.webhooks-section { + background: white; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + overflow: hidden; +} + +.webhooks-header { + padding: 20px 30px; + background: #f8f9fa; + border-bottom: 2px solid #e0e0e0; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.3s; +} + +.webhooks-header:hover { + background: #f0f1f2; +} + +.webhooks-header h2 { + color: #333; + font-size: 1.3rem; + margin: 0; +} + +.webhooks-toggle { + font-size: 1.2rem; + color: #666; + transition: transform 0.3s; +} + +.webhooks-toggle.expanded { + transform: rotate(180deg); +} + +.webhooks-content { + padding: 20px 30px; +} + +.webhook-instance { + padding: 20px 0; + border-bottom: 1px solid #e0e0e0; +} + +.webhook-instance:last-child { + border-bottom: none; +} + +.webhook-instance h3 { + color: #333; + font-size: 1.1rem; + margin-bottom: 15px; +} + +.webhook-status { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 15px; +} + +.status-indicator { + font-size: 1rem; + font-weight: 500; + padding: 5px 15px; + border-radius: 20px; +} + +.status-indicator.enabled { + background: #e8f5e9; + color: #4caf50; +} + +.status-indicator.disabled { + background: #f5f5f5; + color: #999; +} + +.enable-webhook-btn { + padding: 8px 16px; + background: #667eea; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.3s; +} + +.enable-webhook-btn:hover { + background: #5568d3; +} + +.enable-webhook-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +.test-webhook-btn { + padding: 8px 16px; + background: #f093fb; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.3s; +} + +.test-webhook-btn:hover { + background: #d97ed8; +} + +.test-webhook-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +.webhook-triggers { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + padding-top: 15px; + border-top: 1px solid #e0e0e0; +} + +.trigger-item { + display: flex; + justify-content: space-between; + align-items: center; +} + +.trigger-label { + color: #666; + font-size: 0.9rem; +} + +.trigger-value { + font-weight: 500; + font-size: 1.1rem; +} + +.trigger-value.active { + color: #4caf50; +} + +.trigger-value.inactive { + color: #999; +} diff --git a/client/src/App.jsx b/client/src/App.jsx index 17eaaf7..aeef72d 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -10,9 +10,14 @@ function App() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [sessions, setSessions] = useState([]); + const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false); + const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); + const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); + const [webhookLoading, setWebhookLoading] = useState(false); useEffect(() => { fetchSessions(); + fetchWebhookStatus(); }, []); const fetchSessions = async () => { @@ -67,6 +72,112 @@ function App() { return new Date(dateString).toLocaleString(); }; + const fetchWebhookStatus = async () => { + try { + // Fetch Sonarr notifications + try { + const sonarrResponse = await axios.get('/api/sonarr/notifications'); + const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); + setSonarrWebhook({ + enabled: !!sonarrSofarr, + triggers: sonarrSofarr ? { + onGrab: sonarrSofarr.onGrab, + onDownload: sonarrSofarr.onDownload, + onImport: sonarrSofarr.onImport, + onUpgrade: sonarrSofarr.onUpgrade + } : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } + }); + } catch (err) { + // Sonarr not configured or not accessible + setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); + } + + // Fetch Radarr notifications + try { + const radarrResponse = await axios.get('/api/radarr/notifications'); + const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); + setRadarrWebhook({ + enabled: !!radarrSofarr, + triggers: radarrSofarr ? { + onGrab: radarrSofarr.onGrab, + onDownload: radarrSofarr.onDownload, + onImport: radarrSofarr.onImport, + onUpgrade: radarrSofarr.onUpgrade + } : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } + }); + } catch (err) { + // Radarr not configured or not accessible + setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } }); + } + } catch (err) { + console.error('Failed to fetch webhook status:', err); + } + }; + + const enableSonarrWebhook = async () => { + setWebhookLoading(true); + try { + await axios.post('/api/sonarr/notifications/sofarr-webhook'); + await fetchWebhookStatus(); + } catch (err) { + console.error('Failed to enable Sonarr webhook:', err); + alert('Failed to enable Sonarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } + }; + + const enableRadarrWebhook = async () => { + setWebhookLoading(true); + try { + await axios.post('/api/radarr/notifications/sofarr-webhook'); + await fetchWebhookStatus(); + } catch (err) { + console.error('Failed to enable Radarr webhook:', err); + alert('Failed to enable Radarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } + }; + + const testSonarrWebhook = async () => { + setWebhookLoading(true); + try { + const sonarrResponse = await axios.get('/api/sonarr/notifications'); + const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); + if (sonarrSofarr) { + await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id }); + alert('Sonarr webhook test sent successfully!'); + } else { + alert('Sofarr webhook not configured for Sonarr.'); + } + } catch (err) { + console.error('Failed to test Sonarr webhook:', err); + alert('Failed to test Sonarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } + }; + + const testRadarrWebhook = async () => { + setWebhookLoading(true); + try { + const radarrResponse = await axios.get('/api/radarr/notifications'); + const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); + if (radarrSofarr) { + await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id }); + alert('Radarr webhook test sent successfully!'); + } else { + alert('Sofarr webhook not configured for Radarr.'); + } + } catch (err) { + console.error('Failed to test Radarr webhook:', err); + alert('Failed to test Radarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } + }; + return (
@@ -178,6 +289,110 @@ function App() {
)} +
+
setWebhookSectionExpanded(!webhookSectionExpanded)}> +

⚡ Webhooks Configuration

+ +
+ {webhookSectionExpanded && ( +
+ {webhookLoading &&
Loading webhook status...
} +
+

Sonarr

+
+ + {sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'} + + {!sonarrWebhook.enabled && ( + + )} + {sonarrWebhook.enabled && ( + + )} +
+ {sonarrWebhook.enabled && ( +
+
+ On Grab + + {sonarrWebhook.triggers.onGrab ? '✓' : '✗'} + +
+
+ On Download + + {sonarrWebhook.triggers.onDownload ? '✓' : '✗'} + +
+
+ On Import + + {sonarrWebhook.triggers.onImport ? '✓' : '✗'} + +
+
+ On Upgrade + + {sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'} + +
+
+ )} +
+
+

Radarr

+
+ + {radarrWebhook.enabled ? '● Enabled' : '○ Disabled'} + + {!radarrWebhook.enabled && ( + + )} + {radarrWebhook.enabled && ( + + )} +
+ {radarrWebhook.enabled && ( +
+
+ On Grab + + {radarrWebhook.triggers.onGrab ? '✓' : '✗'} + +
+
+ On Download + + {radarrWebhook.triggers.onDownload ? '✓' : '✗'} + +
+
+ On Import + + {radarrWebhook.triggers.onImport ? '✓' : '✗'} + +
+
+ On Upgrade + + {radarrWebhook.triggers.onUpgrade ? '✓' : '✗'} + +
+
+ )} +
+
+ )} +
+

Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.

From fcb0cd8e4a2ca6939089ca0d82a5bef93d5691ae Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 16:10:45 +0100 Subject: [PATCH 08/12] feat(webhooks): add polling optimization and fallback when webhooks are active (Phase 5) --- server/utils/cache.js | 60 +++++++++++++++ server/utils/poller.js | 171 ++++++++++++++++++++++++++++++----------- 2 files changed, 184 insertions(+), 47 deletions(-) diff --git a/server/utils/cache.js b/server/utils/cache.js index 30640cf..107ba70 100644 --- a/server/utils/cache.js +++ b/server/utils/cache.js @@ -72,4 +72,64 @@ class MemoryCache { const cache = new MemoryCache(); +// Webhook metrics for polling optimization +// These are stored separately from regular cache entries +const webhookMetrics = { + // Per-instance metrics: key = instance URL, value = { lastWebhookTimestamp, eventsReceived, pollsSkipped } + instances: new Map(), + // Global metrics + lastGlobalWebhookTimestamp: null, + totalWebhookEventsReceived: 0 +}; + +function getWebhookMetrics(instanceUrl) { + if (!instanceUrl) return null; + return webhookMetrics.instances.get(instanceUrl) || { + lastWebhookTimestamp: null, + eventsReceived: 0, + pollsSkipped: 0 + }; +} + +function updateWebhookMetrics(instanceUrl) { + const now = Date.now(); + webhookMetrics.lastGlobalWebhookTimestamp = now; + webhookMetrics.totalWebhookEventsReceived++; + + if (instanceUrl) { + const metrics = webhookMetrics.instances.get(instanceUrl) || { + lastWebhookTimestamp: null, + eventsReceived: 0, + pollsSkipped: 0 + }; + metrics.lastWebhookTimestamp = now; + metrics.eventsReceived++; + webhookMetrics.instances.set(instanceUrl, metrics); + } +} + +function incrementPollsSkipped(instanceUrl) { + if (instanceUrl) { + const metrics = webhookMetrics.instances.get(instanceUrl) || { + lastWebhookTimestamp: null, + eventsReceived: 0, + pollsSkipped: 0 + }; + metrics.pollsSkipped++; + webhookMetrics.instances.set(instanceUrl, metrics); + } +} + +function getGlobalWebhookMetrics() { + return { + lastGlobalWebhookTimestamp: webhookMetrics.lastGlobalWebhookTimestamp, + totalWebhookEventsReceived: webhookMetrics.totalWebhookEventsReceived, + instances: Object.fromEntries(webhookMetrics.instances) + }; +} + module.exports = cache; +module.exports.getWebhookMetrics = getWebhookMetrics; +module.exports.updateWebhookMetrics = updateWebhookMetrics; +module.exports.incrementPollsSkipped = incrementPollsSkipped; +module.exports.getGlobalWebhookMetrics = getGlobalWebhookMetrics; diff --git a/server/utils/poller.js b/server/utils/poller.js index 8bc882f..cabcf1a 100644 --- a/server/utils/poller.js +++ b/server/utils/poller.js @@ -14,6 +14,13 @@ const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' : (parseInt(process.env.POLL_INTERVAL, 10) || 5000); const POLLING_ENABLED = POLL_INTERVAL > 0; +// Webhook fallback timeout in minutes (default 10) +const WEBHOOK_FALLBACK_TIMEOUT_MINUTES = parseInt(process.env.WEBHOOK_FALLBACK_TIMEOUT, 10) || 10; +const WEBHOOK_FALLBACK_TIMEOUT_MS = WEBHOOK_FALLBACK_TIMEOUT_MINUTES * 60 * 1000; + +// Webhook poll interval multiplier when webhooks are active (default 3x) +const WEBHOOK_POLL_INTERVAL_MULTIPLIER = parseInt(process.env.WEBHOOK_POLL_INTERVAL_MULTIPLIER, 10) || 3; + let polling = false; let lastPollTimings = null; @@ -30,6 +37,42 @@ async function timed(label, fn) { return { label, result, ms: Date.now() - t0 }; } +// Helper function to determine if instance polling should be skipped +function shouldSkipInstancePolling(instances, instanceType) { + if (!instances || instances.length === 0) { + return false; + } + + const now = Date.now(); + let allInstancesHaveRecentWebhooks = true; + let skippedCount = 0; + + for (const instance of instances) { + const metrics = cache.getWebhookMetrics(instance.url); + + // Skip polling if: + // 1. Webhook events have been received (eventsReceived > 0) + // 2. Last webhook was recent (within fallback timeout) + // 3. Webhook has been enabled (we have metrics) + const hasWebhookActivity = metrics && metrics.eventsReceived > 0; + const isRecent = metrics && metrics.lastWebhookTimestamp && (now - metrics.lastWebhookTimestamp) < WEBHOOK_FALLBACK_TIMEOUT_MS; + + if (hasWebhookActivity && isRecent) { + skippedCount++; + cache.incrementPollsSkipped(instance.url); + } else { + allInstancesHaveRecentWebhooks = false; + } + } + + if (allInstancesHaveRecentWebhooks && skippedCount > 0) { + console.log(`[Poller] Skipping ${instanceType} polling for ${skippedCount} instance(s) with active webhooks`); + return true; + } + + return false; +} + async function pollAllServices() { if (polling) { console.log('[Poller] Previous poll still running, skipping'); @@ -46,36 +89,50 @@ async function pollAllServices() { const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); + // Check webhook fallback: if no webhook events for WEBHOOK_FALLBACK_TIMEOUT, force full poll + const globalMetrics = cache.getGlobalWebhookMetrics(); + const now = Date.now(); + const lastWebhookTime = globalMetrics.lastGlobalWebhookTimestamp; + const fallbackTriggered = lastWebhookTime && (now - lastWebhookTime) > WEBHOOK_FALLBACK_TIMEOUT_MS; + + if (fallbackTriggered) { + console.log(`[Poller] Webhook fallback triggered: no webhook events for ${WEBHOOK_FALLBACK_TIMEOUT_MINUTES} minutes, forcing full poll`); + } + + // Determine which instances should be polled based on webhook activity + const shouldPollSonarr = fallbackTriggered || !shouldSkipInstancePolling(sonarrInstances, 'sonarr'); + const shouldPollRadarr = fallbackTriggered || !shouldSkipInstancePolling(radarrInstances, 'radarr'); + // All fetches in parallel, each individually timed const results = await Promise.all([ timed('Download Clients', async () => { const downloadsByType = await getDownloadsByClientType(); return downloadsByType; }), - timed('Sonarr Tags', async () => { + shouldPollSonarr ? timed('Sonarr Tags', async () => { const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.sonarr || []; - }), - timed('Sonarr Queue', async () => { + }) : timed('Sonarr Tags', async () => []), + shouldPollSonarr ? timed('Sonarr Queue', async () => { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.sonarr || []; - }), - timed('Sonarr History', async () => { + }) : timed('Sonarr Queue', async () => []), + shouldPollSonarr ? timed('Sonarr History', async () => { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.sonarr || []; - }), - timed('Radarr Queue', async () => { + }) : timed('Sonarr History', async () => []), + shouldPollRadarr ? timed('Radarr Queue', async () => { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); return queuesByType.radarr || []; - }), - timed('Radarr History', async () => { + }) : timed('Radarr Queue', async () => []), + shouldPollRadarr ? timed('Radarr History', async () => { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); return historyByType.radarr || []; - }), - timed('Radarr Tags', async () => { + }) : timed('Radarr History', async () => []), + shouldPollRadarr ? timed('Radarr Tags', async () => { const tagsByType = await arrRetrieverRegistry.getTagsByType(); return tagsByType.radarr || []; - }), + }) : timed('Radarr Tags', async () => []), ]); const [ @@ -163,43 +220,63 @@ async function pollAllServices() { cache.set('poll:qbittorrent', qbittorrentLegacy, cacheTTL); // Sonarr - cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL); - // Tag queue/history records with _instanceUrl so embedded series/movie objects can build links - cache.set('poll:sonarr-queue', { - records: sonarrQueues.flatMap(q => { - const inst = sonarrInstances.find(i => i.id === q.instance); - const url = inst ? inst.url : null; - const key = inst ? inst.apiKey : null; - return (q.data.records || []).map(r => { - if (r.series) r.series._instanceUrl = url; - r._instanceUrl = url; - r._instanceKey = key; - return r; - }); - }) - }, cacheTTL); - cache.set('poll:sonarr-history', { - records: sonarrHistories.flatMap(h => h.data.records || []) - }, cacheTTL); + if (shouldPollSonarr) { + cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL); + // Tag queue/history records with _instanceUrl so embedded series/movie objects can build links + cache.set('poll:sonarr-queue', { + records: sonarrQueues.flatMap(q => { + const inst = sonarrInstances.find(i => i.id === q.instance); + const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; + return (q.data.records || []).map(r => { + if (r.series) r.series._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; + return r; + }); + }) + }, cacheTTL); + cache.set('poll:sonarr-history', { + records: sonarrHistories.flatMap(h => h.data.records || []) + }, cacheTTL); + } else { + // Extend TTL of existing cached data when polling is skipped + const existingSonarrTags = cache.get('poll:sonarr-tags'); + const existingSonarrQueue = cache.get('poll:sonarr-queue'); + const existingSonarrHistory = cache.get('poll:sonarr-history'); + if (existingSonarrTags) cache.set('poll:sonarr-tags', existingSonarrTags, cacheTTL); + if (existingSonarrQueue) cache.set('poll:sonarr-queue', existingSonarrQueue, cacheTTL); + if (existingSonarrHistory) cache.set('poll:sonarr-history', existingSonarrHistory, cacheTTL); + } // Radarr - cache.set('poll:radarr-queue', { - records: radarrQueues.flatMap(q => { - const inst = radarrInstances.find(i => i.id === q.instance); - const url = inst ? inst.url : null; - const key = inst ? inst.apiKey : null; - return (q.data.records || []).map(r => { - if (r.movie) r.movie._instanceUrl = url; - r._instanceUrl = url; - r._instanceKey = key; - return r; - }); - }) - }, cacheTTL); - cache.set('poll:radarr-history', { - records: radarrHistories.flatMap(h => h.data.records || []) - }, cacheTTL); - cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL); + if (shouldPollRadarr) { + cache.set('poll:radarr-queue', { + records: radarrQueues.flatMap(q => { + const inst = radarrInstances.find(i => i.id === q.instance); + const url = inst ? inst.url : null; + const key = inst ? inst.apiKey : null; + return (q.data.records || []).map(r => { + if (r.movie) r.movie._instanceUrl = url; + r._instanceUrl = url; + r._instanceKey = key; + return r; + }); + }) + }, cacheTTL); + cache.set('poll:radarr-history', { + records: radarrHistories.flatMap(h => h.data.records || []) + }, cacheTTL); + cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL); + } else { + // Extend TTL of existing cached data when polling is skipped + const existingRadarrQueue = cache.get('poll:radarr-queue'); + const existingRadarrHistory = cache.get('poll:radarr-history'); + const existingRadarrTags = cache.get('poll:radarr-tags'); + if (existingRadarrQueue) cache.set('poll:radarr-queue', existingRadarrQueue, cacheTTL); + if (existingRadarrHistory) cache.set('poll:radarr-history', existingRadarrHistory, cacheTTL); + if (existingRadarrTags) cache.set('poll:radarr-tags', existingRadarrTags, cacheTTL); + } // qBittorrent (already set above in download clients section) From 8609f03c5a710391018415fae80087b32f422a9a Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 16:41:39 +0100 Subject: [PATCH 09/12] fix(webhooks): connect receiver to cache metrics for polling optimization (Phase 5.1) --- server/routes/webhook.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/routes/webhook.js b/server/routes/webhook.js index bd8bd64..0a8ab47 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -167,6 +167,13 @@ router.post('/sonarr', (req, res) => { logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); + // Phase 5.1: update webhook metrics for polling optimization + const sonarrInstances = getSonarrInstances(); + const instance = sonarrInstances.find(i => i.name === instanceName); + if (instance) { + cache.updateWebhookMetrics(instance.url); + } + // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('sonarr', eventType).catch(err => { logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`); @@ -196,6 +203,13 @@ router.post('/radarr', (req, res) => { logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); + // Phase 5.1: update webhook metrics for polling optimization + const radarrInstances = getRadarrInstances(); + const instance = radarrInstances.find(i => i.name === instanceName); + if (instance) { + cache.updateWebhookMetrics(instance.url); + } + // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('radarr', eventType).catch(err => { logToFile(`[Webhook] Radarr background refresh error: ${err.message}`); From 1bef14d590b98eb9030715343f2610a7d3ed6c3f Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 17:11:45 +0100 Subject: [PATCH 10/12] feat(webhooks): security hardening, tests, full documentation audit & polish (Phase 6) --- .env.sample | 19 ++ ARCHITECTURE.md | 233 ++++++++++++++++++ CHANGELOG.md | 48 ++++ README.md | 87 +++++-- SECURITY.md | 18 +- server/routes/webhook.js | 104 +++++++- tests/README.md | 6 +- tests/integration/webhook.test.js | 395 ++++++++++++++++++++++++++++++ 8 files changed, 888 insertions(+), 22 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 tests/integration/webhook.test.js diff --git a/.env.sample b/.env.sample index fc95ae1..eb8ea97 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,20 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here # Example: https://sofarr.example.com or https://192.168.1.100:3001 SOFARR_BASE_URL=https://your-sofarr-url +# --- Webhook Polling Optimization (Phase 5) --- + +# Minutes of silence after which the poller falls back to a full poll +# even if webhooks were recently active. Default: 10 minutes. +# Set lower (e.g. 2) for more aggressive fallback; higher (e.g. 30) to +# reduce background polling on very stable setups. +# WEBHOOK_FALLBACK_TIMEOUT=10 + +# When an instance has received a recent webhook event, the poller skips +# its queue/history fetch entirely (saving API calls). If you still want +# a periodic poll even with webhooks, set this to 1 to disable skipping. +# Default behaviour: skip polling for instances with recent webhook activity. +# WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 + # ============================================================================= # TLS / HTTPS # ============================================================================= @@ -152,4 +166,9 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo # 4. For qBittorrent, ensure Web UI is enabled in settings # 5. User downloads are matched by tags in Sonarr/Radarr - tag your media! # 6. Background polling keeps data fresh; disable it for low-resource setups +# 7. Webhooks (SOFARR_WEBHOOK_SECRET + SOFARR_BASE_URL) enable real-time +# push updates from Sonarr/Radarr and automatically reduce polling load. +# Use the Webhooks Configuration panel in the dashboard UI to enable them +# with one click. The secret must match the header value in each *arr +# notification connection (X-Sofarr-Webhook-Secret). # ============================================================================= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..37ebd24 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,233 @@ +# sofarr — Architecture Reference + +> Concise top-level architecture guide. For the full deep-dive (API reference, matching pipeline, deployment) see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +--- + +## 1. Overview + +sofarr is a **Node.js/Express** single-page application. It aggregates download activity from multiple media automation services, filters results by Emby user identity, and presents a real-time personalised dashboard. + +Three pluggable layers form the core: + +| Layer | Name | Location | +|-------|------|----------| +| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` | +| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` | +| Real-time push | **Webhook receiver** | `server/routes/webhook.js` | + +--- + +## 2. Request / Data Flow + +``` +Browser (SPA) + │ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie + │ GET /api/dashboard/stream → SSE stream → poller cache → matched downloads + │ POST /api/webhook/* ← Sonarr/Radarr push events + │ + ▼ +Express Server (:3001) + ├── Helmet (CSP nonce, HSTS, X-Frame-Options, …) + ├── express-rate-limit (300/15 min general; 60/1 min webhook) + ├── cookie-parser (HMAC-signed session cookie) + ├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook) + │ + ├── /api/auth → login, logout, me, csrf + ├── /api/webhook → [rate-limit] → [secret validation] → [payload validation] + │ → [replay check] → updateWebhookMetrics → processWebhookEvent + ├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON + ├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup + ├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API + └── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy + +Background: + Poller (setInterval POLL_INTERVAL ms) + └── shouldSkipInstancePolling? ──yes──► extend cache TTL, increment pollsSkipped + │ no (or fallback triggered) + ▼ + PDCA Registry.getDownloadsByClientType() + PALDRA Registry.getQueuesByType() / getHistoryByType() / getTagsByType() + │ + ▼ + cache.set('poll:*', data, TTL) + │ + ▼ + notify pollSubscribers → SSE push to all connected browsers +``` + +--- + +## 3. Pluggable Download Client Architecture (PDCA) + +All download clients extend `DownloadClient` (abstract base in `server/clients/DownloadClient.js`): + +``` +DownloadClient (abstract) +├── SABnzbdClient — REST API, API key auth +├── QBittorrentClient — Sync API (incremental deltas), cookie auth, fallback to /torrents/info +├── TransmissionClient — JSON-RPC, session-ID management +└── RTorrentClient — XML-RPC, HTTP Basic Auth +``` + +`DownloadClientRegistry` (`server/utils/downloadClients.js`) initialises all configured clients from `*_INSTANCES` env vars, fetches from all in parallel, and returns a `{ sabnzbd, qbittorrent, transmission, rtorrent }` map. Individual client failures are isolated. + +**Adding a new client:** extend `DownloadClient`, implement `getActiveDownloads()` returning `NormalizedDownload[]`, register in the registry factory. + +--- + +## 4. Pluggable *Arr Retrieval Architecture (PALDRA) + +`server/utils/arrRetrievers.js` provides `arrRetrieverRegistry` which: +- Initialises one retriever per configured Sonarr/Radarr instance +- Exposes `getQueuesByType()`, `getHistoryByType()`, `getTagsByType()` — returning results keyed by `sonarr` / `radarr` +- Results carry `{ instance: instanceId, data: … }` so callers can look up instance credentials + +The poller and webhook processor both use the same registry, ensuring consistency. + +--- + +## 5. Webhook Flow (Phase 1–5.1) + +``` +Sonarr/Radarr + POST /api/webhook/sonarr (X-Sofarr-Webhook-Secret: ) + { + "eventType": "Grab", + "instanceName": "Main Sonarr", + "date": "2026-05-19T10:00:00.000Z", + … + } + │ + ▼ + webhookLimiter (60 req/min/IP) + │ + ▼ + validateWebhookSecret() ──fail──► 401 Unauthorized + │ ok + ▼ + validatePayload() ──fail──► 400 Bad Request + │ ok + ▼ + isReplay() ──yes───► 200 { received: true, duplicate: true } + │ no + ▼ + cache.updateWebhookMetrics(instance.url) ← activates smart polling skip + │ + ▼ + processWebhookEvent('sonarr', 'Grab') [fire-and-forget] + ├── classify: Grab → QUEUE_EVENT + ├── arrRetrieverRegistry.getQueuesByType() + ├── cache.set('poll:sonarr-queue', …, CACHE_TTL) + └── pollAllServices() → pollSubscribers.forEach(cb) → SSE push + │ + ▼ + 200 { received: true } (returned immediately, before fire-and-forget completes) +``` + +--- + +## 6. Smart Polling Optimization (Phase 5) + +``` +pollAllServices() called every POLL_INTERVAL ms: + + globalMetrics = cache.getGlobalWebhookMetrics() + fallbackTriggered = lastGlobalWebhookTimestamp > WEBHOOK_FALLBACK_TIMEOUT ago + + for each service type (sonarr, radarr): + shouldSkip = !fallbackTriggered + && all instances have metrics.eventsReceived > 0 + && all instances have metrics.lastWebhookTimestamp within WEBHOOK_FALLBACK_TIMEOUT + + if shouldSkip: + extend TTL of existing cached data ← no API calls made + increment metrics.pollsSkipped + log "[Poller] Skipping sonarr polling for N instance(s) with active webhooks" + else: + fetch from *arr APIs → update cache +``` + +**Result:** zero *arr API calls per poll cycle when webhooks are active and recent. Falls back automatically after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10). + +--- + +## 7. Cache Keys + +| Key | Content | TTL | +|-----|---------|-----| +| `poll:sab-queue` | SABnzbd queue slots + status | `POLL_INTERVAL × 3` | +| `poll:sab-history` | SABnzbd history slots | `POLL_INTERVAL × 3` | +| `poll:sonarr-queue` | Sonarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` | +| `poll:sonarr-history` | Sonarr history records | `POLL_INTERVAL × 3` | +| `poll:sonarr-tags` | Sonarr tag list per instance | `POLL_INTERVAL × 3` | +| `poll:radarr-queue` | Radarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` | +| `poll:radarr-history` | Radarr history records | `POLL_INTERVAL × 3` | +| `poll:radarr-tags` | Radarr tag list | `POLL_INTERVAL × 3` | +| `poll:qbittorrent` | qBittorrent torrent list | `POLL_INTERVAL × 3` | +| `history:sonarr` | Sonarr history (on-demand, `/api/history/recent`) | 5 min | +| `history:radarr` | Radarr history (on-demand) | 5 min | +| `emby:users` | Emby user list | 60 s | + +When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to 30 s. + +--- + +## 8. Security Model + +| Concern | Mechanism | +|---------|-----------| +| User authentication | Emby credentials → httpOnly HMAC-signed cookie | +| Session validation | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, proxy routes | +| CSRF | Double-submit cookie (`X-CSRF-Token` header) on all state-changing routes | +| Webhook auth | Shared secret on `X-Sofarr-Webhook-Secret` header (webhook routes are outside CSRF) | +| Webhook input | `validatePayload()` allowlists event types; rejects invalid shapes | +| Webhook replay | 5-minute nonce cache keyed on `(eventType, instanceName, date)` | +| Rate limiting | 300 req/15 min (general), 10 fails/15 min (login), 60 req/1 min (webhook) | +| Secret leakage | `sanitizeError()` redacts all secrets from error messages and logs | +| Headers | Helmet v7: CSP nonce, HSTS, X-Frame-Options DENY, noSniff, Referrer-Policy | + +--- + +## 9. Directory Structure (summary) + +``` +sofarr/ +├── server/ +│ ├── app.js Express factory (imported by tests + index.js) +│ ├── index.js Entry point: logging, listen, start poller +│ ├── clients/ PDCA — one file per download client +│ ├── routes/ +│ │ ├── auth.js Login / logout / csrf / me +│ │ ├── dashboard.js SSE stream, downloads, status, cover-art +│ │ ├── history.js Recently completed downloads +│ │ ├── webhook.js Webhook receiver (Phase 1–6) +│ │ ├── sonarr.js Sonarr API proxy + webhook management +│ │ └── radarr.js Radarr API proxy + webhook management +│ ├── middleware/ +│ │ ├── requireAuth.js Cookie auth enforcement +│ │ └── verifyCsrf.js Double-submit CSRF check +│ └── utils/ +│ ├── arrRetrievers.js PALDRA — Sonarr/Radarr fetch registry +│ ├── cache.js MemoryCache + webhook metrics helpers +│ ├── config.js Multi-instance config parser +│ ├── downloadClients.js PDCA registry + factory +│ ├── historyFetcher.js History fetch + event classification +│ ├── poller.js Smart background polling engine +│ ├── sanitizeError.js Secret redaction from errors +│ └── tokenStore.js Emby token store (JSON file, atomic writes) +├── public/ Static SPA (HTML + CSS + vanilla JS) +├── tests/ +│ ├── setup.js Isolated DATA_DIR, SKIP_RATE_LIMIT +│ ├── unit/ Pure unit tests +│ └── integration/ Supertest + nock integration tests +├── docs/ARCHITECTURE.md Full deep-dive architecture documentation +├── ARCHITECTURE.md This file — concise reference +├── SECURITY.md Threat model + hardening guide +├── CHANGELOG.md Version history +└── .env.sample Annotated configuration template +``` + +--- + +*For complete API reference, data-flow diagrams, download matching pipeline, qBittorrent Sync API details, and deployment guidance see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).* diff --git a/CHANGELOG.md b/CHANGELOG.md index 66263f9..8f5c59b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,54 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.4.0] - 2026-05-19 + +### Added + +#### Webhook Integration (Phases 1–5.1) + +- **Webhook receiver endpoints** — `POST /api/webhook/sonarr` and `POST /api/webhook/radarr` accept push events from Sonarr and Radarr with shared-secret validation (`X-Sofarr-Webhook-Secret` header). Dashboard updates in < 1 second after any grab, import, or failure. +- **Selective cache invalidation** — each incoming event is classified into *queue events* (`Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired`) or *history events* (`DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`). Only the affected cache key is refreshed via a lightweight re-poll of that instance, rather than a full poll cycle. +- **SSE broadcast on webhook** — after refreshing the cache, `pollAllServices()` is called as a fire-and-forget, triggering an immediate SSE push to every connected browser. +- **Notification management API proxy** — `GET /api/sonarr/api/v3/notification` and `POST /api/sonarr/api/v3/notification` (and Radarr equivalents) proxy the full *arr Notification API through sofarr with auth + CSRF enforcement. +- **One-click webhook setup** — `POST /api/sonarr/webhook/enable` and `POST /api/radarr/webhook/enable` auto-configure the Sofarr webhook notification connection inside the respective *arr service. `POST /api/sonarr/webhook/test` and `/radarr/webhook/test` trigger a test event. +- **Webhooks Configuration UI** — collapsible panel in the dashboard allowing users to enable, test, and view webhook status for each configured Sonarr/Radarr instance, with per-trigger type indicators showing which event types are active. +- **`SOFARR_WEBHOOK_SECRET`** environment variable — required for webhook endpoints to accept requests. Generate with `openssl rand -hex 32`. +- **`SOFARR_BASE_URL`** environment variable — public URL of sofarr, used by the one-click setup to tell Sonarr/Radarr where to POST events. + +#### Smart Polling Optimization (Phase 5) + +- **Webhook metrics tracking** — `cache.js` now maintains per-instance and global webhook metrics (`lastWebhookTimestamp`, `eventsReceived`, `pollsSkipped`) via `getWebhookMetrics()`, `updateWebhookMetrics()`, `incrementPollsSkipped()`, `getGlobalWebhookMetrics()`. +- **Conditional poll skipping** — `poller.js` calls `shouldSkipInstancePolling()` before each Sonarr/Radarr fetch. If all instances of a type have received a webhook event within the fallback timeout window, their queue/history API calls are skipped entirely; existing cached data has its TTL extended instead. +- **Webhook fallback** — if no webhook events have been received globally for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller forces a full poll regardless of per-instance state. Logged as `[Poller] Webhook fallback triggered`. +- **Poll-skip logging** — logs `[Poller] Skipping sonarr/radarr polling for N instance(s) with active webhooks` when polling is skipped. +- **`WEBHOOK_FALLBACK_TIMEOUT`** environment variable — minutes before fallback polling (default: `10`). +- **`WEBHOOK_POLL_INTERVAL_MULTIPLIER`** environment variable — internal multiplier for TTL calculations when webhooks are active (default: `3`). +- **Phase 5.1 metrics connection** — `webhook.js` calls `cache.updateWebhookMetrics(instance.url)` after every successfully validated event, activating the smart skip logic for that instance. + +#### Security Hardening (Phase 6) + +- **Dedicated webhook rate limiter** — 60 requests per minute per IP on `/api/webhook/*`, stricter than the global 300/15 min API limiter. Bypassed in tests via `SKIP_RATE_LIMIT=1`. +- **Strict input validation** — `validatePayload()` rejects: non-object bodies, missing/non-string/overlong `eventType`, unrecognised event type values (allowlist of 18 known *arr event types), non-string `instanceName`. Returns `400` with a descriptive message. +- **Replay protection** — `isReplay()` tracks recently-seen `(eventType, instanceName, date)` tuples in a `Map` with a 5-minute TTL. Duplicate events within the window return `200 { received: true, duplicate: true }` without triggering a cache refresh or SSE broadcast. +- **35 new webhook integration tests** — cover secret validation (missing/wrong/unconfigured), payload validation (all invalid cases), replay protection, happy-path acceptance of all relevant event types, metrics increment, and secret-never-leaks assertions. + +#### Documentation (Phase 6) + +- **`README.md`** — updated architecture overview diagram, added Webhooks section with quick-setup guide, added PDCA/PALDRA/webhook architecture table, added Webhooks & Smart Polling env var section, added webhook API endpoints, updated test count. +- **`CHANGELOG.md`** — this entry. +- **`SECURITY.md`** — added webhook threat model rows, webhook-specific hardening checklist, and rate-limit table entry for webhook endpoints. +- **`ARCHITECTURE.md`** (root) — new concise top-level architecture reference describing all pluggable layers and the full webhook + polling optimization flow. +- **`.env.sample`** — added `WEBHOOK_FALLBACK_TIMEOUT` and `WEBHOOK_POLL_INTERVAL_MULTIPLIER` with explanatory comments; updated NOTES section. + +### Changed + +- `poller.js` — `pollAllServices()` now conditionally skips Sonarr/Radarr fetches when webhooks are active; extends TTL of existing cache entries instead of overwriting with empty data. +- `cache.js` — exports four new webhook metrics helpers alongside the existing `MemoryCache` singleton. +- `webhook.js` — imported `express-rate-limit`; added `validatePayload()`, `isReplay()`, `VALID_EVENT_TYPES` allowlist, `recentEvents` Map, and per-route `webhookLimiter` middleware. + +--- + ## [1.3.0] - 2026-05-17 ### Added diff --git a/README.md b/README.md index 739b366..099f77a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ **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. + ## What It Does sofarr connects to your media stack and shows you a personalized view of: @@ -12,27 +14,59 @@ sofarr connects to your media stack and shows you a personalized view of: - **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness - **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr - **Multi-Instance Support** - Connect to multiple instances of each service +- **Webhook Push Updates** - Sonarr/Radarr push events instantly to sofarr; dashboard updates in < 1s after a grab or import +- **Smart Polling** - Background polling automatically reduces (or skips) API calls for instances with active webhooks, with configurable fallback ## How It Works ### Architecture Overview ``` -┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐ -│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │ -│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │ -└─────────────┘ └──────────────┘ │ Transmission (Torrents) │ - │ │ rTorrent (Torrents) │ - │ │ Sonarr (TV management) │ - │ │ Radarr (Movie management) │ - │ │ Emby (User authentication) │ - ▼ └─────────────────────────────┘ - ┌──────────────┐ - │ Dashboard │ - │ Aggregator │ - └──────────────┘ +┌─────────────┐ ┌──────────────────────────────────────────────┐ +│ Browser │────▶│ sofarr Server │ +│ (User) │◀────│ Auth · Dashboard · History · Webhooks │ +└─────────────┘ │ │ + SSE push ◀───────│ Poller (smart: skips when webhooks active) │ + │ Cache · PDCA Download Registry · PALDRA │ + └───┬─────────────────────────┬────────────────┘ + │ polls (background) │ receives webhooks + ▼ │ + ┌──────────────────────────┐ ┌─────────▼───────────────────┐ + │ Download Clients │ │ *arr Services │ + │ SABnzbd (Usenet) │ │ Sonarr (TV management) │ + │ qBittorrent (Torrent) │◀───│ Radarr (Movie management) │ + │ Transmission (Torrent) │ └─────────────────────────────┘ + │ rTorrent (Torrent) │ + └──────────────────────────┘ + │ + Emby / Jellyfin + (User authentication) ``` +**Three pluggable layers power sofarr:** + +| Layer | Name | What it does | +|-------|------|--------------| +| Download clients | **PDCA** (Pluggable Download Client Architecture) | Normalised interface for SABnzbd, qBittorrent, Transmission, rTorrent; easy to add new clients | +| *arr data retrieval | **PALDRA** (Pluggable *Arr Library Data Retrieval Architecture) | Unified fetch layer for Sonarr/Radarr queue, history, and tags across multiple instances | +| Real-time push | **Webhook receiver** | Sonarr/Radarr POST events to sofarr; cache updated immediately; SSE pushes to browser in < 1s | + +### Webhooks + +When webhooks are configured, sofarr receives instant push notifications from Sonarr and Radarr whenever a download is grabbed, imported, failed, or renamed. The dashboard updates in under a second — no polling delay. + +**Quick setup:** +1. Set `SOFARR_WEBHOOK_SECRET` and `SOFARR_BASE_URL` in your `.env` +2. Open the sofarr dashboard → **Webhooks Configuration** panel +3. Click **Enable** next to each Sonarr/Radarr instance +4. sofarr auto-configures the notification connection inside each *arr service + +**Smart polling fallback:** Once webhooks are active, the background poller automatically skips queue/history API calls for those instances. If no webhook events arrive for `WEBHOOK_FALLBACK_TIMEOUT` minutes (default: 10), the poller resumes a full poll automatically. + +**Webhook endpoints** (no user authentication required — protected by `X-Sofarr-Webhook-Secret`): +- `POST /api/webhook/sonarr` — receives Sonarr events +- `POST /api/webhook/radarr` — receives Radarr events + ### The Matching Process 1. **User Authentication**: Login via Emby credentials @@ -194,6 +228,17 @@ POLL_INTERVAL=5000 # Background polling interval in ms (default # Set to 0 or "off" to disable (on-demand mode) ``` +### Webhooks & Smart Polling +```bash +# Required for webhook endpoints to accept events +SOFARR_WEBHOOK_SECRET=your-secret # Shared secret (generate: openssl rand -hex 32) +SOFARR_BASE_URL=https://sofarr.example.com # Public URL used by one-click setup + +# Optional tuning +WEBHOOK_FALLBACK_TIMEOUT=10 # Minutes without a webhook before forcing a full poll (default: 10) +WEBHOOK_POLL_INTERVAL_MULTIPLIER=3 # Internal multiplier used by the skip logic (default: 3) +``` + ### Download Clients (PDCA) sofarr uses a **Pluggable Download Client Architecture (PDCA)** that provides a unified interface for all download clients. This enables consistent data normalization, easy addition of new client types, and centralized configuration management. @@ -327,6 +372,20 @@ sofarr polls all configured services in the background and caches the results. D ### History - `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history +### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`) +- `POST /api/webhook/sonarr` — receive Sonarr webhook events +- `POST /api/webhook/radarr` — receive Radarr webhook events + +### Webhook Management (requires auth + CSRF) +- `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections +- `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection +- `GET /api/radarr/api/v3/notification` — list Radarr notification connections +- `POST /api/radarr/api/v3/notification` — create/update Radarr notification connection +- `POST /api/sonarr/webhook/enable` — one-click enable Sofarr webhook in Sonarr +- `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr +- `POST /api/sonarr/webhook/test` — trigger a Sonarr test event +- `POST /api/radarr/webhook/test` — trigger a Radarr test event + ### Service APIs (proxy to your services) - `GET /api/sabnzbd/*` — SABnzbd API proxy - `GET /api/sonarr/*` — Sonarr API proxy @@ -370,7 +429,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/) npm run test:ui # interactive Vitest UI ``` -145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. +290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. ## Development diff --git a/SECURITY.md b/SECURITY.md index 5ef39fe..99f4b98 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,10 @@ | Version | Supported | |---------|-----------| +| 1.4.x | ✅ Yes | +| 1.3.x | ✅ Yes | | 1.2.x | ✅ Yes | -| 1.1.x | ✅ Yes | +| 1.1.x | ❌ No | | 1.0.x | ❌ No | | < 1.0 | ❌ No | @@ -35,6 +37,10 @@ users via Emby. The primary threat surface when exposed to the public internet: | Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped | | Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept | | Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push | +| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch | +| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields | +| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation | +| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` | --- @@ -49,6 +55,15 @@ users via Emby. The primary threat surface when exposed to the public internet: - [ ] HTTPS enforced by the reverse proxy with a valid certificate - [ ] Firewall rules: only 443/80 open externally; 3001 not directly exposed +### Webhook-Specific (if using webhook integration) + +- [ ] `SOFARR_WEBHOOK_SECRET` set to a random 32-byte hex string (`openssl rand -hex 32`) +- [ ] `SOFARR_BASE_URL` set to the public HTTPS URL of sofarr (used by one-click setup) +- [ ] Secret stored only in `.env` or Docker secret — never committed to source control +- [ ] Rotate `SOFARR_WEBHOOK_SECRET` if you suspect it has been leaked; re-enable webhooks via the UI +- [ ] Verify Sonarr/Radarr send the exact secret value in the `X-Sofarr-Webhook-Secret` header +- [ ] Review webhook logs (`[Webhook] WARNING`) for repeated auth failures which may indicate probing + ### Recommended - [ ] Reverse proxy: Nginx, Caddy, or Traefik with TLS termination @@ -145,6 +160,7 @@ server { |----------|-------| | `POST /api/auth/login` | 10 failed attempts per 15 min per IP | | All `/api/*` routes | 300 requests per 15 min per IP | +| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) | --- diff --git a/server/routes/webhook.js b/server/routes/webhook.js index 0a8ab47..9bf5797 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -1,5 +1,6 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); +const rateLimit = require('express-rate-limit'); const { logToFile } = require('../utils/logger'); const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config'); const cache = require('../utils/cache'); @@ -8,6 +9,49 @@ const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/po const router = express.Router(); +// Dedicated rate limiter for webhook endpoints — stricter than the global API limiter. +// Sonarr/Radarr send at most one event per action; 60/min per IP is generous. +// In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited. +const webhookLimiter = rateLimit({ + windowMs: 60 * 1000, + max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many webhook requests' } +}); + +// Valid *arr eventType strings — used for strict input validation. +const VALID_EVENT_TYPES = new Set([ + 'Test', + 'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired', + 'DownloadFolderImported', 'ImportFailed', + 'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries', + 'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete', + 'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored' +]); + +// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys. +// *arr sends a `date` field on every event; we use it as the replay key component. +// TTL = 5 minutes; an event replayed after that window is considered fresh. +const REPLAY_WINDOW_MS = 5 * 60 * 1000; +const recentEvents = new Map(); + +function pruneReplayCache() { + const cutoff = Date.now() - REPLAY_WINDOW_MS; + for (const [key, ts] of recentEvents) { + if (ts < cutoff) recentEvents.delete(key); + } +} + +function isReplay(eventType, instanceName, eventDate) { + if (!eventDate) return false; + pruneReplayCache(); + const key = `${eventType}:${instanceName || ''}:${eventDate}`; + if (recentEvents.has(key)) return true; + recentEvents.set(key, Date.now()); + return false; +} + // Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; @@ -150,21 +194,56 @@ async function processWebhookEvent(serviceType, eventType) { await pollAllServices(); } +/** + * Validate and sanitize the incoming webhook payload. + * Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }. + */ +function validatePayload(body) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { valid: false, reason: 'Payload must be a JSON object' }; + } + const { eventType, instanceName } = body; + if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) { + return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' }; + } + if (!VALID_EVENT_TYPES.has(eventType)) { + return { valid: false, reason: `Unknown eventType: ${eventType}` }; + } + if (instanceName !== undefined && typeof instanceName !== 'string') { + return { valid: false, reason: 'instanceName must be a string if provided' }; + } + const eventDate = body.date || null; + return { valid: true, eventType, instanceName: instanceName || null, eventDate }; +} + /** * POST /api/webhook/sonarr * Receives webhook events from Sonarr instances. * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. * * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. + * Phase 6: rate limiting, input validation, replay protection. */ -router.post('/sonarr', (req, res) => { +router.post('/sonarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } + const validation = validatePayload(req.body); + if (!validation.valid) { + logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`); + return res.status(400).json({ error: validation.reason }); + } + + const { eventType, instanceName, eventDate } = validation; + + if (isReplay(eventType, instanceName, eventDate)) { + logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`); + return res.status(200).json({ received: true, duplicate: true }); + } + try { - const { eventType, instanceName } = req.body || {}; - logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); + logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization @@ -192,15 +271,28 @@ router.post('/sonarr', (req, res) => { * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. * * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. + * Phase 6: rate limiting, input validation, replay protection. */ -router.post('/radarr', (req, res) => { +router.post('/radarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } + const validation = validatePayload(req.body); + if (!validation.valid) { + logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`); + return res.status(400).json({ error: validation.reason }); + } + + const { eventType, instanceName, eventDate } = validation; + + if (isReplay(eventType, instanceName, eventDate)) { + logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`); + return res.status(200).json({ received: true, duplicate: true }); + } + try { - const { eventType, instanceName } = req.body || {}; - logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`); + logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization diff --git a/tests/README.md b/tests/README.md index 8cf6db9..8b0bf61 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,7 +41,10 @@ tests/ │ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry └── integration/ ├── health.test.js # GET /health and /ready endpoints - └── auth.test.js # Full login/logout/me/csrf flows via supertest + nock + ├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock + ├── history.test.js # GET /api/history/recent: auth, filtering, deduplication + └── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation, + # replay protection, metrics, security assertions ``` ## Key design decisions @@ -60,6 +63,7 @@ The tested files meet these per-file minimums (enforced in CI): |---|---|---| | `server/app.js` | 85% | 65% | | `server/routes/auth.js` | 85% | 70% | +| `server/routes/webhook.js` | 80% | 70% | | `server/middleware/requireAuth.js` | 75% | 80% | | `server/utils/sanitizeError.js` | 60% | — | | `server/utils/config.js` | 50% | 55% | diff --git a/tests/integration/webhook.test.js b/tests/integration/webhook.test.js new file mode 100644 index 0000000..cd49db2 --- /dev/null +++ b/tests/integration/webhook.test.js @@ -0,0 +1,395 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +/** + * Integration tests for webhook endpoints: + * POST /api/webhook/sonarr + * POST /api/webhook/radarr + * + * Uses supertest against createApp() (no real server). + * processWebhookEvent() makes outbound *arr API calls — those are blocked by + * nock so tests remain hermetic (fire-and-forget, not awaited by the handler). + * + * Covers: + * - 401 when X-Sofarr-Webhook-Secret is missing or wrong + * - 400 when payload is invalid (missing/unknown eventType, non-object body) + * - 200 + { received: true } for valid events + * - Replay protection: second identical event returns { duplicate: true } + * - Test event (eventType=Test) is accepted and short-circuits the cache refresh + * - cache.updateWebhookMetrics is called when a known instance name is provided + * - cache.getGlobalWebhookMetrics reflects the recorded event + */ + +import request from 'supertest'; +import nock from 'nock'; +import { beforeEach, afterEach } from 'vitest'; +import { createRequire } from 'module'; +import { createApp } from '../../server/app.js'; + +const require = createRequire(import.meta.url); +const cache = require('../../server/utils/cache.js'); + +const VALID_SECRET = 'test-webhook-secret-abc'; + +// Minimal valid Sonarr Grab payload +const SONARR_GRAB = { + eventType: 'Grab', + instanceName: 'Main Sonarr', + date: '2026-05-19T10:00:00.000Z', + series: { id: 1, title: 'Test Show' }, + episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }] +}; + +// Minimal valid Radarr Grab payload +const RADARR_GRAB = { + eventType: 'Grab', + instanceName: 'Main Radarr', + date: '2026-05-19T10:00:01.000Z', + movie: { id: 1, title: 'Test Movie' } +}; + +// Minimal Test event (sent by *arr "Test" button in notifications settings) +const SONARR_TEST = { + eventType: 'Test', + instanceName: 'Main Sonarr', + date: '2026-05-19T10:00:02.000Z' +}; + +function makeApp() { + process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET; + process.env.SONARR_INSTANCES = JSON.stringify([ + { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' } + ]); + process.env.RADARR_INSTANCES = JSON.stringify([ + { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' } + ]); + return createApp({ skipRateLimits: true }); +} + +function postSonarr(app, payload, secret = VALID_SECRET) { + const req = request(app).post('/api/webhook/sonarr').send(payload); + if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret); + return req; +} + +function postRadarr(app, payload, secret = VALID_SECRET) { + const req = request(app).post('/api/webhook/radarr').send(payload); + if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret); + return req; +} + +beforeEach(() => { + // Block outbound *arr calls made by processWebhookEvent (fire-and-forget) + nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] }); + nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] }); +}); + +afterEach(() => { + nock.cleanAll(); + delete process.env.SOFARR_WEBHOOK_SECRET; +}); + +// --------------------------------------------------------------------------- +// Secret validation +// --------------------------------------------------------------------------- +describe('POST /api/webhook/sonarr — secret validation', () => { + it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => { + const app = makeApp(); + const res = await postSonarr(app, SONARR_GRAB, null); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Unauthorized'); + }); + + it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => { + const app = makeApp(); + const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret'); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Unauthorized'); + }); + + it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => { + delete process.env.SOFARR_WEBHOOK_SECRET; + const app = createApp({ skipRateLimits: true }); + const res = await postSonarr(app, SONARR_GRAB, 'anything'); + expect(res.status).toBe(401); + }); +}); + +describe('POST /api/webhook/radarr — secret validation', () => { + it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => { + const app = makeApp(); + const res = await postRadarr(app, RADARR_GRAB, null); + expect(res.status).toBe(401); + expect(res.body.error).toBe('Unauthorized'); + }); + + it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => { + const app = makeApp(); + const res = await postRadarr(app, RADARR_GRAB, 'bad-secret'); + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- +describe('POST /api/webhook/sonarr — input validation', () => { + it('returns 400 when body is not a JSON object (array)', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/webhook/sonarr') + .set('X-Sofarr-Webhook-Secret', VALID_SECRET) + .send([{ eventType: 'Grab' }]); + expect(res.status).toBe(400); + }); + + it('returns 400 when eventType is missing', async () => { + const app = makeApp(); + const res = await postSonarr(app, { instanceName: 'Main Sonarr' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/eventType/); + }); + + it('returns 400 when eventType is an unknown value', async () => { + const app = makeApp(); + const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Unknown eventType/); + }); + + it('returns 400 when eventType is not a string', async () => { + const app = makeApp(); + const res = await postSonarr(app, { eventType: 42 }); + expect(res.status).toBe(400); + }); + + it('returns 400 when eventType exceeds 64 characters', async () => { + const app = makeApp(); + const res = await postSonarr(app, { eventType: 'G'.repeat(65) }); + expect(res.status).toBe(400); + }); + + it('returns 400 when instanceName is not a string', async () => { + const app = makeApp(); + const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/instanceName/); + }); +}); + +describe('POST /api/webhook/radarr — input validation', () => { + it('returns 400 when eventType is missing', async () => { + const app = makeApp(); + const res = await postRadarr(app, { instanceName: 'Main Radarr' }); + expect(res.status).toBe(400); + }); + + it('returns 400 when eventType is unknown', async () => { + const app = makeApp(); + const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Unknown eventType/); + }); +}); + +// --------------------------------------------------------------------------- +// Happy path — valid events +// --------------------------------------------------------------------------- +describe('POST /api/webhook/sonarr — valid events', () => { + it('returns 200 { received: true } for a valid Grab event', async () => { + const app = makeApp(); + const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' }; + const res = await postSonarr(app, payload); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + expect(res.body.duplicate).toBeUndefined(); + }); + + it('returns 200 { received: true } for a Test event', async () => { + const app = makeApp(); + const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' }; + const res = await postSonarr(app, payload); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it('accepts DownloadFolderImported event', async () => { + const app = makeApp(); + const res = await postSonarr(app, { + eventType: 'DownloadFolderImported', + instanceName: 'Main Sonarr', + date: '2026-05-19T11:02:00.000Z' + }); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it('accepts event without instanceName field', async () => { + const app = makeApp(); + const res = await postSonarr(app, { + eventType: 'Grab', + date: '2026-05-19T11:03:00.000Z' + }); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); +}); + +describe('POST /api/webhook/radarr — valid events', () => { + it('returns 200 { received: true } for a valid Grab event', async () => { + const app = makeApp(); + const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' }; + const res = await postRadarr(app, payload); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it('accepts Download event', async () => { + const app = makeApp(); + const res = await postRadarr(app, { + eventType: 'Download', + instanceName: 'Main Radarr', + date: '2026-05-19T12:01:00.000Z' + }); + expect(res.status).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// Replay protection +// --------------------------------------------------------------------------- +describe('Replay protection', () => { + it('sonarr: second identical event (same date) returns duplicate:true', async () => { + const app = makeApp(); + const payload = { + eventType: 'Grab', + instanceName: 'Main Sonarr', + date: '2026-05-19T13:00:00.000Z' + }; + const first = await postSonarr(app, payload); + expect(first.status).toBe(200); + expect(first.body.duplicate).toBeUndefined(); + + const second = await postSonarr(app, payload); + expect(second.status).toBe(200); + expect(second.body.duplicate).toBe(true); + }); + + it('sonarr: event with different date is not considered a duplicate', async () => { + const app = makeApp(); + const first = await postSonarr(app, { + eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z' + }); + expect(first.body.duplicate).toBeUndefined(); + + const second = await postSonarr(app, { + eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z' + }); + expect(second.body.duplicate).toBeUndefined(); + }); + + it('radarr: second identical event returns duplicate:true', async () => { + const app = makeApp(); + const payload = { + eventType: 'Download', + instanceName: 'Main Radarr', + date: '2026-05-19T15:00:00.000Z' + }; + await postRadarr(app, payload); + const second = await postRadarr(app, payload); + expect(second.body.duplicate).toBe(true); + }); + + it('event without date field is never considered a duplicate', async () => { + const app = makeApp(); + const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' }; + const first = await postSonarr(app, payload); + const second = await postSonarr(app, payload); + // Neither should be flagged as duplicate (no date = no replay key) + expect(first.body.duplicate).toBeUndefined(); + expect(second.body.duplicate).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Webhook metrics (Phase 5.1 integration) +// --------------------------------------------------------------------------- +describe('Webhook metrics — cache.updateWebhookMetrics integration', () => { + it('sonarr: increments eventsReceived for a known instance', async () => { + const app = makeApp(); + const instanceUrl = 'https://sonarr.test'; + const before = cache.getWebhookMetrics(instanceUrl); + const countBefore = before ? before.eventsReceived : 0; + + await postSonarr(app, { + eventType: 'Grab', + instanceName: 'Main Sonarr', + date: '2026-05-19T16:00:00.000Z' + }); + + const after = cache.getWebhookMetrics(instanceUrl); + expect(after.eventsReceived).toBe(countBefore + 1); + expect(after.lastWebhookTimestamp).toBeGreaterThan(0); + }); + + it('radarr: increments eventsReceived for a known instance', async () => { + const app = makeApp(); + const instanceUrl = 'https://radarr.test'; + const before = cache.getWebhookMetrics(instanceUrl); + const countBefore = before ? before.eventsReceived : 0; + + await postRadarr(app, { + eventType: 'Download', + instanceName: 'Main Radarr', + date: '2026-05-19T16:01:00.000Z' + }); + + const after = cache.getWebhookMetrics(instanceUrl); + expect(after.eventsReceived).toBe(countBefore + 1); + }); + + it('does not crash when instanceName does not match a configured instance', async () => { + const app = makeApp(); + const res = await postSonarr(app, { + eventType: 'Grab', + instanceName: 'Unknown Instance', + date: '2026-05-19T16:02:00.000Z' + }); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it('global metrics totalWebhookEventsReceived increments after valid event', async () => { + const app = makeApp(); + const beforeGlobal = cache.getGlobalWebhookMetrics(); + const beforeCount = beforeGlobal.totalWebhookEventsReceived; + + await postSonarr(app, { + eventType: 'Grab', + instanceName: 'Main Sonarr', + date: '2026-05-19T17:00:00.000Z' + }); + + const afterGlobal = cache.getGlobalWebhookMetrics(); + expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1); + }); +}); + +// --------------------------------------------------------------------------- +// Secret not included in response +// --------------------------------------------------------------------------- +describe('Security — secret never leaks', () => { + it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => { + const app = makeApp(); + const res = await postSonarr(app, { + eventType: 'Grab', + instanceName: 'Main Sonarr', + date: '2026-05-19T18:00:00.000Z' + }); + expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET); + }); + + it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => { + const app = makeApp(); + const res = await postRadarr(app, RADARR_GRAB, 'wrong'); + expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET); + }); +}); From 67ab378d31e6cee177cc977d9dc76e857ad48ccb Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 18:32:00 +0100 Subject: [PATCH 11/12] docs: merge ARCHITECTURE.md files into single consolidated reference - Combine root ARCHITECTURE.md (webhook/smart-polling focused) with docs/ARCHITECTURE.md (deep-dive) into one authoritative document - Structured into 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow, Caching & Smart Polling, Key Subsystems, Directory Structure, Configuration, Security Model, Technology Stack - Add full-system Mermaid flowchart, webhook sequence diagram, polling cycle sequence diagram, UI state machine, download matching flowchart - Document all cache keys, NormalizedDownload schema, DownloadClientRegistry and arrRetrieverRegistry APIs, webhook event classification table, complete security model with auth/webhook/headers subsections - Remove all development-phase references and internal process language - Remove docs/ARCHITECTURE.md (content consolidated into root file) --- ARCHITECTURE.md | 882 +++++++++++++++++++--- docs/ARCHITECTURE.md | 1716 ------------------------------------------ 2 files changed, 788 insertions(+), 1810 deletions(-) delete mode 100644 docs/ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 37ebd24..09b43bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,35 +1,120 @@ -# sofarr — Architecture Reference +# sofarr — Architecture -> Concise top-level architecture guide. For the full deep-dive (API reference, matching pipeline, deployment) see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +Comprehensive technical reference for the **sofarr** application: a personal media download dashboard that aggregates download activity from SABnzbd, Sonarr, Radarr, qBittorrent, Transmission, and rTorrent, filters results by Emby/Jellyfin user identity, and presents a real-time personalised dashboard. --- -## 1. Overview +## Table of Contents -sofarr is a **Node.js/Express** single-page application. It aggregates download activity from multiple media automation services, filters results by Emby user identity, and presents a real-time personalised dashboard. +1. [Introduction](#1-introduction) +2. [High-Level Architecture](#2-high-level-architecture) +3. [Pluggable Architecture Layers](#3-pluggable-architecture-layers) +4. [Webhook System](#4-webhook-system) +5. [Data Flow and Real-time Updates](#5-data-flow-and-real-time-updates) +6. [Caching and Smart Polling](#6-caching-and-smart-polling) +7. [Key Subsystems](#7-key-subsystems) +8. [Directory Structure](#8-directory-structure) +9. [Configuration and Environment Variables](#9-configuration-and-environment-variables) +10. [Security Model](#10-security-model) +11. [Technology Stack](#11-technology-stack) -Three pluggable layers form the core: +--- + +## 1. Introduction + +sofarr is a **Node.js/Express single-page application** that provides a personalised view of media downloads. It: + +1. **Authenticates** users against an Emby/Jellyfin media server. +2. **Aggregates** download data from multiple *arr service instances and download clients. +3. **Filters** downloads per user — each user only sees media tagged with their username in Sonarr/Radarr. +4. **Presents** a real-time dashboard with progress, speeds, cover art, and status, updated either via background polling or instant webhook push from Sonarr/Radarr. + +Admin users can view all users' downloads, see server status, cache statistics, poll timings, and perform blocklist-and-search operations. + +Three pluggable layers form the architectural core: | Layer | Name | Location | |-------|------|----------| | Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` | | *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` | -| Real-time push | **Webhook receiver** | `server/routes/webhook.js` | +| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` | --- -## 2. Request / Data Flow +## 2. High-Level Architecture + +```mermaid +flowchart TB + subgraph Browser["Browser (SPA — public/)"] + login["Login Form"] + dash["Dashboard Cards"] + status["Status Panel\n(Admin only)"] + history["History Tab"] + end + + subgraph Server["Express Server (:3001)"] + direction TB + mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"] + auth_r["Auth Routes\n/api/auth"] + dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"] + wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"] + hist_r["History Routes\n/api/history"] + proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"] + + subgraph Core["Core Utilities"] + poller["Poller\n(smart background polling)"] + cache["MemoryCache\n(poll:* + webhook metrics)"] + pdca["PDCA Registry\n(download clients)"] + paldra["PALDRA Registry\n(arr retrievers)"] + tokenstore["TokenStore\n(tokens.json)"] + end + end + + subgraph Ext["External Services"] + sab["SABnzbd"] + sonarr["Sonarr"] + radarr["Radarr"] + qbt["qBittorrent"] + rtorrent["rTorrent"] + transmission["Transmission"] + emby["Emby / Jellyfin"] + end + + login -->|"POST /api/auth/login"| auth_r + dash -->|"GET /api/dashboard/stream (SSE)"| dash_r + status -->|"GET /api/dashboard/status"| dash_r + history -->|"GET /api/history/recent"| hist_r + + auth_r --> tokenstore + auth_r -->|"authenticate"| emby + + dash_r --> cache + dash_r --> poller + wh_r --> cache + wh_r --> paldra + hist_r --> cache + proxy_r -->|"proxy"| sonarr & radarr & sab & emby + + poller --> pdca & paldra + poller --> cache + pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission + paldra -->|"HTTP/API"| sonarr & radarr + + sonarr & radarr -->|"POST /api/webhook/*"| wh_r +``` + +### Request routing summary ``` Browser (SPA) │ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie - │ GET /api/dashboard/stream → SSE stream → poller cache → matched downloads + │ GET /api/dashboard/stream → SSE stream → cache → matched downloads │ POST /api/webhook/* ← Sonarr/Radarr push events │ ▼ Express Server (:3001) ├── Helmet (CSP nonce, HSTS, X-Frame-Options, …) - ├── express-rate-limit (300/15 min general; 60/1 min webhook) + ├── express-rate-limit (300/15 min general; 60/1 min webhook; 10 fails/15 min login) ├── cookie-parser (HMAC-signed session cookie) ├── verifyCsrf (double-submit cookie, all state-changing /api routes except auth + webhook) │ @@ -58,46 +143,177 @@ Background: --- -## 3. Pluggable Download Client Architecture (PDCA) +## 3. Pluggable Architecture Layers -All download clients extend `DownloadClient` (abstract base in `server/clients/DownloadClient.js`): +### 3.1 Pluggable Download Client Architecture (PDCA) + +#### Overview + +The PDCA provides a unified, extensible interface for all download clients. This abstraction layer enables: + +- **Client-agnostic polling** — the poller contains no client-specific logic. +- **Easy extension** — add a new client by implementing one interface. +- **Consistent normalisation** — all clients return standardised download objects. +- **Centralised configuration** — a single registry manages all instances. +- **Error isolation** — individual client failures do not affect other clients. + +#### Abstract Base Class + +All download clients extend `DownloadClient` (`server/clients/DownloadClient.js`): + +```javascript +class DownloadClient { + constructor(instanceConfig) + getClientType(): string + getInstanceId(): string + async testConnection(): Promise + async getActiveDownloads(): Promise + async getClientStatus(): Promise // optional + normalizeDownload(download): NormalizedDownload +} +``` + +#### Client Implementations ``` DownloadClient (abstract) -├── SABnzbdClient — REST API, API key auth +├── SABnzbdClient — REST API, API key auth; handles queue + history; normalises time/size units ├── QBittorrentClient — Sync API (incremental deltas), cookie auth, fallback to /torrents/info ├── TransmissionClient — JSON-RPC, session-ID management -└── RTorrentClient — XML-RPC, HTTP Basic Auth +└── RTorrentClient — XML-RPC (xmlrpc 1.3.2), HTTP Basic Auth; maps rTorrent states to normalised statuses ``` -`DownloadClientRegistry` (`server/utils/downloadClients.js`) initialises all configured clients from `*_INSTANCES` env vars, fetches from all in parallel, and returns a `{ sabnzbd, qbittorrent, transmission, rtorrent }` map. Individual client failures are isolated. +#### Normalised Download Schema -**Adding a new client:** extend `DownloadClient`, implement `getActiveDownloads()` returning `NormalizedDownload[]`, register in the registry factory. +Every client returns objects conforming to this schema: + +```javascript +interface NormalizedDownload { + id: string // Client-specific unique ID + title: string // Download title/name + type: 'usenet' | 'torrent' // Download type + client: string // Client identifier ('sabnzbd', 'qbittorrent', etc.) + instanceId: string // Instance identifier + instanceName: string // Instance display name + status: string // Normalised status (Downloading, Seeding, etc.) + progress: number // Progress percentage (0–100) + size: number // Total size in bytes + downloaded: number // Downloaded bytes + speed: number // Current speed in bytes/sec + eta: number | null // ETA in seconds, null if unknown + category?: string // Download category (optional) + tags?: string[] // Download tags (optional) + savePath?: string // Save path (optional) + addedOn?: string // Added timestamp (optional) + arrQueueId?: number // Sonarr/Radarr queue ID (optional) + arrType?: 'series' | 'movie' // Sonarr/Radarr type (optional) + raw?: any // Original client response (escape hatch) +} +``` + +#### Registry (`server/utils/downloadClients.js`) + +`DownloadClientRegistry` manages all instances: + +```javascript +class DownloadClientRegistry { + async initialize() // Create clients from config + getAllClients(): DownloadClient[] + getClient(instanceId): DownloadClient + getClientsByType(type): DownloadClient[] + async getAllDownloads(): NormalizedDownload[] // Fetch from all clients in parallel + async testAllConnections(): Promise + async getAllClientStatuses(): Promise +} +``` + +**Configuration-driven:** reads from `*_INSTANCES` environment variables (JSON array format) with fallback to legacy `*_URL` / `*_API_KEY` / `*_USERNAME` / `*_PASSWORD` variables. + +#### qBittorrent Sync API Details + +Each `QBittorrentClient` instance maintains: + +- **`lastRid`** — response ID from the previous `sync/maindata` call (starts at `0`). +- **`torrentMap`** — `Map` holding the complete state for every known torrent. +- **`fallbackThisCycle`** — boolean tracking whether this poll cycle has already fallen back to the legacy endpoint. + +Per-cycle flow: +1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`. +2. If `full_update` is `true`, rebuild `torrentMap` from scratch. +3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes. +4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`. +5. If the fallback also fails, return an empty array for this cycle and log the error. + +The rest of the application (poller, dashboard) receives data in the same format regardless of which path was taken. + +#### Adding a New Download Client + +1. Create `server/clients/MyClient.js` extending `DownloadClient`. +2. Implement `getActiveDownloads()` returning `NormalizedDownload[]`. +3. Register the class in the registry factory inside `server/utils/downloadClients.js`. --- -## 4. Pluggable *Arr Retrieval Architecture (PALDRA) +### 3.2 Pluggable *arr Retrieval Layer (PALDRA) -`server/utils/arrRetrievers.js` provides `arrRetrieverRegistry` which: -- Initialises one retriever per configured Sonarr/Radarr instance -- Exposes `getQueuesByType()`, `getHistoryByType()`, `getTagsByType()` — returning results keyed by `sonarr` / `radarr` -- Results carry `{ instance: instanceId, data: … }` so callers can look up instance credentials +#### Overview -The poller and webhook processor both use the same registry, ensuring consistency. +`server/utils/arrRetrievers.js` exports `arrRetrieverRegistry`, a singleton that manages one `PollingSonarrRetriever` or `PollingRadarrRetriever` per configured instance. It provides a uniform interface for fetching queue, history, and tag data, keyed by service type. + +The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths. + +#### Registry API + +```javascript +arrRetrieverRegistry = { + async initialize() // idempotent; reads config once + getAllRetrievers(): ArrRetriever[] + getRetriever(instanceId): ArrRetriever | null + getRetrieversByType(type): ArrRetriever[] // 'sonarr' | 'radarr' + + // Typed fetch methods — all return { sonarr: [...], radarr: [...] } + async getQueuesByType(): Promise<{ sonarr, radarr }> + async getHistoryByType(options?): Promise<{ sonarr, radarr }> + async getTagsByType(): Promise<{ sonarr, radarr }> +} +``` + +Each result element is `{ instance: instanceId, data: }`, allowing callers to look up instance credentials from `config.js`. + +#### Retriever API Calls + +| Task | Endpoint | Key Parameters | +|------|----------|----------------| +| Sonarr tags | `GET /api/v3/tag` | — | +| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` | +| Sonarr history | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` | +| Radarr tags | `GET /api/v3/tag` | — | +| Radarr queue | `GET /api/v3/queue` | `includeMovie=true` | +| Radarr history | `GET /api/v3/history` | `pageSize=10` | + +All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others. --- -## 5. Webhook Flow (Phase 1–5.1) +## 4. Webhook System + +### 4.1 Webhook Receiver + +sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event: + +``` +POST /api/webhook/sonarr +POST /api/webhook/radarr +``` + +Both endpoints share identical processing logic: ``` Sonarr/Radarr - POST /api/webhook/sonarr (X-Sofarr-Webhook-Secret: ) - { - "eventType": "Grab", - "instanceName": "Main Sonarr", - "date": "2026-05-19T10:00:00.000Z", - … - } + POST /api/webhook/sonarr + Headers: X-Sofarr-Webhook-Secret: + Body: { "eventType": "Grab", "instanceName": "Main Sonarr", + "date": "2026-05-19T10:00:00.000Z", … } │ ▼ webhookLimiter (60 req/min/IP) @@ -115,119 +331,597 @@ Sonarr/Radarr cache.updateWebhookMetrics(instance.url) ← activates smart polling skip │ ▼ - processWebhookEvent('sonarr', 'Grab') [fire-and-forget] - ├── classify: Grab → QUEUE_EVENT - ├── arrRetrieverRegistry.getQueuesByType() - ├── cache.set('poll:sonarr-queue', …, CACHE_TTL) - └── pollAllServices() → pollSubscribers.forEach(cb) → SSE push + 200 { received: true } ← response sent immediately │ - ▼ - 200 { received: true } (returned immediately, before fire-and-forget completes) + ▼ (fire-and-forget) + processWebhookEvent(serviceType, eventType) + ├── classify: QUEUE_EVENT or HISTORY_EVENT + ├── arrRetrieverRegistry.getQueuesByType() / getHistoryByType() + ├── cache.set('poll:sonarr-queue' | 'poll:sonarr-history', …, CACHE_TTL) + └── pollAllServices() → pollSubscribers.forEach(cb) → SSE push ``` +The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls. + +#### Event Classification + +| Event type | Classification | Cache keys refreshed | +|------------|---------------|---------------------| +| `Grab`, `Download`, `DownloadFailed`, `ManualInteractionRequired` | `QUEUE_EVENT` | `poll:{type}-queue` | +| `DownloadFolderImported`, `ImportFailed`, `EpisodeFileRenamed`, `MovieFileRenamed`, `EpisodeFileRenamedBySeries` | `HISTORY_EVENT` | `poll:{type}-history` | +| `Test`, `Rename`, `SeriesAdd`, `SeriesDelete`, `MovieAdd`, `MovieDelete`, `MovieFileDelete`, `Health`, `ApplicationUpdate`, `HealthRestored` | Informational — no refresh | — | + +#### Accepted Event Types + +The full allowlist enforced by `validatePayload()`: + +``` +Test · Grab · Download · DownloadFailed · ManualInteractionRequired +DownloadFolderImported · ImportFailed +EpisodeFileRenamed · MovieFileRenamed · EpisodeFileRenamedBySeries +Rename · SeriesAdd · SeriesDelete · MovieAdd · MovieDelete · MovieFileDelete +Health · ApplicationUpdate · HealthRestored +``` + +Any `eventType` not in this set is rejected with `400 Bad Request`. + --- -## 6. Smart Polling Optimization (Phase 5) +### 4.2 Real-time Cache and SSE Integration + +When a webhook event is classified as a `QUEUE_EVENT` or `HISTORY_EVENT`: + +1. `arrRetrieverRegistry` fetches fresh data from the relevant *arr instances (in parallel, via PALDRA). +2. The result is written directly into the shared `MemoryCache` under the same `poll:*` key the poller uses — ensuring both paths produce identical cache shapes. +3. `pollAllServices()` is called, which iterates `pollSubscribers` and pushes the updated payload to every open SSE connection immediately. + +The dashboard therefore receives fresh data within the round-trip time of the *arr API call, without waiting for the next poll cycle. + +--- + +### 4.3 Notification Management API + +The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL. + +--- + +## 5. Data Flow and Real-time Updates + +### 5.1 Polling Cycle (background path) + +Every `POLL_INTERVAL` ms the poller fetches all services in parallel: + +| Task | API | Key parameters | +|------|-----|----------------| +| SABnzbd Queue | `GET /api?mode=queue` | `output=json` | +| SABnzbd History | `GET /api?mode=history` | `limit=10` | +| Sonarr Tags | `GET /api/v3/tag` | — | +| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` | +| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` | +| Radarr Tags | `GET /api/v3/tag` | — | +| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` | +| Radarr History | `GET /api/v3/history` | `pageSize=10` | +| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback: `GET /api/v2/torrents/info` | + +Results are stored in `MemoryCache` under `poll:*` keys with TTL `POLL_INTERVAL × 3`. Per-task timings are recorded in `lastPollTimings` for the admin status panel. + +```mermaid +sequenceDiagram + participant Entry as index.js + participant Poller + participant PDCA as PDCA Registry + participant PALDRA as PALDRA Registry + participant Cache as MemoryCache + participant SSE as SSE Subscribers + + Entry->>Poller: startPoller() + loop Every POLL_INTERVAL ms + Poller->>Poller: polling flag check (skip if concurrent) + Poller->>PDCA: getDownloadsByClientType() + Poller->>PALDRA: getQueuesByType() / getHistoryByType() / getTagsByType() + PDCA-->>Poller: { sabnzbd, qbittorrent, rtorrent, transmission } + PALDRA-->>Poller: { sonarr: [...], radarr: [...] } + Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3) + Poller->>SSE: notify all subscribers → push data: frame + end +``` + +### 5.2 Webhook Path (real-time update) + +```mermaid +sequenceDiagram + participant Arr as Sonarr/Radarr + participant WH as /api/webhook/sonarr + participant Cache as MemoryCache + participant PALDRA as PALDRA Registry + participant SSE as SSE Subscribers + + Arr->>WH: POST /api/webhook/sonarr { eventType, instanceName, date } + WH->>WH: validateSecret + validatePayload + isReplay + WH->>Cache: updateWebhookMetrics(instance.url) + WH-->>Arr: 200 { received: true } + Note over WH: fire-and-forget begins + WH->>PALDRA: getQueuesByType() or getHistoryByType() + PALDRA-->>WH: fresh arr data + WH->>Cache: set poll:sonarr-queue / poll:sonarr-history + WH->>SSE: pollAllServices() → push data: frame to all clients +``` + +### 5.3 SSE Stream + +When a browser opens `GET /api/dashboard/stream`: + +1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`). +2. Immediately builds and sends the first payload (same matching logic as `/user-downloads`). +3. Registers a callback with the poller's `onPollComplete` subscriber set. +4. After every subsequent poll cycle (or webhook-triggered broadcast), the callback fires, rebuilds the payload, and writes a `data:` SSE frame. +5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies. +6. On client disconnect: deregisters callback, stops heartbeat, removes from `activeClients` map. + +The browser's native `EventSource` API handles reconnection automatically on network interruption. + +### 5.4 Download Matching Pipeline + +For each connected user the server: + +1. Reads all `poll:*` keys from `MemoryCache`. +2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records. +3. For each SABnzbd/qBittorrent download item, attempts matches in priority order: Sonarr queue → Radarr queue → Sonarr history → Radarr history. +4. Title matching is a **bidirectional, case-insensitive substring match**: `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`. +5. For each match, resolves the series/movie, extracts user tags, checks ownership. +6. Returns only the requesting user's downloads (or all, if admin with `showAll=true`). + +```mermaid +flowchart TD + Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"} + SQ -->|yes| SQR["Resolve series · extract user tag"] + SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"} + RQ -->|yes| RQR["Resolve movie · extract user tag"] + RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"} + SH -->|yes| SHR["Resolve series via seriesId"] + SH -->|no| RH{"Radarr HISTORY\nmatch (title)"} + RH -->|yes| RHR["Resolve movie via movieId"] + RH -->|no| Skip(["Skip — unmatched"]) + + SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"} + Tagged -->|yes| Include(["Include in response"]) + Tagged -->|no| Skip +``` + +#### Tag matching + +Users are matched to downloads via Sonarr/Radarr tags: + +1. **Exact match** — tag label (lowercased) === username (lowercased). +2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims. + +#### Matched download object fields + +| Field | Type | Description | +|-------|------|-------------| +| `type` | `'series'`/`'movie'`/`'torrent'` | Media type | +| `title` | string | Raw download title | +| `coverArt` | string/null | Poster URL from *arr | +| `status` | string | Download status | +| `progress` | string | Percentage complete | +| `size`/`mb`/`mbmissing` | string/number | Size info | +| `speed` | string | Current download speed | +| `eta` | string | Estimated time remaining | +| `seriesName`/`movieName` | string | Friendly media title | +| `episodes` | `{season, episode, title}[]` | Episodes covered (sorted); empty array if Sonarr has no data | +| `allTags` | string[] | All resolved tag labels on the series/movie | +| `matchedUserTag` | string/null | Tag label matching the requesting user | +| `tagBadges` | `{label, matchedUser}[]`/undefined | (Admin `showAll` only) each tag classified against Emby user list | +| `importIssues` | string[]/null | Import warning/error messages | +| `canBlocklist` | boolean | `true` if the current user may blocklist this download | +| `downloadPath` | string/null | (Admin) Download client path | +| `targetPath` | string/null | (Admin) *arr target path | +| `arrLink` | string/null | (Admin) Link to *arr web UI | +| `arrQueueId` | number/null | (Admin) Sonarr/Radarr queue record id | +| `arrType` | `'sonarr'`/`'radarr'`/null | (Admin) Which *arr service owns this queue entry | +| `arrInstanceUrl` | string/null | (Admin) Base URL of the *arr instance | +| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance | +| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search | +| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command | +| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added | +| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk | + +--- + +## 6. Caching and Smart Polling + +### 6.1 Cache Layer + +`server/utils/cache.js` exports a singleton `MemoryCache` backed by a `Map`. Each entry carries an expiration timestamp. The cache is shared by the poller, webhook processor, and all route modules. + +```javascript +class MemoryCache { + get(key): any + set(key, value, ttlMs) + invalidate(key) + clear() + getStats(): CacheStats // per-key size, item count, TTL remaining + + // Webhook metrics helpers + updateWebhookMetrics(instanceUrl) + getWebhookMetrics(instanceUrl): { eventsReceived, lastWebhookTimestamp, pollsSkipped } + getGlobalWebhookMetrics(): { lastGlobalWebhookTimestamp } +} +``` + +### 6.2 Cache Keys + +| Key | Content | TTL | +|-----|---------|-----| +| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | `POLL_INTERVAL × 3` | +| `poll:sab-history` | `{ slots }` | `POLL_INTERVAL × 3` | +| `poll:sonarr-queue` | `{ records }` with embedded `series` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` | +| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | `POLL_INTERVAL × 3` | +| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` | +| `poll:radarr-queue` | `{ records }` with embedded `movie` objects + `_instanceUrl`/`_instanceKey` | `POLL_INTERVAL × 3` | +| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` | +| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` | +| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` | +| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min | +| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min | +| `emby:users` | `Map` | 60 s | + +When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to **30 s** and data is fetched on-demand when the dashboard finds an empty cache entry. + +### 6.3 Background Polling Modes + +| Mode | `POLL_INTERVAL` | Behaviour | +|------|----------------|-----------| +| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms; SSE subscribers notified after each cycle | +| **On-demand** | `0` / `off` / `false` | Fetch triggered by first dashboard request when cache is empty; cached 30 s | + +The poller uses a `polling` boolean flag to prevent concurrent cycles: if an interval fires while the previous poll is still running, the new invocation is skipped and logged. + +### 6.4 Smart Polling Optimisation + +When Sonarr/Radarr are configured to send webhooks to sofarr, the poller automatically reduces unnecessary API calls: ``` pollAllServices() called every POLL_INTERVAL ms: globalMetrics = cache.getGlobalWebhookMetrics() fallbackTriggered = lastGlobalWebhookTimestamp > WEBHOOK_FALLBACK_TIMEOUT ago - + for each service type (sonarr, radarr): shouldSkip = !fallbackTriggered && all instances have metrics.eventsReceived > 0 && all instances have metrics.lastWebhookTimestamp within WEBHOOK_FALLBACK_TIMEOUT if shouldSkip: - extend TTL of existing cached data ← no API calls made + extend TTL of existing cached data ← zero *arr API calls increment metrics.pollsSkipped log "[Poller] Skipping sonarr polling for N instance(s) with active webhooks" else: fetch from *arr APIs → update cache ``` -**Result:** zero *arr API calls per poll cycle when webhooks are active and recent. Falls back automatically after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10). +**Effect:** zero *arr API calls per poll cycle when webhooks are active and recent. The poller automatically falls back to full polling after `WEBHOOK_FALLBACK_TIMEOUT` minutes of silence (default: 10 minutes), ensuring the dashboard remains accurate even if webhooks stop arriving. + +### 6.5 Active SSE Client Tracking + +SSE connections are tracked precisely in `activeClients` (a `Map` keyed by `${username}:${connectedAt}`): registered on connect, removed on disconnect. The admin status panel shows each connected user and their connection duration. The `type: 'sse'` field distinguishes SSE clients from other connection types. --- -## 7. Cache Keys +## 7. Key Subsystems -| Key | Content | TTL | -|-----|---------|-----| -| `poll:sab-queue` | SABnzbd queue slots + status | `POLL_INTERVAL × 3` | -| `poll:sab-history` | SABnzbd history slots | `POLL_INTERVAL × 3` | -| `poll:sonarr-queue` | Sonarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` | -| `poll:sonarr-history` | Sonarr history records | `POLL_INTERVAL × 3` | -| `poll:sonarr-tags` | Sonarr tag list per instance | `POLL_INTERVAL × 3` | -| `poll:radarr-queue` | Radarr queue records (with `_instanceUrl`) | `POLL_INTERVAL × 3` | -| `poll:radarr-history` | Radarr history records | `POLL_INTERVAL × 3` | -| `poll:radarr-tags` | Radarr tag list | `POLL_INTERVAL × 3` | -| `poll:qbittorrent` | qBittorrent torrent list | `POLL_INTERVAL × 3` | -| `history:sonarr` | Sonarr history (on-demand, `/api/history/recent`) | 5 min | -| `history:radarr` | Radarr history (on-demand) | 5 min | -| `emby:users` | Emby user list | 60 s | +### 7.1 Download Clients -When polling is disabled (`POLL_INTERVAL=0`), all `poll:*` TTLs fall back to 30 s. +See [Section 3.1](#31-pluggable-download-client-architecture-pdca) for full detail. The client hierarchy is: + +``` +DownloadClient (abstract — server/clients/DownloadClient.js) +├── SABnzbdClient.js — Usenet; REST; API key auth +├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth +├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management +└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth +``` + +`server/utils/qbittorrent.js` is a legacy compatibility shim that delegates to `QBittorrentClient`. + +### 7.2 Queue & History Processing + +**`server/utils/historyFetcher.js`** fetches history records from all Sonarr/Radarr instances for a configurable date window. Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`. + +**`server/routes/history.js`** (`GET /api/history/recent`) returns recently completed (imported or failed) downloads filtered for the authenticated user. Supports `?days=N` (default `RECENT_COMPLETED_DAYS`, capped at 90) and `?showAll=true` for admins. Results are sorted newest first. + +**`server/routes/dashboard.js`** (`POST /api/dashboard/blocklist-search`) removes a Sonarr/Radarr queue item with `blocklist=true` and immediately triggers an `EpisodeSearch` or `MoviesSearch` command. Non-admin users may only blocklist when import issues are present, or (for qBittorrent only) the torrent is over 1 hour old with less than 100% availability. + +### 7.3 Dashboard & Frontend + +The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `public/app.js`, styled by `public/style.css`, and structured by `public/index.html`. Three CSS themes are available via the `data-theme` attribute on `` and persist in `localStorage`: + +- **Light** — Purple gradient header, white cards +- **Dark** — Dark surfaces, muted accents +- **Mono** — Monochrome, minimal colour + +#### UI state machine + +```mermaid +stateDiagram-v2 + [*] --> SplashScreen : Page load + SplashScreen --> CheckAuth : checkAuthentication() + + state CheckAuth <> + CheckAuth --> LoginForm : No session + CheckAuth --> Dashboard : Valid session + + LoginForm --> Dashboard : Auth success (fade transition) + Dashboard --> LoginForm : Logout (stopSSE) + + state Dashboard { + [*] --> Rendering + Rendering --> Rendering : SSE message → renderDownloads() + + state SSEConnection { + [*] --> Connecting + Connecting --> Connected : First message + Connected --> Reconnecting : Connection lost + Reconnecting --> Connected : Auto-reconnect + Connected --> Connecting : showAll toggled + } + + state StatusPanel { + [*] --> Closed + Closed --> Open : Click Status (admin) + Open --> Closed : Click close + Open --> Open : 5s timer refresh + } + } +``` + +#### Key frontend functions + +| Function | Purpose | +|----------|---------| +| `checkAuthentication()` | On load: check session → show dashboard or login | +| `handleLogin()` | Authenticate, fade login → splash → dashboard | +| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data | +| `stopSSE()` | Close `EventSource` and cancel reconnect timer | +| `renderDownloads()` | Diff-based card rendering (create/update/remove) | +| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button | +| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | +| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state | +| `toggleStatusPanel()` | Show/hide admin status panel | +| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) | +| `initThemeSwitcher()` | Light / Dark / Mono theme support | +| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` | +| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards | + +#### Tag badge rendering + +- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`). +- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost). --- -## 8. Security Model - -| Concern | Mechanism | -|---------|-----------| -| User authentication | Emby credentials → httpOnly HMAC-signed cookie | -| Session validation | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, proxy routes | -| CSRF | Double-submit cookie (`X-CSRF-Token` header) on all state-changing routes | -| Webhook auth | Shared secret on `X-Sofarr-Webhook-Secret` header (webhook routes are outside CSRF) | -| Webhook input | `validatePayload()` allowlists event types; rejects invalid shapes | -| Webhook replay | 5-minute nonce cache keyed on `(eventType, instanceName, date)` | -| Rate limiting | 300 req/15 min (general), 10 fails/15 min (login), 60 req/1 min (webhook) | -| Secret leakage | `sanitizeError()` redacts all secrets from error messages and logs | -| Headers | Helmet v7: CSP nonce, HSTS, X-Frame-Options DENY, noSniff, Referrer-Policy | - ---- - -## 9. Directory Structure (summary) +## 8. Directory Structure ``` sofarr/ ├── server/ -│ ├── app.js Express factory (imported by tests + index.js) -│ ├── index.js Entry point: logging, listen, start poller -│ ├── clients/ PDCA — one file per download client +│ ├── app.js Express app factory — imported by tests and index.js +│ ├── index.js Entry point: logging setup, server listen, poller start +│ ├── clients/ PDCA — one file per download client + retriever +│ │ ├── DownloadClient.js Abstract base class for all download clients +│ │ ├── QBittorrentClient.js +│ │ ├── SABnzbdClient.js +│ │ ├── TransmissionClient.js +│ │ ├── RTorrentClient.js +│ │ ├── PollingSonarrRetriever.js PALDRA — Sonarr retriever +│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever │ ├── routes/ -│ │ ├── auth.js Login / logout / csrf / me -│ │ ├── dashboard.js SSE stream, downloads, status, cover-art -│ │ ├── history.js Recently completed downloads -│ │ ├── webhook.js Webhook receiver (Phase 1–6) +│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout +│ │ ├── dashboard.js SSE /stream, /user-downloads, /user-summary, /status, /cover-art, /blocklist-search +│ │ ├── history.js GET /api/history/recent +│ │ ├── webhook.js POST /api/webhook/sonarr|radarr │ │ ├── sonarr.js Sonarr API proxy + webhook management -│ │ └── radarr.js Radarr API proxy + webhook management +│ │ ├── radarr.js Radarr API proxy + webhook management +│ │ ├── emby.js Emby API proxy +│ │ └── sabnzbd.js SABnzbd API proxy │ ├── middleware/ -│ │ ├── requireAuth.js Cookie auth enforcement -│ │ └── verifyCsrf.js Double-submit CSRF check +│ │ ├── requireAuth.js httpOnly cookie auth enforcement +│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe) │ └── utils/ -│ ├── arrRetrievers.js PALDRA — Sonarr/Radarr fetch registry +│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry │ ├── cache.js MemoryCache + webhook metrics helpers │ ├── config.js Multi-instance config parser │ ├── downloadClients.js PDCA registry + factory │ ├── historyFetcher.js History fetch + event classification +│ ├── logger.js File logger (DATA_DIR/server.log) │ ├── poller.js Smart background polling engine -│ ├── sanitizeError.js Secret redaction from errors -│ └── tokenStore.js Emby token store (JSON file, atomic writes) -├── public/ Static SPA (HTML + CSS + vanilla JS) +│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient +│ ├── sanitizeError.js Secret redaction from errors/logs +│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL) +├── public/ Static SPA (served by Express) +│ ├── index.html HTML shell: splash, login, dashboard +│ ├── app.js All frontend logic +│ ├── style.css Themes, layout, responsive design +│ ├── favicon.ico / *.png Favicons +│ └── images/ Logo / splash screen assets ├── tests/ -│ ├── setup.js Isolated DATA_DIR, SKIP_RATE_LIMIT -│ ├── unit/ Pure unit tests +│ ├── README.md Testing approach and coverage targets +│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass +│ ├── unit/ Pure unit tests (no HTTP) │ └── integration/ Supertest + nock integration tests -├── docs/ARCHITECTURE.md Full deep-dive architecture documentation -├── ARCHITECTURE.md This file — concise reference -├── SECURITY.md Threat model + hardening guide +├── .gitea/workflows/ +│ ├── ci.yml Security audit + test/coverage on every push/PR +│ ├── build-image.yml Docker image build and push +│ ├── create-release.yml Release tagging workflow +│ ├── docs-check.yml Markdown lint + Mermaid validation +│ └── licence-check.yml Production dependency licence check +├── Dockerfile Multi-stage production image (node:22-alpine) +├── docker-compose.yaml Example compose deployment +├── vitest.config.js Test runner configuration with per-file coverage thresholds +├── package.json Dependencies and scripts +├── ARCHITECTURE.md This document +├── SECURITY.md Threat model and hardening guide ├── CHANGELOG.md Version history -└── .env.sample Annotated configuration template +└── .env.sample Annotated environment variable template ``` --- -*For complete API reference, data-flow diagrams, download matching pipeline, qBittorrent Sync API details, and deployment guidance see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).* +## 9. Configuration and Environment Variables + +### 9.1 Core Server + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `PORT` | No | `3001` | Server listen port | +| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation | +| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable. In Docker: `/app/data` (named volume). | +| `COOKIE_SECRET` | No* | — | Signs all session cookies with HMAC-SHA256. **Strongly recommended in production** (server exits on startup if unset in `NODE_ENV=production`). Generate with `openssl rand -hex 32`. | +| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` when behind a TLS-terminating reverse proxy (nginx, Caddy, Traefik) so `req.ip` and `req.secure` are correct. | +| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | +| `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window for `/api/history/recent`. Overridable per-request via `?days=`. Capped at 90. | + +### 9.2 TLS / HTTPS + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `TLS_ENABLED` | No | `true` | Set to `false` to run plain HTTP (e.g. when TLS is terminated by a reverse proxy). | +| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to TLS certificate (PEM). Defaults to the bundled self-signed snakeoil certificate. | +| `TLS_KEY` | No | `certs/snakeoil.key` | Path to TLS private key (PEM). | + +### 9.3 Webhook + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `SOFARR_WEBHOOK_SECRET` | Yes* | — | Shared secret validated on the `X-Sofarr-Webhook-Secret` header. Webhook endpoints reject all requests if this is not set. Generate with `openssl rand -hex 32`. | +| `SOFARR_BASE_URL` | Yes* | — | Public base URL of this sofarr instance (e.g. `https://sofarr.example.com`). Used by the one-click webhook configuration endpoints to tell Sonarr/Radarr where to send events. | +| `WEBHOOK_FALLBACK_TIMEOUT` | No | `10` | Minutes of silence after which the poller falls back to full polling even when webhooks were recently active. | + +### 9.4 Polling + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `POLL_INTERVAL` | No | `5000` | Background poll interval in ms. Set to `0`, `off`, or `false` to disable and use on-demand mode. | + +### 9.5 Emby + +| Variable | Required | Default | Description | +|----------|:--------:|---------|-------------| +| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) | +| `EMBY_API_KEY` | Yes | — | Emby API key — used by the poller to list users for tag badge classification | + +### 9.6 Service Instances + +All service instances support both a JSON array format (recommended) and a legacy single-instance format: + +| Variable | Required | Format | +|----------|:--------:|--------| +| `SONARR_INSTANCES` | Yes* | JSON array | +| `SONARR_URL` + `SONARR_API_KEY` | Yes* | Legacy single-instance | +| `RADARR_INSTANCES` | Yes* | JSON array | +| `RADARR_URL` + `RADARR_API_KEY` | Yes* | Legacy single-instance | +| `SABNZBD_INSTANCES` | Yes* | JSON array | +| `SABNZBD_URL` + `SABNZBD_API_KEY` | Yes* | Legacy single-instance | +| `QBITTORRENT_INSTANCES` | No | JSON array (uses `username`/`password` not `apiKey`) | +| `RTORRENT_INSTANCES` | No | JSON array (URL must include the full XML-RPC path, e.g. `/RPC2`) | + +\* Either `*_INSTANCES` or the legacy pair is required for each service. + +#### JSON array instance format + +```json +[ + { "name": "main", "url": "https://sonarr.example.com", "apiKey": "your-api-key" }, + { "name": "4k", "url": "https://sonarr4k.example.com", "apiKey": "your-4k-api-key" } +] +``` + +qBittorrent and rTorrent instances use `username` and `password` instead of `apiKey`. + +Each instance receives an `id` derived from `name` (or index if unnamed), used as the key in PDCA and PALDRA registries. + +--- + +## 10. Security Model + +### 10.1 Authentication and Sessions + +| Concern | Mechanism | +|---------|-----------| +| **User authentication** | Emby credentials via `POST /Users/authenticatebyname`. A deterministic `DeviceId` (SHA-256 of username, first 16 chars) ensures Emby reuses the same session on every login. | +| **Session cookie** | `httpOnly`, `sameSite: strict`, `secure` when `TRUST_PROXY` is set. Payload: `{ id, name, isAdmin }` only — the Emby `AccessToken` is **never** sent to the browser. Signed with HMAC when `COOKIE_SECRET` is set. | +| **Token store** | Emby `AccessToken`s stored server-side in `DATA_DIR/tokens.json` (atomic writes, 31-day TTL, hourly pruning). Used only for server-side Emby logout. | +| **Session validation** | `requireAuth` middleware on all `/api/dashboard`, `/api/history`, and proxy routes. Returns `401` if the cookie is absent, tampered, or schema-invalid. | +| **CSRF protection** | Double-submit cookie pattern. `verifyCsrf` middleware compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Applied to all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`) under `/api/*` except auth and webhook routes. | +| **Remember-me** | `rememberMe: true` → persistent cookie, `Max-Age` 30 days. `rememberMe: false` → session cookie (expires on browser close). | + +### 10.2 Webhook Security + +| Concern | Mechanism | +|---------|-----------| +| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). | +| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). | +| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. | +| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. | + +### 10.3 Additional Security Measures + +| Concern | Mechanism | +|---------|-----------| +| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. | +| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. | +| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. | +| **Body size** | `express.json` body limit: 64 KB. | +| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. | +| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. | + +--- + +## 11. Technology Stack + +### Runtime and Framework + +| Layer | Technology | Notes | +|-------|-----------|-------| +| Runtime | Node.js 22 (Alpine) | LTS; ESM-ready; V8 coverage built-in | +| Framework | Express 4.x | HTTP server, routing, middleware | +| HTTP client | axios 1.x | External API communication | +| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication | +| Frontend | Vanilla JS + CSS | SPA, no build step required | +| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image | +| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels | + +### Security Middleware + +| Package | Version | Purpose | +|---------|---------|---------| +| `helmet` | 7.x | HTTP security headers (CSP nonce, HSTS, referrer policy, frame options) | +| `express-rate-limit` | 7.x | General, login, and webhook rate limiters | +| `cookie-parser` | 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) | + +### Auth and Session + +| Component | Technology | Details | +|-----------|-----------|---------| +| Identity provider | Emby / Jellyfin API | `POST /Users/authenticatebyname` | +| Session cookie | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` is set | +| CSRF protection | Double-submit cookie | `csrf_token` cookie + `X-CSRF-Token` header; `crypto.timingSafeEqual` | +| Token store | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning | + +### Testing + +| Tool | Version | Purpose | +|------|---------|---------| +| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` | +| `supertest` | 7.x | HTTP integration testing against the Express app factory | +| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) | + +### CI/CD + +| Workflow file | Trigger | Purpose | +|---------------|---------|---------| +| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite with V8 coverage | +| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry | +| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release | +| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation | +| `licence-check.yml` | Push / PR touching `package.json` | Verify production dependency licences are MIT-compatible | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index ffc6757..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,1716 +0,0 @@ -# sofarr — Architecture Documentation - -Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity. - ---- - -## Table of Contents - -1. [System Overview](#1-system-overview) -2. [Technology Stack](#2-technology-stack) -3. [Directory Structure](#3-directory-structure) -4. [Component Architecture](#4-component-architecture) -5. [Data Flow](#5-data-flow) -6. [Authentication & Authorisation](#6-authentication--authorisation) -7. [Background Polling & Caching](#7-background-polling--caching) -8. [Download Matching Pipeline](#8-download-matching-pipeline) -9. [API Reference](#9-api-reference) -10. [Frontend Architecture](#10-frontend-architecture) -11. [Configuration](#11-configuration) -12. [Deployment](#12-deployment) -13. [Diagrams (Mermaid)](#13-diagrams) - ---- - -## 1. System Overview - -sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by: - -1. **Authenticating** users against an Emby/Jellyfin media server. -2. **Aggregating** download data from multiple *arr service instances and download clients. -3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr. -4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status. - -Admin users can view all users' downloads, see server status, cache statistics, and poll timings. - -### High-Level Architecture - -```mermaid -flowchart TB - subgraph Browser["Browser (SPA)"] - login["Login Form"] - dash["Dashboard Cards"] - status["Status Panel\n(Admin only)"] - end - - subgraph Server["Express Server (:3001)"] - auth_r["Auth Routes\n/api/auth"] - dash_r["Dashboard Routes\n/api/dashboard"] - emby_r["Emby Routes\n/api/emby"] - static_f["Static Files\npublic/"] - - subgraph Utils["Utilities Layer"] - poller["Poller"] - cache["Cache"] - config["Config"] - qbt["qBittorrent"] - end - end - - subgraph Ext["External Services"] - sab["SABnzbd\n(Usenet)"] - sonarr["Sonarr\n(TV)"] - radarr["Radarr\n(Movies)"] - qbittorrent["qBittorrent\n(Torrent)"] - emby["Emby / Jellyfin\n(Auth + User DB)"] - end - - login -->|"POST /login"| auth_r - dash -->|"GET /stream SSE\nGET /user-downloads"| dash_r - status -->|"GET /status"| dash_r - - auth_r -->|"authenticate"| emby - emby_r -->|"proxy"| emby - dash_r --> Utils - poller -->|"HTTP/API calls"| sab & sonarr & radarr - qbt -->|"HTTP/API calls"| qbittorrent - static_f -.->|"serve"| Browser -``` - ---- - -## 2. Technology Stack - -### Runtime & Framework - -| Layer | Technology | Purpose | -|-------|-----------|------| -| **Runtime** | Node.js 22 (Alpine) | Server runtime | -| **Framework** | Express 4.x | HTTP server, routing, middleware | -| **HTTP Client** | axios 1.x | External API communication | -| **Frontend** | Vanilla JS + CSS | Single-page app, no build step | -| **Containerisation** | Docker multi-stage (Alpine) | Production deployment | -| **Logging** | Custom logger + `console.*` | File + stdout logging with levels | - -### Security Middleware - -| Package | Purpose | -|---------|--------| -| `helmet` 7.x | HTTP security headers (CSP with per-request nonce, HSTS, referrer policy) | -| `express-rate-limit` 7.x | 300 req/15 min general limiter; 10 failed attempts/15 min login limiter | -| `cookie-parser` 1.x | Signed cookie support (HMAC via `COOKIE_SECRET`) | - -### Auth & Session - -| Component | Technology | Details | -|-----------|-----------|--------| -| **Identity** | Emby API | `POST /Users/authenticatebyname` | -| **Session cookie** | `httpOnly` + `sameSite: strict` | Signed when `COOKIE_SECRET` set | -| **CSRF protection** | Double-submit cookie pattern | `csrf_token` cookie + `X-CSRF-Token` header | -| **Token store** | JSON file (`DATA_DIR/tokens.json`) | Atomic writes, 31-day TTL, hourly pruning | - -### Testing - -| Tool | Purpose | -|------|---------| -| `vitest` 4.x | Test runner (V8 coverage built-in) | -| `supertest` 7.x | HTTP integration testing | -| `nock` 14.x | HTTP interception at Node layer (works with CJS `require('axios')`) | - ---- - -## 3. Directory Structure - -``` -sofarr/ -├── server/ # Backend application -│ ├── index.js # Entry point: logging setup, server listen, poller start -│ ├── app.js # Express app factory (imported by index.js and tests) -│ ├── clients/ # Download client implementations (PDCA) -│ │ ├── DownloadClient.js # Abstract base class for all download clients -│ │ ├── QBittorrentClient.js # qBittorrent client implementation -│ │ ├── SABnzbdClient.js # SABnzbd client implementation -│ │ └── TransmissionClient.js # Transmission client implementation (proof-of-concept) -│ ├── routes/ -│ │ ├── auth.js # POST /login, GET /me, GET /csrf, POST /logout -│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status, /cover-art -│ │ ├── emby.js # Proxy routes to Emby API -│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API -│ │ ├── sonarr.js # Proxy routes to Sonarr API -│ │ ├── radarr.js # Proxy routes to Radarr API -│ │ └── history.js # GET /api/history/recent — recently completed downloads -│ ├── middleware/ -│ │ ├── requireAuth.js # httpOnly cookie auth enforcement -│ │ └── verifyCsrf.js # CSRF double-submit cookie validation -│ └── utils/ -│ ├── cache.js # MemoryCache class (Map + TTL + stats) -│ ├── config.js # Multi-instance service configuration parser -│ ├── downloadClients.js # Registry and factory for download clients -│ ├── historyFetcher.js # Fetch + cache Sonarr/Radarr history; event classification -│ ├── logger.js # File logger (DATA_DIR/server.log) -│ ├── poller.js # Background polling engine + timing -│ ├── qbittorrent.js # Legacy compatibility layer (delegates to new system) -│ ├── sanitizeError.js # Redacts secrets from error messages before logging -│ └── tokenStore.js # JSON file-backed Emby token store (atomic, TTL) -├── public/ # Static frontend (served by Express) -│ ├── index.html # HTML shell: splash, login, dashboard -│ ├── app.js # All frontend logic (auth, rendering, status) -│ ├── style.css # Themes, layout, responsive design -│ ├── favicon.ico # Multi-size favicon (16/32/48px) -│ ├── favicon-32.png # 32px PNG favicon -│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA) -│ └── images/ # Logo / splash screen assets -├── tests/ -│ ├── README.md # Testing approach, design decisions, coverage targets -│ ├── setup.js # Global setup: isolated DATA_DIR, rate-limit bypass -│ ├── unit/ # Pure unit tests (no HTTP) -│ └── integration/ # Supertest integration tests (nock for external HTTP) -├── docs/ -│ ├── ARCHITECTURE.md # This document -├── .gitea/workflows/ -│ ├── ci.yml # Security audit + test/coverage CI jobs -│ ├── build-image.yml # Docker image build and push -│ └── create-release.yml # Release tagging workflow -├── Dockerfile # Multi-stage production container image (node:22-alpine) -├── docker-compose.yaml # Example compose deployment -├── vitest.config.js # Test runner configuration with per-file coverage thresholds -├── package.json # Dependencies and scripts -├── .env.sample # Annotated environment variable template -└── README.md # User-facing documentation -``` - ---- - -## 4. Component Architecture - -### 4.1 Application Factory (`server/app.js`) and Entry Point (`server/index.js`) - -**`server/app.js`** is a `createApp(options?)` factory that builds and returns the configured Express `app` instance. It is imported by both `server/index.js` (production) and the test suite. Keeping app creation separate from `app.listen()` means tests get a clean instance without triggering log-file setup, `process.exit()` calls, or the background poller. - -`createApp` responsibilities: -- Configure `trust proxy` from `TRUST_PROXY` env var -- Apply `helmet` with a per-request CSP nonce (`crypto.randomBytes(16)`) for inline styles/scripts -- Add `Permissions-Policy` header -- Apply the general API rate limiter (300 req / 15 min per IP) -- Mount `cookie-parser` (signed when `COOKIE_SECRET` is set) -- Mount `express.json` (64 KB body limit) -- Expose `/health` and `/ready` endpoints (no auth, no rate limit) -- Mount `/api/auth` routes **before** CSRF middleware (login/logout are exempt) -- Mount `verifyCsrf` for all subsequent `/api` routes -- Mount remaining route modules under `/api/*` -- Register global error handler (500 with sanitized message) - -**`server/index.js`** entry point responsibilities: -- Load `.env` via `dotenv` -- Configure structured logging (`LOG_LEVEL`) with `console.*` redirection to both stdout and `DATA_DIR/server.log` -- Call `createApp()`, serve `public/` as static files, start `app.listen()` -- Start the background poller - -### 4.2 Route Modules - -| Module | Mount Point | Auth Required | CSRF Required | Purpose | -|--------|------------|:-------------:|:-------------:|--------| -| `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout | -| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes (except `/stream` — GET) | SSE stream, aggregated download data, status, cover-art proxy | -| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API | -| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API | -| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API | -| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Yes | Proxy to Radarr API | -| `history.js` | `/api/history` | Yes (`requireAuth`) | No (GET only) | Recently completed downloads from Sonarr/Radarr history | - -**`requireAuth`** (`server/middleware/requireAuth.js`) reads the `emby_user` cookie (signed if `COOKIE_SECRET` is set) and attaches the parsed `{ id, name, isAdmin }` user to `req.user`. Returns `401` if the cookie is absent, tampered, or schema-invalid. - -**`verifyCsrf`** (`server/middleware/verifyCsrf.js`) implements the double-submit cookie pattern for all state-changing requests (`POST`, `PUT`, `PATCH`, `DELETE`). Compares the `csrf_token` cookie against the `X-CSRF-Token` request header using `crypto.timingSafeEqual`. Safe methods (`GET`, `HEAD`, `OPTIONS`) are exempt. Auth routes are mounted **before** this middleware (login/logout are exempt by design — `sameSite: strict` provides equivalent protection). - -> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) exist for direct API access. The dashboard itself reads from the poller cache — it does not proxy through these routes. - -### 4.3 Utility Modules - -**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index. - -**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining). - -**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand. After each successful poll it notifies all registered SSE subscriber callbacks so connected clients receive data immediately. - -**`qbittorrent.js`** — Legacy compatibility layer that delegates to the new DownloadClient system. Maintains backward compatibility for existing code while the actual qBittorrent implementation has been moved to `server/clients/QBittorrentClient.js`. - -**`downloadClients.js`** — Registry and factory for download clients. Manages all configured download client instances (SABnzbd, qBittorrent, Transmission) and provides a unified interface for fetching downloads, testing connections, and getting client status. - -**`tokenStore.js`** — JSON file-backed store (`DATA_DIR/tokens.json`) for Emby `AccessToken`s. Tokens are stored server-side and **never sent to the client**. Writes are atomic (write to `.tmp` then rename). Entries expire after 31 days (slightly longer than the maximum 30-day cookie). Pruning runs on startup and hourly. - -**`sanitizeError.js`** — Redacts secrets from error message strings before they are logged or returned in API responses. Patterns: URL query-param secrets (`apikey=`, `token=`, etc.), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`, etc.), Bearer tokens, and basic-auth credentials in URLs. - -**`historyFetcher.js`** — Fetches history records from all Sonarr/Radarr instances for a configurable date window (`since`). Results are cached under `history:sonarr` / `history:radarr` for 5 minutes. Exports `classifySonarrEvent` / `classifyRadarrEvent` (returns `'imported'` | `'failed'` | `'other'`) and `invalidateHistoryCache`. - -**`logger.js`** — Simple file appender writing timestamped messages to `DATA_DIR/server.log`. - ---- - -## 4.4 Download Client Architecture (PDCA) - -### 4.4.1 Overview - -The **Pluggable Download Client Architecture (PDCA)** provides a unified, extensible interface for all download clients (SABnzbd, qBittorrent, Transmission, etc.). This abstraction layer enables: - -- **Client-agnostic polling**: The poller no longer needs client-specific logic -- **Easy addition of new clients**: Implement the `DownloadClient` interface -- **Consistent data normalization**: All clients return standardized download objects -- **Centralized configuration**: Single registry manages all client instances - -### 4.4.2 Abstract Base Class (`DownloadClient.js`) - -The `DownloadClient` abstract base class defines the contract that all download clients must implement: - -```javascript -class DownloadClient { - constructor(instanceConfig) - getClientType(): string - getInstanceId(): string - async testConnection(): Promise - async getActiveDownloads(): Promise - async getClientStatus(): Promise // Optional - normalizeDownload(download): NormalizedDownload -} -``` - -**Key Features:** -- Enforces implementation of required methods -- Provides common initialization logic -- Defines the normalized download schema - -### 4.4.3 Normalized Download Schema - -All clients must return objects matching this standardized schema: - -```javascript -interface NormalizedDownload { - id: string // Client-specific unique ID - title: string // Download title/name - type: 'usenet' | 'torrent' // Download type - client: string // Client identifier ('sabnzbd', 'qbittorrent', etc.) - instanceId: string // Instance identifier - instanceName: string // Instance display name - status: string // Normalized status (Downloading, Seeding, etc.) - progress: number // Progress percentage (0-100) - size: number // Total size in bytes - downloaded: number // Downloaded bytes - speed: number // Current speed in bytes/sec - eta: number | null // ETA in seconds, null if unknown - category?: string // Download category (optional) - tags?: string[] // Download tags (optional) - savePath?: string // Save path (optional) - addedOn?: string // Added timestamp (optional) - arrQueueId?: number // Sonarr/Radarr queue ID (optional) - arrType?: 'series' | 'movie' // Sonarr/Radarr type (optional) - raw?: any // Original client response (escape hatch) -} -``` - -### 4.4.4 Client Implementations - -#### QBittorrentClient -- Extends the existing qBittorrent implementation with Sync API support -- Maintains backward compatibility with legacy cache format -- Handles cookie authentication and automatic re-auth -- Preserves fallback logic for Sync API failures - -#### SABnzbdClient -- Extracts SABnzbd logic from the poller into a dedicated client -- Handles both queue and history data -- Normalizes time strings and size units -- Extracts Sonarr/Radarr information from filenames - -#### TransmissionClient -- Proof-of-concept implementation for Transmission daemon -- Uses JSON-RPC over HTTP -- Handles session ID management and conflict resolution -- Demonstrates how easy it is to add new client types - -#### RTorrentClient -- XML-RPC implementation for rTorrent daemon -- Uses the xmlrpc package (v1.3.2) for communication -- Supports HTTP Basic Auth when credentials are configured -- Maps rTorrent states (d.state, d.is_active, d.is_hash_checking) to normalized statuses -- Calculates ETA from download speed and remaining bytes - -### 4.4.5 Registry and Factory (`downloadClients.js`) - -The `DownloadClientRegistry` manages all client instances: - -```javascript -class DownloadClientRegistry { - async initialize() // Create clients from config - getAllClients(): DownloadClient[] // Get all registered clients - getClient(instanceId): DownloadClient // Get specific client - getClientsByType(type): DownloadClient[] // Get clients by type - async getAllDownloads(): NormalizedDownload[] // Fetch from all clients - async testAllConnections(): Promise - async getAllClientStatuses(): Promise -} -``` - -**Features:** -- **Configuration-driven**: Reads from `*_INSTANCES` environment variables -- **Parallel execution**: Fetches from all clients concurrently -- **Error isolation**: Individual client failures don't affect others -- **Singleton pattern**: Single registry instance shared across the application - -### 4.4.6 Integration with Poller - -The poller has been refactored to use the registry: - -```javascript -// Old approach (client-specific) -const sabQueues = await Promise.all(sabInstances.map(inst => - axios.get(`${inst.url}/api`, { params: { mode: 'queue' } }) -)); -const qbTorrents = await getTorrents(); - -// New approach (unified) -await initializeClients(); -const downloadsByType = await getDownloadsByClientType(); -``` - -**Benefits:** -- **30-40% reduction in poller code size** -- **Consistent error handling** across all clients -- **Unified timing and logging** -- **Zero breaking changes** to existing cache structure - -### 4.4.7 Backward Compatibility - -The PDCA implementation maintains **100% backward compatibility**: - -- **Cache keys**: `poll:sab-queue`, `poll:sab-history`, `poll:qbittorrent` unchanged -- **Data shapes**: Legacy formats preserved through transformation -- **API responses**: No changes to existing endpoints -- **Legacy functions**: `qbittorrent.js` delegates to new system - ---- - -## 5. Data Flow - -### 5.1 Polling Cycle - -Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel: - -| Task | API Call | Params | -|------|----------|--------| -| SABnzbd Queue | `GET /api?mode=queue` | `output=json` | -| SABnzbd History | `GET /api?mode=history` | `limit=10` | -| Sonarr Tags | `GET /api/v3/tag` | — | -| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` | -| Sonarr History | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` | -| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` | -| Radarr History | `GET /api/v3/history` | `pageSize=10` | -| Radarr Tags | `GET /api/v3/tag` | — | -| qBittorrent | `GET /api/v2/sync/maindata?rid=N` | Fallback to `GET /api/v2/torrents/info` | - -Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`. - -#### qBittorrent Sync API Details - -Each `QBittorrentClient` instance maintains: -- **`lastRid`** — the response ID from the previous `sync/maindata` call (starts at `0`). -- **`torrentMap`** — a `Map` holding the complete state for every known torrent on this qBittorrent instance. -- **`fallbackThisCycle`** — boolean tracking whether this poll cycle has already fallen back to the legacy endpoint. - -**Flow per poll cycle:** - -1. `getAllTorrents()` resets `fallbackThisCycle = false` on every client. -2. `client.getTorrents()` attempts `GET /api/v2/sync/maindata?rid={lastRid}`. -3. qBittorrent returns: - - `rid` — new response ID to use next time. - - `full_update` — if `true`, `torrents` contains the complete current list (rebuild `torrentMap`). - - `torrents` — object keyed by hash; values are either full objects (first call / `full_update`) or delta objects (only changed fields). - - `torrents_removed` — array of hashes to delete from `torrentMap`. -4. The client merges delta fields into existing entries, removes deleted entries, and returns the current values of `torrentMap` as an array. -5. If the Sync API call fails (network error, 500, unexpected response shape), the client falls back **once per cycle** to `GET /api/v2/torrents/info`. -6. If the fallback also fails, the client returns an empty array for this poll and logs the error. - -**Backward compatibility:** The rest of the application (poller, dashboard) receives data in the exact same format as before; no routes or frontend code are aware of the sync mechanism. - -### 5.2 SSE Stream - -When a browser opens `GET /api/dashboard/stream` (after authentication): - -1. Server sets `Content-Type: text/event-stream`, disables buffering (`X-Accel-Buffering: no`) -2. Immediately builds and sends the first payload (same matching logic as below) -3. Registers a callback with the poller's `onPollComplete` subscriber set -4. After every subsequent poll cycle completes the callback fires, rebuilds the payload, and writes a `data:` SSE frame -5. A 25-second heartbeat comment (`: heartbeat`) keeps the connection alive through proxies -6. On client disconnect: deregisters callback, stops heartbeat, removes from active-client map - -The browser's native `EventSource` API handles reconnection automatically on network interruption. - -### 5.3 Download Matching - -For each connected user the server: - -1. Reads all `poll:*` keys from cache -2. Builds `seriesMap` and `moviesMap` from embedded objects in queue records -3. Builds `sonarrTagMap` and `radarrTagMap` from tag data -4. For each SABnzbd queue slot → tries to match against Sonarr/Radarr queue records by title -5. For each SABnzbd history slot → tries to match against Sonarr/Radarr history records -6. For each qBittorrent torrent → tries to match against Sonarr/Radarr queue, then history -7. For each match, resolves the series/movie, extracts the user tag, checks if it belongs to the requesting user -8. Returns only the user's downloads (or all, if admin with `showAll=true`) - ---- - -## 6. Authentication & Authorisation - -### Flow - -1. User submits credentials (+ optional `rememberMe`) via the login form -2. Backend applies the **login rate limiter** (max 10 failed attempts per IP per 15-minute window; successful requests don't count) -3. Backend calls Emby `POST /Users/authenticatebyname` using a deterministic `DeviceId` derived from the username — this causes Emby to reuse the existing session rather than creating a new one on every login -4. On success, fetches full user profile (`GET /Users/{id}`) to determine admin status -5. Stores the Emby `AccessToken` in **`tokenStore`** (server-side, never sent to the client) -6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`: - - **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days - - **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes) - - `secure` flag enabled when `TRUST_PROXY` is set (i.e. a TLS-terminating reverse proxy is in front) - - Signed with HMAC when `COOKIE_SECRET` is set -7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token -8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests -9. All subsequent dashboard requests read the `emby_user` cookie for identity via `requireAuth` - -### Authorisation Matrix - -| Feature | Regular User | Admin | -|---------|:----------:|:-----:| -| View own downloads | ✓ | ✓ | -| View all users' downloads | ✗ | ✓ (`showAll`) | -| See download/target paths | ✗ | ✓ | -| See Sonarr/Radarr links | ✗ | ✓ | -| View status panel | ✗ | ✓ | -| Blocklist & search | ✓ (when import issues OR torrent >1h old AND availability<100%) | ✓ (all downloads) | - -### Tag Matching - -Users are matched to downloads via tags in Sonarr/Radarr: - -1. **Exact match**: tag label (lowercased) === username (lowercased) -2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims - ---- - -## 7. Background Polling & Caching - -### Polling Modes - -| Mode | `POLL_INTERVAL` | Behaviour | -|------|----------------|-----------| -| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms | -| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty | - -### Cache Keys - -| Key | Content | Source | -|-----|---------|--------| -| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue | -| `poll:sab-history` | `{ slots }` | SABnzbd history | -| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API | -| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) | -| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history | -| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) | -| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history | -| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API | -| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents | -| `emby:users` | `Map` | Full Emby user list (60s TTL) | -| `history:sonarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Sonarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | -| `history:radarr` | `[record, ...]` — flat array with `_instanceUrl` / `_instanceName` | Radarr history (5 min TTL, fetched on-demand by `/api/history/recent`) | - -### TTL Strategy - -- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow -- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch - -### Active Client Tracking - -SSE connections are tracked precisely: registered on connect, removed on disconnect. The admin status panel shows each connected user and how long they have been connected. The `type: 'sse'` field distinguishes SSE clients from any legacy HTTP clients. - ---- - -## 8. Download Matching Pipeline - -The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to. - -### Matching Strategy - -For each download item (SABnzbd slot or qBittorrent torrent): - -```mermaid -flowchart TD - Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"} - SQ -->|yes| SQR["Resolve series via seriesMap\nextract user tag, check match"] - SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"} - RQ -->|yes| RQR["Resolve movie via moviesMap\nextract user tag, check match"] - RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"} - SH -->|yes| SHR["Resolve series via seriesId\nextract user tag, check match"] - SH -->|no| RH{"Radarr HISTORY\nmatch (title)"} - RH -->|yes| RHR["Resolve movie via movieId\nextract user tag, check match"] - RH -->|no| Skip(["Skip - unmatched"]) - - SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"} - Tagged -->|yes| Include(["Include in response"]) - Tagged -->|no| Skip -``` - -### Title Matching - -Matches are **bidirectional substring matches** (case-insensitive): -```javascript -rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle) -``` - -### Download Object Structure - -Each matched download produces an object with: - -| Field | Type | Description | -|-------|------|-------------| -| `type` | `'series'` / `'movie'` / `'torrent'` | Media type | -| `title` | string | Raw download title | -| `coverArt` | string / null | Poster URL from *arr | -| `status` | string | Download status | -| `progress` | string | Percentage complete | -| `size` / `mb` / `mbmissing` | string / number | Size info | -| `speed` | string | Current download speed | -| `eta` | string | Estimated time remaining | -| `seriesName` / `movieName` | string | Friendly media title | -| `episodes` | `{season, episode, title}[]` | (Series only) Episodes covered by this download, sorted by season/episode. Single-episode downloads have one entry; series packs have multiple. Empty array if Sonarr has no episode data. | -| `allTags` | string[] | All resolved tag labels on the series/movie | -| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` | -| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | -| `importIssues` | string[] / null | Import warning/error messages | -| `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) | -| `canBlocklist` | boolean | `true` if the current user can blocklist this download (admin: always; non-admin: when import issues OR torrent >1h old AND availability<100%) | -| `downloadPath` | string / null | (Admin) Download client path | -| `targetPath` | string / null | (Admin) *arr target path | -| `arrLink` | string / null | (Admin) Link to *arr web UI | -| `arrQueueId` | number / null | (Admin, import-pending only) Sonarr/Radarr queue record id | -| `arrType` | `'sonarr'`/`'radarr'` / null | (Admin, import-pending only) Which *arr service owns this queue entry | -| `arrInstanceUrl` | string / null | (Admin, import-pending only) Base URL of the *arr instance | -| `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance | -| `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search | -| `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command | -| `addedOn` | number / null | (qBittorrent only) Unix timestamp when the torrent was added, used for age-based blocklist eligibility | - ---- - -## 9. API Reference - -### `POST /api/auth/login` - -Authenticate a user via Emby. Rate-limited to **10 failed attempts per IP per 15 minutes**. - -**Request Body:** -```json -{ "username": "string", "password": "string", "rememberMe": false } -``` - -| Field | Required | Description | -|-------|:--------:|-----------| -| `username` | Yes | Max 128 chars, must be a non-empty string | -| `password` | Yes | Max 256 chars | -| `rememberMe` | No | `true` = 30-day persistent cookie; `false`/omitted = session cookie | - -**Response (200):** -```json -{ - "success": true, - "user": { "id": "string", "name": "string", "isAdmin": false }, - "csrfToken": "64-char hex string" -} -``` - -**Response (400):** Invalid input (empty/overlong username or password). - -**Response (401):** -```json -{ "success": false, "error": "Invalid username or password" } -``` - -**Response (429):** Too many failed attempts from this IP. - -**Side Effects:** -- Sets `emby_user` cookie: `httpOnly`, `sameSite: strict`, `secure` in production, signed if `COOKIE_SECRET` is set. Payload: `{ id, name, isAdmin }`. The Emby `AccessToken` is **never** included. -- Sets `csrf_token` cookie: `httpOnly: false` (JS-readable), same security flags. 64-char hex. -- Stores the Emby `AccessToken` server-side in `tokenStore` (used only for server-side Emby logout). - ---- - -### `GET /api/auth/me` - -Check current session (no auth required — returns unauthenticated state rather than 401). - -**Response (authenticated):** -```json -{ "authenticated": true, "user": { "id": "string", "name": "string", "isAdmin": false } } -``` - -**Response (not authenticated):** -```json -{ "authenticated": false } -``` - ---- - -### `GET /api/auth/csrf` - -Issue a fresh CSRF token. Used by the SPA after a page reload when the in-memory `csrfToken` variable is lost. - -**Response (200):** -```json -{ "csrfToken": "64-char hex string" } -``` - -**Side Effect:** Sets a new `csrf_token` cookie. - ---- - -### `POST /api/auth/logout` - -Clear session and revoke the Emby token server-side. Does **not** require a CSRF token (auth routes are mounted before `verifyCsrf`; `sameSite: strict` provides equivalent protection). - ---- - -### `GET /api/dashboard/stream` - -Server-Sent Events stream. Opens a persistent connection that pushes download data on every poll cycle. - -**Query Parameters:** -| Param | Type | Description | -|-------|------|-------------| -| `showAll` | `"true"` | (Admin) Include all users' downloads | - -**Response:** `Content-Type: text/event-stream` - -Each event is a `data:` frame containing JSON: -```json -{ - "user": "Alice", - "isAdmin": false, - "downloads": [ /* download objects — same shape as /user-downloads */ ] -} -``` - -The connection is kept alive with `: heartbeat` comments every 25 seconds. The browser's `EventSource` reconnects automatically on failure. - ---- - -### `GET /api/dashboard/user-downloads` - -Fetch downloads for the authenticated user (single HTTP request, no streaming). - -**Query Parameters:** -| Param | Type | Description | -|-------|------|-------------| -| `showAll` | `"true"` | (Admin) Show all users' downloads | - -**Response (200):** -```json -{ - "user": "string", - "isAdmin": true, - "downloads": [ /* download objects */ ] -} -``` - ---- - -### `GET /api/dashboard/status` - -Admin-only server status. - -**Response (200):** -```json -{ - "server": { - "uptimeSeconds": 3600, - "nodeVersion": "v18.19.0", - "memoryUsageMB": 45.2, - "heapUsedMB": 28.1, - "heapTotalMB": 35.0 - }, - "polling": { - "enabled": true, - "intervalMs": 5000, - "lastPoll": { - "totalMs": 1234, - "timestamp": "2026-05-16T00:00:00.000Z", - "tasks": [ - { "label": "SABnzbd Queue", "ms": 120 }, - { "label": "Sonarr Queue", "ms": 890 } - ] - } - }, - "cache": { - "entryCount": 9, - "totalSizeBytes": 51200, - "entries": [ - { "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false } - ] - }, - "clients": [ - { "user": "Alice", "type": "sse", "connectedAt": 1715817600000, "lastSeen": 1715817600000 } - ] -} -``` - ---- - -### `GET /api/dashboard/user-summary` - -Admin-only per-user download counts (fetches live from APIs, not cached). - -**Response (200):** -```json -[ - { "username": "Alice", "seriesCount": 12, "movieCount": 5 } -] -``` - ---- - -### `POST /api/dashboard/blocklist-search` - -Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command. - -**Access:** Admin users can blocklist any download. Non-admin users can only blocklist downloads that meet specific eligibility criteria: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. The frontend only shows the button when the user is eligible. - -Requires CSRF token (`X-CSRF-Token` header). - -**Request Body:** -```json -{ - "arrQueueId": 1234, - "arrType": "sonarr", - "arrInstanceUrl": "https://sonarr.example.com", - "arrInstanceKey": "your-api-key", - "arrContentId": 5678, - "arrContentType": "episode" -} -``` - -| Field | Required | Description | -|-------|:--------:|-------------| -| `arrQueueId` | Yes | Sonarr/Radarr queue record `id` | -| `arrType` | Yes | `"sonarr"` or `"radarr"` | -| `arrInstanceUrl` | Yes | Base URL of the *arr instance | -| `arrInstanceKey` | Yes | API key for the *arr instance | -| `arrContentId` | Yes | `episodeId` (Sonarr) or `movieId` (Radarr) | -| `arrContentType` | Yes | `"episode"` or `"movie"` | - -**Response (200):** `{ "ok": true }` - -**Response (400):** Missing or invalid fields. - -**Response (403):** Non-admin user attempting to blocklist without meeting eligibility criteria (no import issues and not an eligible torrent). - -**Response (502):** Upstream *arr call failed. - -**Side Effects:** -- Calls `DELETE /api/v3/queue/{id}?removeFromClient=true&blocklist=true` on the *arr instance -- Calls `POST /api/v3/command` with `EpisodeSearch`/`MoviesSearch` on the *arr instance -- Triggers a background `pollAllServices()` so the next SSE push reflects the removed item - ---- - -### `GET /api/history/recent` - -Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days. - -**Query Parameters:** -| Param | Type | Default | Description | -|-------|------|---------|-------------| -| `days` | integer | `RECENT_COMPLETED_DAYS` env (default `7`) | How many days back to search. Capped at 90. | -| `showAll` | `"true"` | — | (Admin) Return records for all tagged users, not just the current user | - -**Response (200):** -```json -{ - "user": "Alice", - "isAdmin": false, - "days": 7, - "history": [ - { - "type": "series", - "outcome": "imported", - "title": "Show.S01E01.720p", - "seriesName": "My Show", - "episodes": [ - { "season": 1, "episode": 1, "title": "Pilot" } - ], - "coverArt": "https://…/poster.jpg", - "completedAt": "2026-05-15T18:00:00.000Z", - "quality": "720p", - "instanceName": "Main Sonarr", - "arrLink": "https://sonarr.example.com/series/my-show", - "allTags": ["alice"], - "matchedUserTag": "alice", - "arrRecordId": 1234, - "failureMessage": null - } - ] -} -``` - -- `outcome` is `"imported"` or `"failed"`. Records with other event types (e.g. `grabbed`) are filtered out. -- `episodes` is a sorted array of `{ season, episode, title }` objects. Single-episode downloads have one entry; series packs have multiple. `title` is `null` if not returned by Sonarr. Empty array if Sonarr has no episode data. -- `failureMessage` is only included when the authenticated user is an admin and `outcome` is `"failed"`. -- `arrRecordId` is only included for admin users. -- Results are sorted newest first. -- History data is cached server-side for 5 minutes (`history:sonarr` / `history:radarr` cache keys). - ---- - -## 10. Frontend Architecture - -The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`. - -### UI States - -```mermaid -stateDiagram-v2 - [*] --> SplashScreen : Page load - SplashScreen --> LoginForm : No session - SplashScreen --> Dashboard : Valid session - LoginForm --> Dashboard : Auth success - Dashboard --> LoginForm : Logout - - state Dashboard { - [*] --> ActiveDownloads - ActiveDownloads --> ActiveDownloads : SSE update - - state StatusPanel { - [*] --> Closed - Closed --> Open : Click Status (admin) - Open --> Closed : Click close - Open --> Open : 5s refresh - } - } -``` - -### Key Frontend Functions - -| Function | Purpose | -|----------|---------| -| `checkAuthentication()` | On load: check session → show dashboard or login | -| `handleLogin()` | Authenticate, fade login → splash → dashboard | -| `goHome()` | Navigate to default view: switch to Active Downloads tab, close status panel, reset showAll | -| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide | -| `stopSSE()` | Close `EventSource` and cancel reconnect timer | -| `renderDownloads()` | Diff-based card rendering (create/update/remove) | -| `createDownloadCard()` | Build DOM for a single download card; renders tag badges, import-issue badge, blocklist button | -| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | -| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state | -| `toggleStatusPanel()` | Show/hide admin status panel | -| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) | -| `initThemeSwitcher()` | Light / Dark / Mono theme support | -| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` | -| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards | -| `createHistoryCard()` | Build DOM for a single history card with outcome/upgrade badges | - -### Themes - -Three CSS themes via `data-theme` attribute on ``: -- **Light** — Purple gradient header, white cards -- **Dark** — Dark surfaces, muted accents -- **Mono** — Monochrome, minimal colour - -Theme selection persists in `localStorage`. - -### Tag Badge Rendering - -Download cards render tag badges in the card header: - -- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`). -- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`: - - Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost) - - Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost) - -### Live Push via SSE - -The dashboard receives updates via a persistent `EventSource` connection to `GET /api/dashboard/stream`. The server pushes a new `data:` event immediately after every poll cycle completes — there is no client-side timer. The browser's `EventSource` implementation handles reconnection automatically on network interruption. - -The status panel refreshes on a fixed 5-second timer and shows each SSE client with its connect duration. - ---- - -## 11. Configuration - -### Environment Variables - -#### Core - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `PORT` | No | `3001` | Server listen port | -| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation. Does **not** control cookie `secure` flag or CSP `upgrade-insecure-requests` (both gated on `TRUST_PROXY`). | -| `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). | -| `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. | -| `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. | -| `TLS_ENABLED` | No | `true` | Set to `false` to disable HTTPS and run plain HTTP (e.g. when TLS is terminated by a reverse proxy). | -| `TLS_CERT` | No | `certs/snakeoil.crt` | Path to the TLS certificate file (PEM). Defaults to the bundled self-signed snakeoil certificate. | -| `TLS_KEY` | No | `certs/snakeoil.key` | Path to the TLS private key file (PEM). Defaults to the bundled snakeoil key. | - -#### Emby - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `EMBY_URL` | Yes | — | Emby/Jellyfin base URL (e.g. `https://emby.example.com`) | -| `EMBY_API_KEY` | Yes | — | Emby API key (used by the poller to list users for tag badge classification) | - -#### Service Instances - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances | -| `SONARR_URL` | Yes* | — | Legacy: single Sonarr URL | -| `SONARR_API_KEY` | Yes* | — | Legacy: single Sonarr API key | -| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances | -| `RADARR_URL` | Yes* | — | Legacy: single Radarr URL | -| `RADARR_API_KEY` | Yes* | — | Legacy: single Radarr API key | -| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances | -| `SABNZBD_URL` | Yes* | — | Legacy: single SABnzbd URL | -| `SABNZBD_API_KEY` | Yes* | — | Legacy: single SABnzbd API key | -| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances | - -\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required. - -#### Tuning - -| Variable | Required | Default | Description | -|----------|:--------:|---------|-------------| -| `POLL_INTERVAL` | No | `5000` | Poll interval in ms. Set to `0`, `off`, or `false` to disable background polling (on-demand mode). | -| `RECENT_COMPLETED_DAYS` | No | `7` | Default lookback window (days) for `GET /api/history/recent`. Overridable per-request via `?days=`. Max 90. | -| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` | - -### Instance JSON Format - -```json -[ - { - "name": "main", - "url": "https://sonarr.example.com", - "apiKey": "your-api-key" - }, - { - "name": "4k", - "url": "https://sonarr4k.example.com", - "apiKey": "your-4k-api-key" - } -] -``` - -qBittorrent instances use `username` and `password` instead of `apiKey`. - ---- - -## 12. Deployment - -### Docker image - -The production image uses a two-stage build on `node:22-alpine`: - -1. **`deps` stage** — runs `npm ci --omit=dev` to install only production dependencies. -2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs. - -Key environment variables set in the image: -- `NODE_ENV=production` — enables production startup validation and logging -- `DATA_DIR=/app/data` — token store and log file location -- TLS is **enabled by default** using the bundled snakeoil self-signed certificate (`certs/snakeoil.crt`). Set `TLS_CERT`/`TLS_KEY` to your own certificate, or set `TLS_ENABLED=false` when terminating TLS at a reverse proxy. - -### Docker Compose - -```yaml -services: - sofarr: - image: docker.i3omb.com/sofarr:latest - container_name: sofarr - restart: unless-stopped - ports: - - "3001:3001" # HTTPS by default (snakeoil cert if no TLS_CERT set) - environment: - - NODE_ENV=production - - DATA_DIR=/app/data - - COOKIE_SECRET=change-me-to-a-long-random-string - # Option A: direct TLS (default). Supply your own cert/key: - # - TLS_CERT=/app/certs/server.crt - # - TLS_KEY=/app/certs/server.key - # Option B: behind a TLS-terminating reverse proxy: - # - TLS_ENABLED=false - # - TRUST_PROXY=1 - - EMBY_URL=https://emby.example.com - - EMBY_API_KEY=your-emby-api-key - - SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - - RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - - SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}] - - QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}] - - POLL_INTERVAL=5000 - - LOG_LEVEL=info - volumes: - - sofarr-data:/app/data - # Uncomment to supply your own certificate (Option A): - # - /path/to/server.crt:/app/certs/server.crt:ro - # - /path/to/server.key:/app/certs/server.key:ro - -volumes: - sofarr-data: -``` - -### Security hardening checklist - -- **Use HTTPS** — TLS is on by default (snakeoil cert). Supply `TLS_CERT`/`TLS_KEY` pointing to a CA-signed certificate for trusted HTTPS. Alternatively terminate TLS at a reverse proxy and set `TLS_ENABLED=false` + `TRUST_PROXY=1`. -- **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery. -- **Set `TRUST_PROXY=1`** only when a TLS-terminating reverse proxy sits in front — ensures `req.secure` is correct and the CSP `upgrade-insecure-requests` + `secure` cookie flag fire correctly. -- **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. -- **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP. - -### CI / CD - -The `.gitea/workflows/` directory contains five pipeline definitions: - -| File | Trigger | Purpose | -|------|---------|--------| -| `ci.yml` | Every push / PR (all branches) | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage | -| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to `reg.i3omb.com`. `release/**` pushes versioned + `latest` tags; `develop` pushes a `:develop` tag. | -| `create-release.yml` | Tag push (`v*`) | Generate release notes from git log and create a Gitea release | -| `docs-check.yml` | Push / PR touching `**.md` (non-main / non-release branches) | Markdown lint + Mermaid diagram parse validation | -| `licence-check.yml` | Push / PR touching `package.json` or `package-lock.json` | Verify all production dependency licences are compatible with MIT | - -> **Diagrams** are written in Mermaid and render natively in Gitea — no separate diagram files or CI render step required. See [Section 13](#13-diagrams). - ---- - -## 13. Diagrams - -All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown. No external tooling or PNG exports are required — the source is the diagram. - -### 13.1 Component Diagram - -```mermaid -graph TB - subgraph Browser - html[index.html] - appjs[app.js] - css[style.css] - html -->|loads| appjs - html -->|loads| css - end - - subgraph Express Server - entry[index.js\nEntry Point] - appfactory[app.js\ncreateApp factory] - - subgraph Middleware - hm[helmet\nCSP nonce + HSTS] - rl[express-rate-limit\nAPI + login] - cp[cookie-parser\nsigned cookies] - ej[express.json\n64kb limit] - es[express.static] - requireauth[requireAuth.js] - verifycsrf[verifyCsrf.js\ndouble-submit] - end - - subgraph Routes - auth[auth.js\n/api/auth\npre-CSRF] - dashboard[dashboard.js\n/api/dashboard\n+SSE /stream] - emby_r[emby.js\n/api/emby] - sab_r[sabnzbd.js\n/api/sabnzbd] - sonarr_r[sonarr.js\n/api/sonarr] - radarr_r[radarr.js\n/api/radarr] - history_r[history.js\n/api/history] - end - - subgraph Utilities - poller[poller.js] - cache[cache.js\nMemoryCache] - config[config.js] - qbt[qbittorrent.js\nQBittorrentClient] - tokenstore[tokenStore.js\ntokens.json] - sanitize[sanitizeError.js] - logger[logger.js] - historyfetcher[historyFetcher.js] - end - - entry --> appfactory - entry --> es - entry --> poller - - appfactory --> hm & rl & cp & ej - appfactory -->|pre-CSRF| auth - appfactory --> verifycsrf - appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r - - dashboard & emby_r & sab_r & sonarr_r & radarr_r & history_r --> requireauth - auth --> tokenstore - dashboard --> cache & poller & config & qbt - history_r --> cache & config & historyfetcher - historyfetcher --> cache & config - poller --> cache & config & qbt & logger - qbt --> config & logger - auth & dashboard -.-> sanitize - end - - subgraph External Services - emby[Emby / Jellyfin] - sab[SABnzbd] - sonarr[Sonarr] - radarr[Radarr] - qbit[qBittorrent] - end - - auth --> emby - dashboard --> emby - poller --> sab & sonarr & radarr - qbt --> qbit - emby_r --> emby - sab_r --> sab - sonarr_r --> sonarr - radarr_r --> radarr - - appjs -->|POST /login, GET /me, GET /csrf, POST /logout| auth - appjs -->|GET /stream SSE, GET /user-downloads, GET /status| dashboard - es -->|serve static| html -``` - -### 13.2 Authentication Sequence - -```mermaid -sequenceDiagram - actor User - participant Browser as Browser (app.js) - participant Auth as Express /api/auth - participant Tokens as TokenStore (tokens.json) - participant Emby as Emby Server - - rect rgb(240,240,255) - Note over Browser,Auth: Page Load - Browser->>Auth: GET /api/auth/me - Auth->>Auth: Read emby_user cookie (signed if COOKIE_SECRET) - alt Cookie valid - Auth-->>Browser: { authenticated: true, user } - Browser->>Auth: GET /api/auth/csrf - Auth-->>Browser: { csrfToken } + Set csrf_token cookie - Browser->>Browser: store csrfToken in memory - Browser->>Browser: showDashboard() + startSSE() - else No cookie / tampered - Auth-->>Browser: { authenticated: false } - Browser->>Browser: showLogin() - end - end - - rect rgb(240,255,240) - Note over Browser,Emby: Login - User->>Browser: Enter credentials (+ rememberMe) - Browser->>Auth: POST /api/auth/login - Note right of Auth: Rate limit: max 10 failed attempts per IP / 15 min - Auth->>Emby: POST /Users/authenticatebyname (DeviceId = sha256(username)[0:16]) - alt Valid credentials - Emby-->>Auth: { User.Id, AccessToken } - Auth->>Emby: GET /Users/{id} - Emby-->>Auth: { Name, Policy.IsAdministrator } - Auth->>Tokens: storeToken(userId, AccessToken) - Note right of Tokens: Server-side only, 31-day TTL, atomic write - Auth->>Auth: Set emby_user cookie (httpOnly, sameSite=strict, secure if TRUST_PROXY) - Auth->>Auth: Set csrf_token cookie (httpOnly=false, sameSite=strict) - Auth-->>Browser: { success: true, user, csrfToken } - Browser->>Browser: showDashboard() + startSSE() - else Invalid credentials - Emby-->>Auth: 401 - Auth-->>Browser: { success: false, error } - end - end - - rect rgb(255,245,230) - Note over Browser,Auth: Logout - User->>Browser: Click Logout - Browser->>Browser: stopSSE() - Browser->>Auth: POST /api/auth/logout - Auth->>Tokens: getToken(userId) - Tokens-->>Auth: { accessToken } - Auth->>Emby: POST /Sessions/Logout - Auth->>Tokens: clearToken(userId) - Auth->>Auth: clearCookie(emby_user, csrf_token) - Auth-->>Browser: { success: true } - Browser->>Browser: showLogin() - end -``` - -### 13.3 Dashboard SSE Stream Sequence - -```mermaid -sequenceDiagram - actor User - participant Browser as Browser (app.js) - participant Dashboard as Express /api/dashboard - participant Cache as MemoryCache - participant Poller - participant Ext as External Services - - User->>Browser: Login success / valid session - Browser->>Dashboard: GET /api/dashboard/stream (EventSource) - Dashboard->>Dashboard: requireAuth: extract user/isAdmin - Dashboard->>Dashboard: Set Content-Type: text/event-stream, register in activeClients - - opt Polling disabled AND cache empty - Dashboard->>Poller: pollAllServices() - Poller->>Ext: Parallel API calls - Ext-->>Poller: Raw data - Poller->>Cache: set poll:* keys (TTL=30s) - end - - Dashboard->>Cache: get all poll:* keys - Dashboard->>Dashboard: Build maps, match downloads, extractUserTag / buildTagBadges - Dashboard-->>Browser: data: { user, isAdmin, downloads } - Browser->>Browser: hideLoading() + renderDownloads() - - loop Every poll cycle - Poller->>Poller: pollAllServices() complete - Poller->>Dashboard: onPollComplete callback fires - Dashboard->>Cache: get all poll:* keys - Dashboard->>Dashboard: Rebuild payload - Dashboard-->>Browser: data: { user, isAdmin, downloads } - Browser->>Browser: renderDownloads() diff-based - end - - Note over Dashboard,Browser: : heartbeat every 25s keeps connection alive - - User->>Browser: Close tab / logout - Browser->>Dashboard: TCP close (req close event) - Dashboard->>Dashboard: offPollComplete(cb), clearInterval(heartbeat), delete activeClients[key] -``` - -### 13.4 Background Polling Cycle - -```mermaid -sequenceDiagram - participant Entry as index.js (startup) - participant Poller - participant Config - participant SAB as SABnzbd (per instance) - participant Sonarr as Sonarr (per instance) - participant Radarr as Radarr (per instance) - participant QBT as qBittorrent Client - participant Cache as MemoryCache - - Entry->>Poller: startPoller() - alt POLL_INTERVAL > 0 - Poller->>Poller: pollAllServices() immediate - Poller->>Poller: setInterval(pollAllServices, POLL_INTERVAL) - else POLL_INTERVAL = 0 - Poller-->>Entry: on-demand mode - end - - Note over Poller: Each poll cycle - Poller->>Poller: polling flag check (skip if concurrent) - Poller->>Poller: polling = true - - Poller->>Config: getSABnzbdInstances() / getSonarrInstances() / getRadarrInstances() - Config-->>Poller: instance configs - - Note over Poller,Cache: All 9 fetches run in parallel via Promise.all, each wrapped in timed() - - Poller->>SAB: GET /api?mode=queue - SAB-->>Poller: { queue: { slots, status, speed } } - Poller->>SAB: GET /api?mode=history&limit=10 - SAB-->>Poller: { history: { slots } } - Poller->>Sonarr: GET /api/v3/tag + queue + history - Sonarr-->>Poller: tags, queue records (includeSeries), history - Poller->>Radarr: GET /api/v3/tag + queue + history - Radarr-->>Poller: tags, queue records (includeMovie), history - Poller->>QBT: getTorrents() - QBT-->>Poller: [{ name, progress, ... }] - - Poller->>Poller: Record per-task timings: lastPollTimings = { totalMs, timestamp, tasks } - Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL x 3) - Poller->>Poller: Notify SSE subscribers (forEach cb()) - Poller->>Poller: polling = false -``` - -### 13.5 Server Class Diagram - -```mermaid -classDiagram - class EntryPoint["index.js (EntryPoint)"] { - +startPoller() - +app.listen() - setupLogging() - serveStatic() - } - class createApp["app.js (createApp factory)"] { - +createApp(skipRateLimits?) Express - mountHelmet() - mountRateLimiters() - mountRoutes() - mountErrorHandler() - } - class AuthRouter["auth.js (Router)"] { - +POST /login - +GET /me - +GET /csrf - +POST /logout - authenticateViaEmby() - issueCookies() - revokeToken() - } - class DashboardRouter["dashboard.js (Router)"] { - -activeClients Map - +GET /stream SSE - +GET /user-downloads - +GET /user-summary - +GET /status - +GET /cover-art - +POST /blocklist-search - buildDownloadPayload() - extractUserTag() - buildTagBadges() - getEmbyUsers() - getImportIssues() - } - class RequireAuth["requireAuth.js (Middleware)"] { - +requireAuth(req, res, next) - readCookie() - validateSchema() - } - class VerifyCsrf["verifyCsrf.js (Middleware)"] { - +verifyCsrf(req, res, next) - timingSafeEqual() - } - class MemoryCache { - -store Map - +get(key) any - +set(key, value, ttlMs) - +invalidate(key) - +clear() - +getStats() CacheStats - } - class Poller { - -POLL_INTERVAL number - -polling boolean - -subscribers Set - +startPoller() - +stopPoller() - +pollAllServices() - +onPollComplete(cb) - +offPollComplete(cb) - +getLastPollTimings() PollTimings - } - class Config { - +getSABnzbdInstances() Instance[] - +getSonarrInstances() Instance[] - +getRadarrInstances() Instance[] - +getQbittorrentInstances() Instance[] - } - class QBittorrentClient { - -url string - -authCookie string - +login() bool - +getTorrents() Torrent[] - +makeRequest(endpoint) - } - class TokenStore { - -STORE_PATH string - -TOKEN_TTL_MS 31days - +storeToken(userId, token) - +getToken(userId) - +clearToken(userId) - atomicWrite() - pruneExpired() - } - class SanitizeError { - +sanitizeError(err) string - redactQueryParams() - redactAuthHeaders() - } - - EntryPoint --> createApp : createApp() - EntryPoint --> Poller : startPoller() - createApp --> AuthRouter : mount pre-CSRF - createApp --> VerifyCsrf : apply to /api - createApp --> DashboardRouter - DashboardRouter --> RequireAuth - DashboardRouter --> MemoryCache - DashboardRouter --> Poller - DashboardRouter --> Config - DashboardRouter ..> SanitizeError - AuthRouter --> TokenStore - AuthRouter ..> SanitizeError - Poller --> MemoryCache - Poller --> Config - Poller --> QBittorrentClient - QBittorrentClient --> Config -``` - -### 13.6 Data Model Diagram - -```mermaid -classDiagram - class Download { - +type series|movie|torrent - +title string - +coverArt string - +status string - +progress string - +size string - +mb string - +mbmissing string - +speed string - +eta string - +seriesName string - +movieName string - +allTags string[] - +matchedUserTag string - +tagBadges TagBadge[] - +importIssues string[] - +downloadPath string - +targetPath string - +arrLink string - +seeds number - +peers number - +availability string - +hash string - +completedAt string - +canBlocklist boolean - +addedOn number - +arrQueueId number - +arrType string - +arrInstanceUrl string - +arrContentId number - +arrContentType string - } - class TagBadge { - +label string - +matchedUser string - } - class APIResponse { - +user string - +isAdmin boolean - +downloads Download[] - } - class SSEEvent { - +user string - +isAdmin boolean - +downloads Download[] - } - class StatusResponse { - +server ServerInfo - +polling PollingInfo - +cache CacheStats - +clients ClientInfo[] - } - class SessionCookie { - +id string - +name string - +isAdmin boolean - } - class SABnzbdQueueSlot { - +filename string - +percentage string - +mb string - +mbmissing string - +timeleft string - +status string - } - class qBittorrentTorrent { - +name string - +hash string - +progress float - +state string - +dlspeed number - +eta number - +num_seeds number - +num_leechs number - +availability number - +added_on number - } - class SonarrQueueRecord { - +seriesId number - +series SonarrSeries - +title string - +trackedDownloadStatus string - +statusMessages StatusMessage[] - } - class RadarrQueueRecord { - +movieId number - +movie RadarrMovie - +title string - +trackedDownloadStatus string - +statusMessages StatusMessage[] - } - - APIResponse "1" *-- "many" Download - SSEEvent "1" *-- "many" Download - Download "1" *-- "many" TagBadge - SABnzbdQueueSlot ..> Download : matched and transformed - qBittorrentTorrent ..> Download : mapTorrentToDownload() - SonarrQueueRecord ..> Download : coverArt, seriesName, tags - RadarrQueueRecord ..> Download : coverArt, movieName, tags -``` - -### 13.7 Frontend UI State Diagram - -```mermaid -stateDiagram-v2 - [*] --> SplashScreen : Page load - - SplashScreen --> CheckAuth : checkAuthentication() - - state CheckAuth <> - CheckAuth --> LoginForm : No session - CheckAuth --> Dashboard : Valid session - - state LoginForm { - [*] --> Idle - Idle --> Submitting : Submit form - Submitting --> Error : Auth failed - Error --> Submitting : Re-submit - Submitting --> [*] : Auth success - } - - LoginForm --> Dashboard : Auth success (fade transition) - - state Dashboard { - [*] --> Rendering - Rendering --> Rendering : SSE message triggers renderDownloads() - Rendering --> Rendering : Theme change - - state SSEConnection { - [*] --> Connecting - Connecting --> Connected : First message - Connected --> Reconnecting : Connection lost - Reconnecting --> Connected : Auto-reconnect - Connected --> Connecting : showAll toggled - } - - state StatusPanel { - [*] --> Closed - Closed --> Open : Click Status (admin) - Open --> Closed : Click close - Open --> Open : 5s timer refresh - } - } - - Dashboard --> LoginForm : Logout (stopSSE) -``` - -### 13.8 Poller State Diagram - -```mermaid -stateDiagram-v2 - [*] --> CheckConfig : startPoller() - - state CheckConfig <> - CheckConfig --> Disabled : POLL_INTERVAL = 0 - CheckConfig --> Idle : POLL_INTERVAL > 0 - - state Disabled { - [*] --> OnDemand - OnDemand : No background timer. Data fetched when dashboard request finds empty cache. - } - - Disabled --> Polling : dashboard triggers pollAllServices() - Polling --> Disabled : Poll complete (on-demand) - - Idle --> Polling : setInterval fires or immediate first poll - - state Polling { - [*] --> Locked - Locked : polling = true - Locked --> Fetching - Fetching --> Storing : All promises resolved - Fetching --> HandleError : Per-service error (caught) - Storing --> Notifying : Cache updated, TTL = POLL_INTERVAL x 3 - Notifying : Notify SSE subscribers - Notifying --> Done - Done : polling = false - Done --> [*] - } - - state HandleError { - [*] --> LogError - LogError : Log error, polling = false - } - - Polling --> Idle : Poll complete - HandleError --> Idle : Next interval - - state ConcurrentSkip { - [*] --> Skip - Skip : polling === true, skip cycle - } - Idle --> ConcurrentSkip : Interval fires while previous still running - ConcurrentSkip --> Idle : Log skip -``` - -### 13.9 Download Matching Flow - -```mermaid -flowchart TD - A([Start: user request]) --> B[Read all poll:* keys from MemoryCache] - B --> C[Build seriesMap, moviesMap\nsonarrTagMap, radarrTagMap] - C --> D{showAll?} - D -->|yes| E[Fetch Emby user list\ncached 60s → embyUserMap] - D -->|no| F - E --> F[userDownloads = empty array] - - F --> G[/SABnzbd queue slots/] - G --> H{Matches Sonarr queue?} - H -->|yes| I[Resolve series\nextractAllTags + extractUserTag] - I --> J{showAll + anyTag\nor matchedUserTag?} - J -->|yes| K[Build Download object\nAdd tagBadges if showAll\nAdd importIssues, admin fields] - K --> L[Push to userDownloads] - H --> M{Matches Radarr queue?} - M -->|yes| N[Resolve movie\nextractAllTags + extractUserTag] - N --> J - - L --> G - - G --> O[/SABnzbd history slots/] - O --> P{Matches Sonarr history?} - P -->|yes| Q[Resolve series\nBuild Download type=series\nAdd completedAt] - Q --> R{showAll+anyTag\nor matchedUserTag?} - R -->|yes| S[Push to userDownloads] - P --> T{Matches Radarr history?} - T -->|yes| U[Resolve movie\nBuild Download type=movie\nAdd completedAt] - U --> R - - S --> O - - O --> V[/qBittorrent torrents/] - V --> W{Matches Sonarr queue?} - W -->|yes| X[mapTorrentToDownload\n+ enrich with series] - X --> Y{Tag matches?} - Y -->|yes| Z[Push to userDownloads] - W --> AA{Matches Radarr queue?} - AA -->|yes| AB[mapTorrentToDownload\n+ enrich with movie] - AB --> Y - AA --> AC{Matches Sonarr history?} - AC -->|yes| AD[Resolve series via seriesMap] - AD --> Y - AC --> AE{Matches Radarr history?} - AE -->|yes| AF[Resolve movie via moviesMap] - AF --> Y - AE -->|no| AG[Skip - unmatched torrent] - - Z --> V - AG --> V - - V --> AH([Return JSON\nuser, isAdmin, downloads]) - - style K fill:#d4edda - style Q fill:#d4edda - style U fill:#d4edda - style X fill:#d4edda - style AB fill:#d4edda - style AD fill:#d4edda - style AF fill:#d4edda - style AG fill:#f8d7da -``` - - - From 76f0aad45328c1d0aa8732ac01f1ac44c56583d0 Mon Sep 17 00:00:00 2001 From: Gronod Date: Tue, 19 May 2026 18:33:03 +0100 Subject: [PATCH 12/12] chore: bump version to 1.5.0 --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5c59b..9115f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.5.0] - 2026-05-19 + +### Changed + +- **`ARCHITECTURE.md`** — consolidated the two existing architecture documents (root concise reference and `docs/ARCHITECTURE.md` deep-dive) into a single comprehensive reference at the project root. The merged document covers all 11 sections: Introduction, High-Level Architecture, Pluggable Architecture Layers (PDCA + PALDRA), Webhook System, Data Flow and Real-time Updates, Caching and Smart Polling, Key Subsystems, Directory Structure, Configuration and Environment Variables, Security Model, and Technology Stack. Includes full Mermaid diagrams for system overview, polling cycle, webhook path, UI state machine, and download matching pipeline. +- **`docs/ARCHITECTURE.md`** — removed; content fully merged into root `ARCHITECTURE.md`. + +--- + ## [1.4.0] - 2026-05-19 ### Added diff --git a/package.json b/package.json index ff12476..4d3c60f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.4.0", + "version": "1.5.0", "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": {