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:
Krystie
2026-03-29 21:30:20 -07:00
parent ac23b2e093
commit 6bde2eb62e
7 changed files with 217 additions and 132 deletions

View File

@@ -3,11 +3,24 @@ const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); 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(); const router = express.Router();
// Auto-configure Overseerr with detected services // 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 { radarr, sonarr } = req.body;
const results = { radarr: null, sonarr: null }; const results = { radarr: null, sonarr: null };
@@ -16,17 +29,17 @@ module.exports = function(ctx, helpers) {
const overseerrSession = await helpers.getOverseerrSession(); const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) { 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.' 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 // Helper to make authenticated requests to Overseerr
const overseerrFetch = async (endpoint, options = {}) => { const overseerrFetch = async (endpoint, options = {}) => {
const url = `${overseerrUrl}${endpoint}`; const url = `${overseerrUrl}${endpoint}`;
const response = await ctx.fetchT(url, { const response = await fetchT(url, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -41,12 +54,12 @@ module.exports = function(ctx, helpers) {
try { try {
const statusRes = await overseerrFetch('/api/v1/status'); const statusRes = await overseerrFetch('/api/v1/status');
if (!statusRes.ok) { 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' hint: 'Make sure Overseerr is running on port 5055'
}); });
} }
} catch (e) { } 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' hint: 'Check if Overseerr container is running'
}); });
} }
@@ -59,20 +72,20 @@ module.exports = function(ctx, helpers) {
const radarrBaseUrl = radarr.url.replace(/\/+$/, ''); const radarrBaseUrl = radarr.url.replace(/\/+$/, '');
// Fetch quality profiles from Radarr // 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 } headers: { 'X-Api-Key': radarr.apiKey }
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr // 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 } headers: { 'X-Api-Key': radarr.apiKey }
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies'; 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 = { const radarrConfig = {
name: 'Radarr', name: 'Radarr',
@@ -115,14 +128,14 @@ module.exports = function(ctx, helpers) {
const sonarrBaseUrl = sonarr.url.replace(/\/+$/, ''); const sonarrBaseUrl = sonarr.url.replace(/\/+$/, '');
// Fetch quality profiles from Sonarr // 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 } headers: { 'X-Api-Key': sonarr.apiKey }
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr // 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 } headers: { 'X-Api-Key': sonarr.apiKey }
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; 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) // Fetch language profiles from Sonarr (v3 uses languageprofile, v4 doesn't need it)
let languageProfileId = 1; let languageProfileId = 1;
try { try {
const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, { const langRes = await fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey } headers: { 'X-Api-Key': sonarr.apiKey }
}); });
if (langRes.ok) { if (langRes.ok) {
@@ -142,7 +155,7 @@ module.exports = function(ctx, helpers) {
// Language profiles might not exist in Sonarr v4 // 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 = { const sonarrConfig = {
name: 'Sonarr', name: 'Sonarr',
@@ -188,7 +201,7 @@ module.exports = function(ctx, helpers) {
}, 'arr-configure-overseerr')); }, 'arr-configure-overseerr'));
// Test connection to external Radarr/Sonarr service // 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 { try {
const { service, url, apiKey } = req.body; const { service, url, apiKey } = req.body;
@@ -200,7 +213,7 @@ module.exports = function(ctx, helpers) {
try { try {
validateURL(url); validateURL(url);
} catch (validationErr) { } catch (validationErr) {
return ctx.errorResponse(res, 400, validationErr.message); return errorResponse(res, 400, validationErr.message);
} }
// Validate API key format // Validate API key format
@@ -225,13 +238,13 @@ module.exports = function(ctx, helpers) {
apiEndpoint = `${baseUrl}/identity`; apiEndpoint = `${baseUrl}/identity`;
headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' }; headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' };
} else { } 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 // Make the API call
const response = await ctx.fetchT(apiEndpoint, { const response = await fetchT(apiEndpoint, {
method: 'GET', method: 'GET',
headers, headers,
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
@@ -241,7 +254,7 @@ module.exports = function(ctx, helpers) {
const data = await response.json(); const data = await response.json();
const version = service === 'plex' ? data.MediaContainer?.version : data.version; const version = service === 'plex' ? data.MediaContainer?.version : data.version;
const appName = service === 'plex' ? 'Plex' : data.appName; 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({ return res.json({
success: true, success: true,
version, version,
@@ -252,25 +265,25 @@ module.exports = function(ctx, helpers) {
} else if (response.status === 404) { } else if (response.status === 404) {
throw new NotFoundError('API not found - check URL'); throw new NotFoundError('API not found - check URL');
} else { } else {
return ctx.errorResponse(res, 502, `HTTP ${response.status}`); return errorResponse(res, 502, `HTTP ${response.status}`);
} }
} catch (error) { } catch (error) {
await ctx.logError('arr-test-connection', error); await logError('arr-test-connection', error);
if (error.cause?.code === 'ECONNREFUSED') { 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')) { } 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')); }, 'arr-test-connection'));
// Quick setup: Detect all services and configure Overseerr automatically // Quick setup: Detect all services and configure Overseerr automatically
router.post('/arr/auto-setup', ctx.asyncHandler(async (req, res) => { router.post('/arr/auto-setup', asyncHandler(async (req, res) => {
ctx.log.info('arr', 'Starting arr auto-setup'); log.info('arr', 'Starting arr auto-setup');
// Step 1: Detect all running arr services // 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 detected = {};
const servicePatterns = ARR_SERVICES; const servicePatterns = ARR_SERVICES;
@@ -309,17 +322,17 @@ module.exports = function(ctx, helpers) {
prowlarrFound: !!detected.prowlarr?.apiKey prowlarrFound: !!detected.prowlarr?.apiKey
}; };
ctx.log.info('arr', 'Detected services', summary); log.info('arr', 'Detected services', summary);
if (!summary.overseerrFound) { 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, detected,
summary summary
}); });
} }
if (!summary.radarrFound && !summary.sonarrFound) { 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, detected,
summary summary
}); });
@@ -329,18 +342,18 @@ module.exports = function(ctx, helpers) {
const overseerrSession = await helpers.getOverseerrSession(); const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) { 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, setupUrl: detected.overseerr.localUrl,
detected, detected,
summary 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 // Helper for authenticated Overseerr requests
const overseerrFetch = async (endpoint, options = {}) => { const overseerrFetch = async (endpoint, options = {}) => {
return ctx.fetchT(`${detected.overseerr.url}${endpoint}`, { return fetchT(`${detected.overseerr.url}${endpoint}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -356,20 +369,20 @@ module.exports = function(ctx, helpers) {
if (detected.radarr?.apiKey) { if (detected.radarr?.apiKey) {
try { try {
// Fetch quality profiles from Radarr // 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 } headers: { 'X-Api-Key': detected.radarr.apiKey }
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr // 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 } headers: { 'X-Api-Key': detected.radarr.apiKey }
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies'; 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 = { const radarrConfig = {
name: 'Radarr', name: 'Radarr',
@@ -403,14 +416,14 @@ module.exports = function(ctx, helpers) {
if (detected.sonarr?.apiKey) { if (detected.sonarr?.apiKey) {
try { try {
// Fetch quality profiles from Sonarr // 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 } headers: { 'X-Api-Key': detected.sonarr.apiKey }
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr // 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 } headers: { 'X-Api-Key': detected.sonarr.apiKey }
}); });
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
@@ -419,7 +432,7 @@ module.exports = function(ctx, helpers) {
// Fetch language profiles (Sonarr v3) // Fetch language profiles (Sonarr v3)
let languageProfileId = 1; let languageProfileId = 1;
try { 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 } headers: { 'X-Api-Key': detected.sonarr.apiKey }
}); });
if (langRes.ok) { if (langRes.ok) {
@@ -428,7 +441,7 @@ module.exports = function(ctx, helpers) {
} }
} catch (e) { /* Sonarr v4 doesn't need this */ } } 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 = { const sonarrConfig = {
name: 'Sonarr', name: 'Sonarr',
@@ -481,7 +494,7 @@ module.exports = function(ctx, helpers) {
}, 'arr-auto-setup')); }, 'arr-auto-setup'));
// Fetch quality profiles from an arr service (Radarr/Sonarr) // 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; const { service, url, apiKey } = req.query;
if (!service || !['radarr', 'sonarr'].includes(service)) { if (!service || !['radarr', 'sonarr'].includes(service)) {
@@ -493,19 +506,19 @@ module.exports = function(ctx, helpers) {
let resolvedUrl = url; let resolvedUrl = url;
if (!resolvedKey) { if (!resolvedKey) {
resolvedKey = await ctx.credentialManager.retrieve(`arr.${service}.apikey`); resolvedKey = await credentialManager.retrieve(`arr.${service}.apikey`);
} }
if (!resolvedKey) { if (!resolvedKey) {
resolvedKey = await ctx.credentialManager.retrieve(`service.${service}.apikey`); resolvedKey = await credentialManager.retrieve(`service.${service}.apikey`);
} }
if (!resolvedUrl) { if (!resolvedUrl) {
const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`); const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
resolvedUrl = metadata?.url; resolvedUrl = metadata?.url;
} }
if (!resolvedUrl) { if (!resolvedUrl) {
try { try {
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const svcList = Array.isArray(services) ? services : services.services || []; const svcList = Array.isArray(services) ? services : services.services || [];
const found = svcList.find(s => s.id === service); const found = svcList.find(s => s.id === service);
if (found?.externalUrl) resolvedUrl = found.externalUrl; if (found?.externalUrl) resolvedUrl = found.externalUrl;
@@ -520,13 +533,13 @@ module.exports = function(ctx, helpers) {
const baseUrl = resolvedUrl.replace(/\/+$/, ''); const baseUrl = resolvedUrl.replace(/\/+$/, '');
try { 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' }, headers: { 'X-Api-Key': resolvedKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
if (!profilesRes.ok) { 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})`); 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 })); const mapped = profiles.map(p => ({ id: p.id, name: p.name }));
// Load stored profile preference // 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; const storedProfileId = metadata?.qualityProfileId || null;
res.json({ success: true, profiles: mapped, storedProfileId }); res.json({ success: true, profiles: mapped, storedProfileId });
} catch (e) { } catch (e) {
if (e.cause?.code === 'ECONNREFUSED') { 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') { 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')); }, 'arr-quality-profiles'));
// Save quality profile preference (without re-storing API key) // 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; const { service, qualityProfileId, qualityProfileName } = req.body;
if (!service || !['radarr', 'sonarr'].includes(service)) { if (!service || !['radarr', 'sonarr'].includes(service)) {
@@ -561,7 +574,7 @@ module.exports = function(ctx, helpers) {
} }
const credKey = `arr.${service}.apikey`; const credKey = `arr.${service}.apikey`;
const existing = await ctx.credentialManager.getMetadata(credKey); const existing = await credentialManager.getMetadata(credKey);
if (!existing) { if (!existing) {
throw new NotFoundError('No stored credentials for this service'); throw new NotFoundError('No stored credentials for this service');
@@ -570,7 +583,7 @@ module.exports = function(ctx, helpers) {
// Merge quality profile into existing metadata // Merge quality profile into existing metadata
existing.qualityProfileId = qualityProfileId; existing.qualityProfileId = qualityProfileId;
existing.qualityProfileName = qualityProfileName || null; 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}` }); res.json({ success: true, message: `Quality profile updated for ${service}` });
}, 'arr-quality-profile-save')); }, 'arr-quality-profile-save'));

View File

@@ -2,11 +2,22 @@ const express = require('express');
const { validateURL, validateToken } = require('../../input-validator'); const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
module.exports = function(ctx, helpers) { /**
* Arr credentials routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @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, asyncHandler, errorResponse, log, helpers }) {
const router = express.Router(); const router = express.Router();
// Store arr service credentials // Store arr service credentials
router.post('/arr/credentials', ctx.asyncHandler(async (req, res) => { router.post('/arr/credentials', asyncHandler(async (req, res) => {
const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body; const { service, apiKey, url, seedboxBaseUrl, qualityProfileId, qualityProfileName } = req.body;
if (!service || !apiKey) { if (!service || !apiKey) {
@@ -15,7 +26,7 @@ module.exports = function(ctx, helpers) {
const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex']; const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
if (!validServices.includes(service)) { if (!validServices.includes(service)) {
return ctx.errorResponse(res, 400, `Invalid service. Must be one of: ${validServices.join(', ')}`); return errorResponse(res, 400, `Invalid service. Must be one of: ${validServices.join(', ')}`);
} }
// Validate API key format // Validate API key format
@@ -50,7 +61,7 @@ module.exports = function(ctx, helpers) {
if (!resolvedUrl) { if (!resolvedUrl) {
// Try to resolve URL from services.json // Try to resolve URL from services.json
try { try {
const services = await ctx.servicesStateManager.read(); const services = await servicesStateManager.read();
const svc = Array.isArray(services) ? services : services.services || []; const svc = Array.isArray(services) ? services : services.services || [];
const found = svc.find(s => s.id === service && s.isExternal); const found = svc.find(s => s.id === service && s.isExternal);
if (found?.externalUrl) resolvedUrl = found.externalUrl; if (found?.externalUrl) resolvedUrl = found.externalUrl;
@@ -73,9 +84,9 @@ module.exports = function(ctx, helpers) {
} }
// Store the credential // Store the credential
const stored = await ctx.credentialManager.store(credKey, apiKey, metadata); const stored = await credentialManager.store(credKey, apiKey, metadata);
if (!stored) { if (!stored) {
return ctx.errorResponse(res, 500, 'Failed to store credential'); return errorResponse(res, 500, 'Failed to store credential');
} }
// Optionally store seedbox base URL // Optionally store seedbox base URL
@@ -83,12 +94,12 @@ module.exports = function(ctx, helpers) {
try { validateURL(seedboxBaseUrl); } catch (e) { try { validateURL(seedboxBaseUrl); } catch (e) {
throw new ValidationError('Invalid seedbox base URL'); throw new ValidationError('Invalid seedbox base URL');
} }
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, { await credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
storedAt: new Date().toISOString() storedAt: new Date().toISOString()
}); });
} }
ctx.log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false }); log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
res.json({ res.json({
success: true, success: true,
@@ -99,14 +110,14 @@ module.exports = function(ctx, helpers) {
}, 'arr-credentials-store')); }, 'arr-credentials-store'));
// List stored arr credentials (keys only, not values) // List stored arr credentials (keys only, not values)
router.get('/arr/credentials', ctx.asyncHandler(async (req, res) => { router.get('/arr/credentials', asyncHandler(async (req, res) => {
const services = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex']; const services = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
const credentials = {}; const credentials = {};
for (const service of services) { for (const service of services) {
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
const hasKey = !!(await ctx.credentialManager.retrieve(credKey)); const hasKey = !!(await credentialManager.retrieve(credKey));
const metadata = await ctx.credentialManager.getMetadata(credKey); const metadata = await credentialManager.getMetadata(credKey);
credentials[service] = { credentials[service] = {
hasKey, hasKey,
@@ -118,17 +129,17 @@ module.exports = function(ctx, helpers) {
} }
// Get seedbox base URL // Get seedbox base URL
const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl'); const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null }); res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null });
}, 'arr-credentials-list')); }, 'arr-credentials-list'));
// Delete stored arr credentials // Delete stored arr credentials
router.delete('/arr/credentials/:service', ctx.asyncHandler(async (req, res) => { router.delete('/arr/credentials/:service', asyncHandler(async (req, res) => {
const { service } = req.params; const { service } = req.params;
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
await ctx.credentialManager.delete(credKey); await credentialManager.delete(credKey);
ctx.log.info('arr', 'Deleted credentials', { service }); log.info('arr', 'Deleted credentials', { service });
res.json({ success: true, message: `${service} credentials removed` }); res.json({ success: true, message: `${service} credentials removed` });
}, 'arr-credentials-delete')); }, 'arr-credentials-delete'));

View File

@@ -1,12 +1,23 @@
const express = require('express'); const express = require('express');
const { APP_PORTS, ARR_SERVICES } = require('../../constants'); const { APP_PORTS, ARR_SERVICES } = require('../../constants');
module.exports = function(ctx, helpers) { /**
* Arr service detection routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.credentialManager - Credential manager
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.helpers - Arr helpers module
* @returns {express.Router}
*/
module.exports = function({ docker, servicesStateManager, credentialManager, fetchT, asyncHandler, helpers }) {
const router = express.Router(); const router = express.Router();
// Detect running arr services and their configurations // Detect running arr services and their configurations
router.get('/arr/detect', ctx.asyncHandler(async (req, res) => { router.get('/arr/detect', asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: false }); const containers = await docker.client.listContainers({ all: false });
const detected = { const detected = {
plex: null, plex: null,
radarr: null, radarr: null,
@@ -64,14 +75,14 @@ module.exports = function(ctx, helpers) {
}, 'arr-detect')); }, 'arr-detect'));
// Smart Detect: Unified discovery of all arr services // Smart Detect: Unified discovery of all arr services
router.get('/arr/smart-detect', ctx.asyncHandler(async (req, res) => { router.get('/arr/smart-detect', asyncHandler(async (req, res) => {
const serviceList = ['plex', 'radarr', 'sonarr', 'prowlarr', 'seerr']; const serviceList = ['plex', 'radarr', 'sonarr', 'prowlarr', 'seerr'];
const defaultPorts = APP_PORTS; const defaultPorts = APP_PORTS;
const result = {}; const result = {};
// 1. Scan Docker containers // 1. Scan Docker containers
let containers = []; let containers = [];
try { containers = await ctx.docker.client.listContainers({ all: false }); } catch (e) { /* Docker not available */ } try { containers = await docker.client.listContainers({ all: false }); } catch (e) { /* Docker not available */ }
const servicePatterns = ARR_SERVICES; const servicePatterns = ARR_SERVICES;
@@ -95,18 +106,18 @@ module.exports = function(ctx, helpers) {
// 2. Load services.json for external entries // 2. Load services.json for external entries
let storedServices = []; let storedServices = [];
try { try {
const data = await ctx.servicesStateManager.read(); const data = await servicesStateManager.read();
storedServices = Array.isArray(data) ? data : data.services || []; storedServices = Array.isArray(data) ? data : data.services || [];
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
// 3. Load stored credentials // 3. Load stored credentials
const storedCreds = {}; const storedCreds = {};
const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl'); const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl');
for (const svc of serviceList) { for (const svc of serviceList) {
const credKey = svc === 'plex' ? 'arr.plex.token' : `arr.${svc}.apikey`; const credKey = svc === 'plex' ? 'arr.plex.token' : `arr.${svc}.apikey`;
const apiKey = await ctx.credentialManager.retrieve(credKey); const apiKey = await credentialManager.retrieve(credKey);
const metadata = await ctx.credentialManager.getMetadata(credKey); const metadata = await credentialManager.getMetadata(credKey);
if (apiKey) { if (apiKey) {
storedCreds[svc] = { apiKey, metadata }; storedCreds[svc] = { apiKey, metadata };
} }
@@ -141,7 +152,7 @@ module.exports = function(ctx, helpers) {
entry.hasToken = true; entry.hasToken = true;
entry.status = 'connected'; entry.status = 'connected';
// Store for later use // Store for later use
await ctx.credentialManager.store('arr.plex.token', token, { await credentialManager.store('arr.plex.token', token, {
service: 'plex', source: 'local', url: entry.url, service: 'plex', source: 'local', url: entry.url,
lastVerified: new Date().toISOString() lastVerified: new Date().toISOString()
}); });
@@ -158,7 +169,7 @@ module.exports = function(ctx, helpers) {
entry.hasApiKey = true; entry.hasApiKey = true;
const configuredServices = { radarr: false, sonarr: false, plex: false }; const configuredServices = { radarr: false, sonarr: false, plex: false };
try { try {
const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, { const radarrCheck = await fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });
@@ -168,7 +179,7 @@ module.exports = function(ctx, helpers) {
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
try { try {
const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, { const sonarrCheck = await fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });
@@ -178,7 +189,7 @@ module.exports = function(ctx, helpers) {
} }
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
try { try {
const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, { const plexCheck = await fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, {
headers: { 'Cookie': session.cookie }, headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });

View File

@@ -1,14 +1,23 @@
const { APP_PORTS } = require('../../constants'); const { APP_PORTS } = require('../../constants');
module.exports = function(ctx) { /**
* Arr helpers factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.credentialManager - Credential manager
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @param {Object} deps.log - Logger instance
* @returns {Object} Helper functions
*/
module.exports = function({ docker, credentialManager, fetchT, log }) {
// Helper: Extract API key from arr service config.xml // Helper: Extract API key from arr service config.xml
async function getArrApiKey(containerName) { async function getArrApiKey(containerName) {
try { try {
const container = await ctx.docker.findContainer(containerName); const container = await docker.findContainer(containerName);
if (!container) return null; if (!container) return null;
const dockerContainer = ctx.docker.client.getContainer(container.Id); const dockerContainer = docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({ const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'], Cmd: ['cat', '/config/config.xml'],
AttachStdout: true, AttachStdout: true,
@@ -28,7 +37,7 @@ module.exports = function(ctx) {
stream.on('error', () => resolve(null)); stream.on('error', () => resolve(null));
}); });
} catch (error) { } catch (error) {
ctx.log.error('docker', 'Failed to get API key', { containerName, error: error.message }); log.error('docker', 'Failed to get API key', { containerName, error: error.message });
return null; return null;
} }
} }
@@ -36,14 +45,14 @@ module.exports = function(ctx) {
// Helper: Get Plex token from container or config // Helper: Get Plex token from container or config
async function getPlexToken(containerName) { async function getPlexToken(containerName) {
try { try {
const containers = await ctx.docker.client.listContainers({ all: false }); const containers = await docker.client.listContainers({ all: false });
const container = containers.find(c => const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
); );
if (!container) return null; if (!container) return null;
const dockerContainer = ctx.docker.client.getContainer(container.Id); const dockerContainer = docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({ const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true, AttachStdout: true,
@@ -62,7 +71,7 @@ module.exports = function(ctx) {
stream.on('error', () => resolve(null)); stream.on('error', () => resolve(null));
}); });
} catch (error) { } catch (error) {
ctx.log.error('docker', 'Failed to get Plex token', { error: error.message }); log.error('docker', 'Failed to get Plex token', { error: error.message });
return null; return null;
} }
} }
@@ -84,16 +93,16 @@ module.exports = function(ctx) {
// Fall back to stored Plex token in credential manager // Fall back to stored Plex token in credential manager
if (!plexToken) { if (!plexToken) {
plexToken = await ctx.credentialManager.retrieve('arr.plex.token'); plexToken = await credentialManager.retrieve('arr.plex.token');
} }
if (!plexToken) { if (!plexToken) {
ctx.log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)'); log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)');
return null; return null;
} }
// Authenticate with Seerr via Plex token // Authenticate with Seerr via Plex token
const authRes = await ctx.fetchT(`${seerrUrl}/api/v1/auth/plex`, { const authRes = await fetchT(`${seerrUrl}/api/v1/auth/plex`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }), body: JSON.stringify({ authToken: plexToken }),
@@ -101,20 +110,20 @@ module.exports = function(ctx) {
}); });
if (!authRes.ok) { if (!authRes.ok) {
ctx.log.error('arr', 'Seerr Plex auth failed', { status: authRes.status }); log.error('arr', 'Seerr Plex auth failed', { status: authRes.status });
return null; return null;
} }
const setCookie = authRes.headers.get('set-cookie'); const setCookie = authRes.headers.get('set-cookie');
if (!setCookie) { if (!setCookie) {
ctx.log.error('arr', 'No session cookie returned from Seerr'); log.error('arr', 'No session cookie returned from Seerr');
return null; return null;
} }
const sessionCookie = setCookie.split(';')[0]; const sessionCookie = setCookie.split(';')[0];
return { cookie: sessionCookie, plexToken }; return { cookie: sessionCookie, plexToken };
} catch (e) { } catch (e) {
ctx.log.error('arr', 'Could not get Seerr session', { error: e.message }); log.error('arr', 'Could not get Seerr session', { error: e.message });
return null; return null;
} }
} }
@@ -123,7 +132,7 @@ module.exports = function(ctx) {
// Uses session cookie auth (Overseerr requires Plex-based admin session for settings) // Uses session cookie auth (Overseerr requires Plex-based admin session for settings)
async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) { async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) {
// 1. Get Plex server identity (for return info) // 1. Get Plex server identity (for return info)
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { const identityRes = await fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
@@ -139,7 +148,7 @@ module.exports = function(ctx) {
useSsl: false useSsl: false
}; };
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { const configRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -154,19 +163,19 @@ module.exports = function(ctx) {
// 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries // 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries
try { try {
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { await fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST', method: 'POST',
headers: { 'Cookie': sessionCookie }, headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
} catch (e) { } catch (e) {
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message }); log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
} }
// 4. Get discovered libraries // 4. Get discovered libraries
let libraries = []; let libraries = [];
try { try {
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { const libRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie }, headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });
@@ -186,13 +195,13 @@ module.exports = function(ctx) {
// Check existing apps to avoid duplicates // Check existing apps to avoid duplicates
let existingApps = []; let existingApps = [];
try { try {
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { const existingRes = await fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey }, headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
existingApps = existingRes.ok ? await existingRes.json() : []; existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) { } catch (e) {
ctx.log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message }); log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message });
} }
for (const [appName, config] of Object.entries(apps)) { for (const [appName, config] of Object.entries(apps)) {
@@ -222,7 +231,7 @@ module.exports = function(ctx) {
}; };
try { try {
const res = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { const res = await fetchT(`${prowlarrUrl}/api/v1/applications`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -259,7 +268,7 @@ module.exports = function(ctx) {
} }
try { try {
const response = await ctx.fetchT(apiEndpoint, { const response = await fetchT(apiEndpoint, {
method: 'GET', method: 'GET',
headers, headers,
signal: AbortSignal.timeout(15000) signal: AbortSignal.timeout(15000)

View File

@@ -1,14 +1,34 @@
const express = require('express'); const express = require('express');
/**
* Arr routes aggregator
* Assembles all arr sub-routes with their dependencies
* @param {Object} ctx - Application context (for backward compatibility)
* @returns {express.Router}
*/
module.exports = function(ctx) { module.exports = function(ctx) {
const router = express.Router(); const router = express.Router();
const helpers = require('./helpers')(ctx);
router.use(require('./detect')(ctx, helpers)); // Extract dependencies from context
router.use(require('./credentials')(ctx, helpers)); const deps = {
router.use(require('./config')(ctx, helpers)); docker: ctx.docker,
router.use(require('./smart-connect')(ctx, helpers)); credentialManager: ctx.credentialManager,
router.use(require('./plex')(ctx, helpers)); servicesStateManager: ctx.servicesStateManager,
fetchT: ctx.fetchT,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log
};
// Initialize helpers with dependencies
const helpers = require('./helpers')(deps);
// Mount sub-routes with explicit dependencies
router.use(require('./detect')({ ...deps, helpers }));
router.use(require('./credentials')({ ...deps, helpers }));
router.use(require('./config')({ ...deps, helpers }));
router.use(require('./smart-connect')({ ...deps, helpers }));
router.use(require('./plex')({ ...deps, helpers }));
return router; return router;
}; };

View File

@@ -1,11 +1,21 @@
const express = require('express'); const express = require('express');
const { APP_PORTS } = require('../../constants'); const { APP_PORTS } = require('../../constants');
module.exports = function(ctx, helpers) { /**
* Plex routes factory
* @param {Object} deps - Explicit dependencies
* @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({ fetchT, asyncHandler, errorResponse, log, helpers }) {
const router = express.Router(); const router = express.Router();
// Plex Libraries endpoint // Plex Libraries endpoint
router.get('/plex/libraries', ctx.asyncHandler(async (req, res) => { router.get('/plex/libraries', asyncHandler(async (req, res) => {
// Get Plex token // Get Plex token
let plexToken = await helpers.getPlexToken('plex'); let plexToken = await helpers.getPlexToken('plex');
if (!plexToken) { if (!plexToken) {
@@ -13,7 +23,7 @@ module.exports = function(ctx, helpers) {
} }
if (!plexToken) { if (!plexToken) {
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', { return errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
hint: 'Deploy Plex with a claim token or manually configure it.' hint: 'Deploy Plex with a claim token or manually configure it.'
}); });
} }
@@ -30,13 +40,13 @@ module.exports = function(ctx, helpers) {
} catch (e) { /* use default */ } } catch (e) { /* use default */ }
// Fetch libraries // Fetch libraries
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, { const libRes = await fetchT(`${plexUrl}/library/sections`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
if (!libRes.ok) { if (!libRes.ok) {
return ctx.errorResponse(res, 502, `Plex returned ${libRes.status}`); return errorResponse(res, 502, `Plex returned ${libRes.status}`);
} }
const data = await libRes.json(); const data = await libRes.json();
@@ -52,7 +62,7 @@ module.exports = function(ctx, helpers) {
let serverName = 'Plex'; let serverName = 'Plex';
let version = null; let version = null;
try { try {
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { const identityRes = await fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });

View File

@@ -1,11 +1,22 @@
const express = require('express'); const express = require('express');
const { APP_PORTS } = require('../../constants'); const { APP_PORTS } = require('../../constants');
module.exports = function(ctx, helpers) { /**
* Arr smart-connect routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.credentialManager - Credential manager
* @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, fetchT, asyncHandler, errorResponse, log, helpers }) {
const router = express.Router(); const router = express.Router();
// Smart Connect: Unified orchestration endpoint // Smart Connect: Unified orchestration endpoint
router.post('/arr/smart-connect', ctx.asyncHandler(async (req, res) => { router.post('/arr/smart-connect', asyncHandler(async (req, res) => {
const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body; const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body;
const steps = []; const steps = [];
const connectedServices = {}; // { radarr: { url, apiKey }, sonarr: { url, apiKey }, ... } const connectedServices = {}; // { radarr: { url, apiKey }, sonarr: { url, apiKey }, ... }
@@ -20,9 +31,9 @@ module.exports = function(ctx, helpers) {
// Fallback to stored credentials // Fallback to stored credentials
if (!apiKey) { if (!apiKey) {
const credKey = `arr.${svc}.apikey`; const credKey = `arr.${svc}.apikey`;
apiKey = await ctx.credentialManager.retrieve(credKey); apiKey = await credentialManager.retrieve(credKey);
if (!url) { if (!url) {
const metadata = await ctx.credentialManager.getMetadata(credKey); const metadata = await credentialManager.getMetadata(credKey);
url = metadata?.url; url = metadata?.url;
} }
} }
@@ -52,7 +63,7 @@ module.exports = function(ctx, helpers) {
// Save credentials // Save credentials
if (saveCredentials) { if (saveCredentials) {
const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, { const stored = await credentialManager.store(`arr.${svc}.apikey`, apiKey, {
service: svc, source: 'external', url, service: svc, source: 'external', url,
lastVerified: new Date().toISOString(), lastVerified: new Date().toISOString(),
version: test.version version: test.version
@@ -71,7 +82,7 @@ module.exports = function(ctx, helpers) {
let plexUrl = null; let plexUrl = null;
if (configurePlex) { if (configurePlex) {
plexToken = await helpers.getPlexToken('plex'); plexToken = await helpers.getPlexToken('plex');
if (!plexToken) plexToken = await ctx.credentialManager.retrieve('arr.plex.token'); if (!plexToken) plexToken = await credentialManager.retrieve('arr.plex.token');
if (plexToken) { if (plexToken) {
// Get Plex URL // Get Plex URL
@@ -108,14 +119,14 @@ module.exports = function(ctx, helpers) {
const radarrBasePath = radarrUrlObj.pathname.replace(/\/+$/, ''); const radarrBasePath = radarrUrlObj.pathname.replace(/\/+$/, '');
// Fetch quality profiles // Fetch quality profiles
const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, { const profilesRes = await fetchT(`${radarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
// Use stored quality profile preference, fallback to first profile // Use stored quality profile preference, fallback to first profile
const radarrMeta = await ctx.credentialManager.getMetadata('arr.radarr.apikey'); const radarrMeta = await credentialManager.getMetadata('arr.radarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' }; let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (radarrMeta?.qualityProfileId) { if (radarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === radarrMeta.qualityProfileId); const stored = profiles.find(p => p.id === radarrMeta.qualityProfileId);
@@ -123,7 +134,7 @@ module.exports = function(ctx, helpers) {
} }
// Fetch root folders // Fetch root folders
const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await fetchT(`${radarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
@@ -151,7 +162,7 @@ module.exports = function(ctx, helpers) {
tags: [] tags: []
}; };
const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { const radarrRes = await fetchT(`${overseerrUrl}/api/v1/settings/radarr`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(radarrConfig), body: JSON.stringify(radarrConfig),
@@ -175,21 +186,21 @@ module.exports = function(ctx, helpers) {
const sonarrUrlObj = new URL(sonarrUrl); const sonarrUrlObj = new URL(sonarrUrl);
const sonarrBasePath = sonarrUrlObj.pathname.replace(/\/+$/, ''); const sonarrBasePath = sonarrUrlObj.pathname.replace(/\/+$/, '');
const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, { const profilesRes = await fetchT(`${sonarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
const profiles = profilesRes.ok ? await profilesRes.json() : []; const profiles = profilesRes.ok ? await profilesRes.json() : [];
// Use stored quality profile preference, fallback to first profile // Use stored quality profile preference, fallback to first profile
const sonarrMeta = await ctx.credentialManager.getMetadata('arr.sonarr.apikey'); const sonarrMeta = await credentialManager.getMetadata('arr.sonarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' }; let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (sonarrMeta?.qualityProfileId) { if (sonarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === sonarrMeta.qualityProfileId); const stored = profiles.find(p => p.id === sonarrMeta.qualityProfileId);
if (stored) defaultProfile = stored; if (stored) defaultProfile = stored;
} }
const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { const rootFoldersRes = await fetchT(`${sonarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000) signal: AbortSignal.timeout(10000)
}); });
@@ -198,7 +209,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1; let languageProfileId = 1;
try { try {
const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, { const langRes = await fetchT(`${sonarrUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(5000) signal: AbortSignal.timeout(5000)
}); });
@@ -229,7 +240,7 @@ module.exports = function(ctx, helpers) {
tags: [] tags: []
}; };
const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { const sonarrRes = await fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(sonarrConfig), body: JSON.stringify(sonarrConfig),