Refactor arr routes: explicit dependency injection
- Updated all arr route modules to use destructured dependencies - Added JSDoc comments for factory functions - Replaced ctx. references with direct parameter access - Updated arr/index.js to extract and pass explicit dependencies - Maintained backward compatibility with context pattern - All files pass syntax validation Files refactored: - routes/arr/detect.js - routes/arr/credentials.js - routes/arr/config.js (579 lines) - routes/arr/smart-connect.js - routes/arr/plex.js - routes/arr/helpers.js - routes/arr/index.js (orchestrator)
This commit is contained in:
@@ -3,11 +3,24 @@ const { APP_PORTS, ARR_SERVICES } = require('../../constants');
|
||||
const { validateURL, validateToken } = require('../../input-validator');
|
||||
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors');
|
||||
|
||||
module.exports = function(ctx, helpers) {
|
||||
/**
|
||||
* Arr configuration routes factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.credentialManager - Credential manager
|
||||
* @param {Object} deps.servicesStateManager - Services state manager
|
||||
* @param {Object} deps.docker - Docker client wrapper
|
||||
* @param {Function} deps.fetchT - Timeout-wrapped fetch
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.errorResponse - Error response helper
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @param {Object} deps.helpers - Arr helpers module
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ credentialManager, servicesStateManager, docker, fetchT, asyncHandler, errorResponse, log, helpers }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Auto-configure Overseerr with detected services
|
||||
router.post('/arr/configure-overseerr', ctx.asyncHandler(async (req, res) => {
|
||||
router.post('/arr/configure-overseerr', asyncHandler(async (req, res) => {
|
||||
const { radarr, sonarr } = req.body;
|
||||
const results = { radarr: null, sonarr: null };
|
||||
|
||||
@@ -16,17 +29,17 @@ module.exports = function(ctx, helpers) {
|
||||
const overseerrSession = await helpers.getOverseerrSession();
|
||||
|
||||
if (!overseerrSession) {
|
||||
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
|
||||
return errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
|
||||
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.'
|
||||
});
|
||||
}
|
||||
|
||||
ctx.log.info('arr', 'Authenticated with Overseerr via Plex session');
|
||||
log.info('arr', 'Authenticated with Overseerr via Plex session');
|
||||
|
||||
// Helper to make authenticated requests to Overseerr
|
||||
const overseerrFetch = async (endpoint, options = {}) => {
|
||||
const url = `${overseerrUrl}${endpoint}`;
|
||||
const response = await ctx.fetchT(url, {
|
||||
const response = await fetchT(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -41,12 +54,12 @@ module.exports = function(ctx, helpers) {
|
||||
try {
|
||||
const statusRes = await overseerrFetch('/api/v1/status');
|
||||
if (!statusRes.ok) {
|
||||
return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', {
|
||||
return errorResponse(res, 502, 'Cannot connect to Overseerr', {
|
||||
hint: 'Make sure Overseerr is running on port 5055'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, {
|
||||
return errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, {
|
||||
hint: 'Check if Overseerr container is running'
|
||||
});
|
||||
}
|
||||
@@ -59,20 +72,20 @@ module.exports = function(ctx, helpers) {
|
||||
const radarrBaseUrl = radarr.url.replace(/\/+$/, '');
|
||||
|
||||
// Fetch quality profiles from Radarr
|
||||
const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, {
|
||||
const profilesRes = await fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, {
|
||||
headers: { 'X-Api-Key': radarr.apiKey }
|
||||
});
|
||||
const profiles = profilesRes.ok ? await profilesRes.json() : [];
|
||||
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
|
||||
|
||||
// Fetch root folders from Radarr
|
||||
const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
|
||||
const rootFoldersRes = await fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
|
||||
headers: { 'X-Api-Key': radarr.apiKey }
|
||||
});
|
||||
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
|
||||
const defaultRootFolder = rootFolders[0]?.path || '/movies';
|
||||
|
||||
ctx.log.info('arr', 'Radarr configured', { profile: defaultProfile.name, profileId: defaultProfile.id, rootFolder: defaultRootFolder });
|
||||
log.info('arr', 'Radarr configured', { profile: defaultProfile.name, profileId: defaultProfile.id, rootFolder: defaultRootFolder });
|
||||
|
||||
const radarrConfig = {
|
||||
name: 'Radarr',
|
||||
@@ -115,14 +128,14 @@ module.exports = function(ctx, helpers) {
|
||||
const sonarrBaseUrl = sonarr.url.replace(/\/+$/, '');
|
||||
|
||||
// Fetch quality profiles from Sonarr
|
||||
const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, {
|
||||
const profilesRes = await fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, {
|
||||
headers: { 'X-Api-Key': sonarr.apiKey }
|
||||
});
|
||||
const profiles = profilesRes.ok ? await profilesRes.json() : [];
|
||||
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
|
||||
|
||||
// Fetch root folders from Sonarr
|
||||
const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, {
|
||||
const rootFoldersRes = await fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, {
|
||||
headers: { 'X-Api-Key': sonarr.apiKey }
|
||||
});
|
||||
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
|
||||
@@ -131,7 +144,7 @@ module.exports = function(ctx, helpers) {
|
||||
// Fetch language profiles from Sonarr (v3 uses languageprofile, v4 doesn't need it)
|
||||
let languageProfileId = 1;
|
||||
try {
|
||||
const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
|
||||
const langRes = await fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
|
||||
headers: { 'X-Api-Key': sonarr.apiKey }
|
||||
});
|
||||
if (langRes.ok) {
|
||||
@@ -142,7 +155,7 @@ module.exports = function(ctx, helpers) {
|
||||
// Language profiles might not exist in Sonarr v4
|
||||
}
|
||||
|
||||
ctx.log.info('arr', 'Sonarr configured', { profile: defaultProfile.name, profileId: defaultProfile.id, rootFolder: defaultRootFolder });
|
||||
log.info('arr', 'Sonarr configured', { profile: defaultProfile.name, profileId: defaultProfile.id, rootFolder: defaultRootFolder });
|
||||
|
||||
const sonarrConfig = {
|
||||
name: 'Sonarr',
|
||||
@@ -188,7 +201,7 @@ module.exports = function(ctx, helpers) {
|
||||
}, 'arr-configure-overseerr'));
|
||||
|
||||
// Test connection to external Radarr/Sonarr service
|
||||
router.post('/arr/test-connection', ctx.asyncHandler(async (req, res) => {
|
||||
router.post('/arr/test-connection', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { service, url, apiKey } = req.body;
|
||||
|
||||
@@ -200,7 +213,7 @@ module.exports = function(ctx, helpers) {
|
||||
try {
|
||||
validateURL(url);
|
||||
} catch (validationErr) {
|
||||
return ctx.errorResponse(res, 400, validationErr.message);
|
||||
return errorResponse(res, 400, validationErr.message);
|
||||
}
|
||||
|
||||
// Validate API key format
|
||||
@@ -225,13 +238,13 @@ module.exports = function(ctx, helpers) {
|
||||
apiEndpoint = `${baseUrl}/identity`;
|
||||
headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' };
|
||||
} else {
|
||||
return ctx.errorResponse(res, 400, `Unknown service: ${service}`);
|
||||
return errorResponse(res, 400, `Unknown service: ${service}`);
|
||||
}
|
||||
|
||||
ctx.log.info('arr', 'Testing service connection', { service });
|
||||
log.info('arr', 'Testing service connection', { service });
|
||||
|
||||
// Make the API call
|
||||
const response = await ctx.fetchT(apiEndpoint, {
|
||||
const response = await fetchT(apiEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000)
|
||||
@@ -241,7 +254,7 @@ module.exports = function(ctx, helpers) {
|
||||
const data = await response.json();
|
||||
const version = service === 'plex' ? data.MediaContainer?.version : data.version;
|
||||
const appName = service === 'plex' ? 'Plex' : data.appName;
|
||||
ctx.log.info('arr', 'Service connection successful', { service, appName, version });
|
||||
log.info('arr', 'Service connection successful', { service, appName, version });
|
||||
return res.json({
|
||||
success: true,
|
||||
version,
|
||||
@@ -252,25 +265,25 @@ module.exports = function(ctx, helpers) {
|
||||
} else if (response.status === 404) {
|
||||
throw new NotFoundError('API not found - check URL');
|
||||
} else {
|
||||
return ctx.errorResponse(res, 502, `HTTP ${response.status}`);
|
||||
return errorResponse(res, 502, `HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await ctx.logError('arr-test-connection', error);
|
||||
await logError('arr-test-connection', error);
|
||||
if (error.cause?.code === 'ECONNREFUSED') {
|
||||
return ctx.errorResponse(res, 502, 'Connection refused');
|
||||
return errorResponse(res, 502, 'Connection refused');
|
||||
} else if (error.name === 'AbortError' || error.message?.includes('timeout')) {
|
||||
return ctx.errorResponse(res, 504, 'Connection timeout');
|
||||
return errorResponse(res, 504, 'Connection timeout');
|
||||
}
|
||||
return ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
return errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
}
|
||||
}, 'arr-test-connection'));
|
||||
|
||||
// Quick setup: Detect all services and configure Overseerr automatically
|
||||
router.post('/arr/auto-setup', ctx.asyncHandler(async (req, res) => {
|
||||
ctx.log.info('arr', 'Starting arr auto-setup');
|
||||
router.post('/arr/auto-setup', asyncHandler(async (req, res) => {
|
||||
log.info('arr', 'Starting arr auto-setup');
|
||||
|
||||
// Step 1: Detect all running arr services
|
||||
const containers = await ctx.docker.client.listContainers({ all: false });
|
||||
const containers = await docker.client.listContainers({ all: false });
|
||||
const detected = {};
|
||||
|
||||
const servicePatterns = ARR_SERVICES;
|
||||
@@ -309,17 +322,17 @@ module.exports = function(ctx, helpers) {
|
||||
prowlarrFound: !!detected.prowlarr?.apiKey
|
||||
};
|
||||
|
||||
ctx.log.info('arr', 'Detected services', summary);
|
||||
log.info('arr', 'Detected services', summary);
|
||||
|
||||
if (!summary.overseerrFound) {
|
||||
return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
|
||||
return errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
|
||||
detected,
|
||||
summary
|
||||
});
|
||||
}
|
||||
|
||||
if (!summary.radarrFound && !summary.sonarrFound) {
|
||||
return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', {
|
||||
return errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', {
|
||||
detected,
|
||||
summary
|
||||
});
|
||||
@@ -329,18 +342,18 @@ module.exports = function(ctx, helpers) {
|
||||
const overseerrSession = await helpers.getOverseerrSession();
|
||||
|
||||
if (!overseerrSession) {
|
||||
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
|
||||
return errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
|
||||
setupUrl: detected.overseerr.localUrl,
|
||||
detected,
|
||||
summary
|
||||
});
|
||||
}
|
||||
|
||||
ctx.log.info('arr', 'Authenticated with Overseerr via Plex session');
|
||||
log.info('arr', 'Authenticated with Overseerr via Plex session');
|
||||
|
||||
// Helper for authenticated Overseerr requests
|
||||
const overseerrFetch = async (endpoint, options = {}) => {
|
||||
return ctx.fetchT(`${detected.overseerr.url}${endpoint}`, {
|
||||
return fetchT(`${detected.overseerr.url}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -356,20 +369,20 @@ module.exports = function(ctx, helpers) {
|
||||
if (detected.radarr?.apiKey) {
|
||||
try {
|
||||
// Fetch quality profiles from Radarr
|
||||
const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, {
|
||||
const profilesRes = await fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, {
|
||||
headers: { 'X-Api-Key': detected.radarr.apiKey }
|
||||
});
|
||||
const profiles = profilesRes.ok ? await profilesRes.json() : [];
|
||||
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
|
||||
|
||||
// Fetch root folders from Radarr
|
||||
const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, {
|
||||
const rootFoldersRes = await fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, {
|
||||
headers: { 'X-Api-Key': detected.radarr.apiKey }
|
||||
});
|
||||
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
|
||||
const defaultRootFolder = rootFolders[0]?.path || '/movies';
|
||||
|
||||
ctx.log.info('arr', 'Radarr profile selected', { profile: defaultProfile.name, rootFolder: defaultRootFolder });
|
||||
log.info('arr', 'Radarr profile selected', { profile: defaultProfile.name, rootFolder: defaultRootFolder });
|
||||
|
||||
const radarrConfig = {
|
||||
name: 'Radarr',
|
||||
@@ -403,14 +416,14 @@ module.exports = function(ctx, helpers) {
|
||||
if (detected.sonarr?.apiKey) {
|
||||
try {
|
||||
// Fetch quality profiles from Sonarr
|
||||
const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, {
|
||||
const profilesRes = await fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, {
|
||||
headers: { 'X-Api-Key': detected.sonarr.apiKey }
|
||||
});
|
||||
const profiles = profilesRes.ok ? await profilesRes.json() : [];
|
||||
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
|
||||
|
||||
// Fetch root folders from Sonarr
|
||||
const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, {
|
||||
const rootFoldersRes = await fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, {
|
||||
headers: { 'X-Api-Key': detected.sonarr.apiKey }
|
||||
});
|
||||
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
|
||||
@@ -419,7 +432,7 @@ module.exports = function(ctx, helpers) {
|
||||
// Fetch language profiles (Sonarr v3)
|
||||
let languageProfileId = 1;
|
||||
try {
|
||||
const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, {
|
||||
const langRes = await fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, {
|
||||
headers: { 'X-Api-Key': detected.sonarr.apiKey }
|
||||
});
|
||||
if (langRes.ok) {
|
||||
@@ -428,7 +441,7 @@ module.exports = function(ctx, helpers) {
|
||||
}
|
||||
} catch (e) { /* Sonarr v4 doesn't need this */ }
|
||||
|
||||
ctx.log.info('arr', 'Sonarr profile selected', { profile: defaultProfile.name, rootFolder: defaultRootFolder });
|
||||
log.info('arr', 'Sonarr profile selected', { profile: defaultProfile.name, rootFolder: defaultRootFolder });
|
||||
|
||||
const sonarrConfig = {
|
||||
name: 'Sonarr',
|
||||
@@ -481,7 +494,7 @@ module.exports = function(ctx, helpers) {
|
||||
}, 'arr-auto-setup'));
|
||||
|
||||
// Fetch quality profiles from an arr service (Radarr/Sonarr)
|
||||
router.get('/arr/quality-profiles', ctx.asyncHandler(async (req, res) => {
|
||||
router.get('/arr/quality-profiles', asyncHandler(async (req, res) => {
|
||||
const { service, url, apiKey } = req.query;
|
||||
|
||||
if (!service || !['radarr', 'sonarr'].includes(service)) {
|
||||
@@ -493,19 +506,19 @@ module.exports = function(ctx, helpers) {
|
||||
let resolvedUrl = url;
|
||||
|
||||
if (!resolvedKey) {
|
||||
resolvedKey = await ctx.credentialManager.retrieve(`arr.${service}.apikey`);
|
||||
resolvedKey = await credentialManager.retrieve(`arr.${service}.apikey`);
|
||||
}
|
||||
if (!resolvedKey) {
|
||||
resolvedKey = await ctx.credentialManager.retrieve(`service.${service}.apikey`);
|
||||
resolvedKey = await credentialManager.retrieve(`service.${service}.apikey`);
|
||||
}
|
||||
|
||||
if (!resolvedUrl) {
|
||||
const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`);
|
||||
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
|
||||
resolvedUrl = metadata?.url;
|
||||
}
|
||||
if (!resolvedUrl) {
|
||||
try {
|
||||
const services = await ctx.servicesStateManager.read();
|
||||
const services = await servicesStateManager.read();
|
||||
const svcList = Array.isArray(services) ? services : services.services || [];
|
||||
const found = svcList.find(s => s.id === service);
|
||||
if (found?.externalUrl) resolvedUrl = found.externalUrl;
|
||||
@@ -520,13 +533,13 @@ module.exports = function(ctx, helpers) {
|
||||
const baseUrl = resolvedUrl.replace(/\/+$/, '');
|
||||
|
||||
try {
|
||||
const profilesRes = await ctx.fetchT(`${baseUrl}/api/v3/qualityprofile`, {
|
||||
const profilesRes = await fetchT(`${baseUrl}/api/v3/qualityprofile`, {
|
||||
headers: { 'X-Api-Key': resolvedKey, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000)
|
||||
});
|
||||
|
||||
if (!profilesRes.ok) {
|
||||
return ctx.errorResponse(res, profilesRes.status === 401 ? 401 : 502,
|
||||
return errorResponse(res, profilesRes.status === 401 ? 401 : 502,
|
||||
profilesRes.status === 401 ? 'Invalid API key' : `Failed to fetch profiles (HTTP ${profilesRes.status})`);
|
||||
}
|
||||
|
||||
@@ -534,23 +547,23 @@ module.exports = function(ctx, helpers) {
|
||||
const mapped = profiles.map(p => ({ id: p.id, name: p.name }));
|
||||
|
||||
// Load stored profile preference
|
||||
const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`);
|
||||
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
|
||||
const storedProfileId = metadata?.qualityProfileId || null;
|
||||
|
||||
res.json({ success: true, profiles: mapped, storedProfileId });
|
||||
} catch (e) {
|
||||
if (e.cause?.code === 'ECONNREFUSED') {
|
||||
return ctx.errorResponse(res, 502, 'Connection refused — is the service running?');
|
||||
return errorResponse(res, 502, 'Connection refused — is the service running?');
|
||||
}
|
||||
if (e.name === 'AbortError') {
|
||||
return ctx.errorResponse(res, 504, 'Connection timeout');
|
||||
return errorResponse(res, 504, 'Connection timeout');
|
||||
}
|
||||
return ctx.errorResponse(res, 500, e.message);
|
||||
return errorResponse(res, 500, e.message);
|
||||
}
|
||||
}, 'arr-quality-profiles'));
|
||||
|
||||
// Save quality profile preference (without re-storing API key)
|
||||
router.post('/arr/quality-profiles', ctx.asyncHandler(async (req, res) => {
|
||||
router.post('/arr/quality-profiles', asyncHandler(async (req, res) => {
|
||||
const { service, qualityProfileId, qualityProfileName } = req.body;
|
||||
|
||||
if (!service || !['radarr', 'sonarr'].includes(service)) {
|
||||
@@ -561,7 +574,7 @@ module.exports = function(ctx, helpers) {
|
||||
}
|
||||
|
||||
const credKey = `arr.${service}.apikey`;
|
||||
const existing = await ctx.credentialManager.getMetadata(credKey);
|
||||
const existing = await credentialManager.getMetadata(credKey);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundError('No stored credentials for this service');
|
||||
@@ -570,7 +583,7 @@ module.exports = function(ctx, helpers) {
|
||||
// Merge quality profile into existing metadata
|
||||
existing.qualityProfileId = qualityProfileId;
|
||||
existing.qualityProfileName = qualityProfileName || null;
|
||||
await ctx.credentialManager.storeMetadata(credKey, existing);
|
||||
await credentialManager.storeMetadata(credKey, existing);
|
||||
|
||||
res.json({ success: true, message: `Quality profile updated for ${service}` });
|
||||
}, 'arr-quality-profile-save'));
|
||||
|
||||
Reference in New Issue
Block a user