feat: implement Pluggable Abstraction Layer for Data Retrieval (PALDRA) - #19
All checks were successful
All checks were successful
- 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
This commit is contained in:
81
server/clients/ArrRetriever.js
Normal file
81
server/clients/ArrRetriever.js
Normal file
@@ -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>} 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<Object>} 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<Object>} History object with records array
|
||||
*/
|
||||
async getHistory(instanceConfig, options = {}) {
|
||||
throw new Error('getHistory() must be implemented by subclass');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArrRetriever;
|
||||
96
server/clients/PollingRadarrRetriever.js
Normal file
96
server/clients/PollingRadarrRetriever.js
Normal file
@@ -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>} 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<Object>} 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<Object>} 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;
|
||||
99
server/clients/PollingSonarrRetriever.js
Normal file
99
server/clients/PollingSonarrRetriever.js
Normal file
@@ -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>} 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<Object>} 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<Object>} 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;
|
||||
Reference in New Issue
Block a user