diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index 9c74ba4..a949e1b 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -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')); diff --git a/dashcaddy-api/routes/arr/credentials.js b/dashcaddy-api/routes/arr/credentials.js index 4540c8c..17cd22c 100644 --- a/dashcaddy-api/routes/arr/credentials.js +++ b/dashcaddy-api/routes/arr/credentials.js @@ -2,11 +2,22 @@ const express = require('express'); const { validateURL, validateToken } = require('../../input-validator'); 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(); // 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; if (!service || !apiKey) { @@ -15,7 +26,7 @@ module.exports = function(ctx, helpers) { const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex']; 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 @@ -50,7 +61,7 @@ module.exports = function(ctx, helpers) { if (!resolvedUrl) { // Try to resolve URL from services.json try { - const services = await ctx.servicesStateManager.read(); + const services = await servicesStateManager.read(); const svc = Array.isArray(services) ? services : services.services || []; const found = svc.find(s => s.id === service && s.isExternal); if (found?.externalUrl) resolvedUrl = found.externalUrl; @@ -73,9 +84,9 @@ module.exports = function(ctx, helpers) { } // Store the credential - const stored = await ctx.credentialManager.store(credKey, apiKey, metadata); + const stored = await credentialManager.store(credKey, apiKey, metadata); if (!stored) { - return ctx.errorResponse(res, 500, 'Failed to store credential'); + return errorResponse(res, 500, 'Failed to store credential'); } // Optionally store seedbox base URL @@ -83,12 +94,12 @@ module.exports = function(ctx, helpers) { try { validateURL(seedboxBaseUrl); } catch (e) { 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() }); } - 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({ success: true, @@ -99,14 +110,14 @@ module.exports = function(ctx, helpers) { }, 'arr-credentials-store')); // 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 credentials = {}; for (const service of services) { const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; - const hasKey = !!(await ctx.credentialManager.retrieve(credKey)); - const metadata = await ctx.credentialManager.getMetadata(credKey); + const hasKey = !!(await credentialManager.retrieve(credKey)); + const metadata = await credentialManager.getMetadata(credKey); credentials[service] = { hasKey, @@ -118,17 +129,17 @@ module.exports = function(ctx, helpers) { } // 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 }); }, 'arr-credentials-list')); // 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 credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`; - await ctx.credentialManager.delete(credKey); - ctx.log.info('arr', 'Deleted credentials', { service }); + await credentialManager.delete(credKey); + log.info('arr', 'Deleted credentials', { service }); res.json({ success: true, message: `${service} credentials removed` }); }, 'arr-credentials-delete')); diff --git a/dashcaddy-api/routes/arr/detect.js b/dashcaddy-api/routes/arr/detect.js index 5af17ce..3bd0ed1 100644 --- a/dashcaddy-api/routes/arr/detect.js +++ b/dashcaddy-api/routes/arr/detect.js @@ -1,12 +1,23 @@ const express = require('express'); 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(); // Detect running arr services and their configurations - router.get('/arr/detect', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: false }); + router.get('/arr/detect', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: false }); const detected = { plex: null, radarr: null, @@ -64,14 +75,14 @@ module.exports = function(ctx, helpers) { }, 'arr-detect')); // 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 defaultPorts = APP_PORTS; const result = {}; // 1. Scan Docker 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; @@ -95,18 +106,18 @@ module.exports = function(ctx, helpers) { // 2. Load services.json for external entries let storedServices = []; try { - const data = await ctx.servicesStateManager.read(); + const data = await servicesStateManager.read(); storedServices = Array.isArray(data) ? data : data.services || []; } catch (e) { /* ignore */ } // 3. Load stored credentials const storedCreds = {}; - const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl'); + const seedboxBaseUrl = await credentialManager.retrieve('arr.seedbox.baseurl'); for (const svc of serviceList) { const credKey = svc === 'plex' ? 'arr.plex.token' : `arr.${svc}.apikey`; - const apiKey = await ctx.credentialManager.retrieve(credKey); - const metadata = await ctx.credentialManager.getMetadata(credKey); + const apiKey = await credentialManager.retrieve(credKey); + const metadata = await credentialManager.getMetadata(credKey); if (apiKey) { storedCreds[svc] = { apiKey, metadata }; } @@ -141,7 +152,7 @@ module.exports = function(ctx, helpers) { entry.hasToken = true; entry.status = 'connected'; // 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, lastVerified: new Date().toISOString() }); @@ -158,7 +169,7 @@ module.exports = function(ctx, helpers) { entry.hasApiKey = true; const configuredServices = { radarr: false, sonarr: false, plex: false }; 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 }, signal: AbortSignal.timeout(5000) }); @@ -168,7 +179,7 @@ module.exports = function(ctx, helpers) { } } catch (e) { /* ignore */ } 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 }, signal: AbortSignal.timeout(5000) }); @@ -178,7 +189,7 @@ module.exports = function(ctx, helpers) { } } catch (e) { /* ignore */ } 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 }, signal: AbortSignal.timeout(5000) }); diff --git a/dashcaddy-api/routes/arr/helpers.js b/dashcaddy-api/routes/arr/helpers.js index 2936f51..312ccdf 100644 --- a/dashcaddy-api/routes/arr/helpers.js +++ b/dashcaddy-api/routes/arr/helpers.js @@ -1,14 +1,23 @@ 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 async function getArrApiKey(containerName) { try { - const container = await ctx.docker.findContainer(containerName); + const container = await docker.findContainer(containerName); if (!container) return null; - const dockerContainer = ctx.docker.client.getContainer(container.Id); + const dockerContainer = docker.client.getContainer(container.Id); const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/config.xml'], AttachStdout: true, @@ -28,7 +37,7 @@ module.exports = function(ctx) { stream.on('error', () => resolve(null)); }); } 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; } } @@ -36,14 +45,14 @@ module.exports = function(ctx) { // Helper: Get Plex token from container or config async function getPlexToken(containerName) { try { - const containers = await ctx.docker.client.listContainers({ all: false }); + const containers = await docker.client.listContainers({ all: false }); const container = containers.find(c => c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) ); if (!container) return null; - const dockerContainer = ctx.docker.client.getContainer(container.Id); + const dockerContainer = docker.client.getContainer(container.Id); const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], AttachStdout: true, @@ -62,7 +71,7 @@ module.exports = function(ctx) { stream.on('error', () => resolve(null)); }); } 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; } } @@ -84,16 +93,16 @@ module.exports = function(ctx) { // Fall back to stored Plex token in credential manager if (!plexToken) { - plexToken = await ctx.credentialManager.retrieve('arr.plex.token'); + plexToken = await credentialManager.retrieve('arr.plex.token'); } 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; } // 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', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authToken: plexToken }), @@ -101,20 +110,20 @@ module.exports = function(ctx) { }); 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; } const setCookie = authRes.headers.get('set-cookie'); if (!setCookie) { - ctx.log.error('arr', 'No session cookie returned from Seerr'); + log.error('arr', 'No session cookie returned from Seerr'); return null; } const sessionCookie = setCookie.split(';')[0]; return { cookie: sessionCookie, plexToken }; } 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; } } @@ -123,7 +132,7 @@ module.exports = function(ctx) { // Uses session cookie auth (Overseerr requires Plex-based admin session for settings) async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) { // 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' }, signal: AbortSignal.timeout(10000) }); @@ -139,7 +148,7 @@ module.exports = function(ctx) { useSsl: false }; - const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { + const configRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, { method: 'POST', headers: { '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 try { - await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { + await fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { method: 'POST', headers: { 'Cookie': sessionCookie }, signal: AbortSignal.timeout(10000) }); } 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 let libraries = []; try { - const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { + const libRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, { headers: { 'Cookie': sessionCookie }, signal: AbortSignal.timeout(5000) }); @@ -186,13 +195,13 @@ module.exports = function(ctx) { // Check existing apps to avoid duplicates let existingApps = []; try { - const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { + const existingRes = await fetchT(`${prowlarrUrl}/api/v1/applications`, { headers: { 'X-Api-Key': prowlarrApiKey }, signal: AbortSignal.timeout(10000) }); existingApps = existingRes.ok ? await existingRes.json() : []; } 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)) { @@ -222,7 +231,7 @@ module.exports = function(ctx) { }; try { - const res = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { + const res = await fetchT(`${prowlarrUrl}/api/v1/applications`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -259,7 +268,7 @@ module.exports = function(ctx) { } try { - const response = await ctx.fetchT(apiEndpoint, { + const response = await fetchT(apiEndpoint, { method: 'GET', headers, signal: AbortSignal.timeout(15000) diff --git a/dashcaddy-api/routes/arr/index.js b/dashcaddy-api/routes/arr/index.js index 4caef22..49a4a66 100644 --- a/dashcaddy-api/routes/arr/index.js +++ b/dashcaddy-api/routes/arr/index.js @@ -1,14 +1,34 @@ 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) { const router = express.Router(); - const helpers = require('./helpers')(ctx); - router.use(require('./detect')(ctx, helpers)); - router.use(require('./credentials')(ctx, helpers)); - router.use(require('./config')(ctx, helpers)); - router.use(require('./smart-connect')(ctx, helpers)); - router.use(require('./plex')(ctx, helpers)); + // Extract dependencies from context + const deps = { + docker: ctx.docker, + credentialManager: ctx.credentialManager, + 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; }; diff --git a/dashcaddy-api/routes/arr/plex.js b/dashcaddy-api/routes/arr/plex.js index d351d23..ee0771d 100644 --- a/dashcaddy-api/routes/arr/plex.js +++ b/dashcaddy-api/routes/arr/plex.js @@ -1,11 +1,21 @@ const express = require('express'); 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(); // Plex Libraries endpoint - router.get('/plex/libraries', ctx.asyncHandler(async (req, res) => { + router.get('/plex/libraries', asyncHandler(async (req, res) => { // Get Plex token let plexToken = await helpers.getPlexToken('plex'); if (!plexToken) { @@ -13,7 +23,7 @@ module.exports = function(ctx, helpers) { } 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.' }); } @@ -30,13 +40,13 @@ module.exports = function(ctx, helpers) { } catch (e) { /* use default */ } // 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' }, signal: AbortSignal.timeout(10000) }); 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(); @@ -52,7 +62,7 @@ module.exports = function(ctx, helpers) { let serverName = 'Plex'; let version = null; try { - const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { + const identityRes = await fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, signal: AbortSignal.timeout(5000) }); diff --git a/dashcaddy-api/routes/arr/smart-connect.js b/dashcaddy-api/routes/arr/smart-connect.js index 0836c03..ce61b87 100644 --- a/dashcaddy-api/routes/arr/smart-connect.js +++ b/dashcaddy-api/routes/arr/smart-connect.js @@ -1,11 +1,22 @@ const express = require('express'); 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(); // 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 steps = []; const connectedServices = {}; // { radarr: { url, apiKey }, sonarr: { url, apiKey }, ... } @@ -20,9 +31,9 @@ module.exports = function(ctx, helpers) { // Fallback to stored credentials if (!apiKey) { const credKey = `arr.${svc}.apikey`; - apiKey = await ctx.credentialManager.retrieve(credKey); + apiKey = await credentialManager.retrieve(credKey); if (!url) { - const metadata = await ctx.credentialManager.getMetadata(credKey); + const metadata = await credentialManager.getMetadata(credKey); url = metadata?.url; } } @@ -52,7 +63,7 @@ module.exports = function(ctx, helpers) { // Save credentials 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, lastVerified: new Date().toISOString(), version: test.version @@ -71,7 +82,7 @@ module.exports = function(ctx, helpers) { let plexUrl = null; if (configurePlex) { 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) { // Get Plex URL @@ -108,14 +119,14 @@ module.exports = function(ctx, helpers) { const radarrBasePath = radarrUrlObj.pathname.replace(/\/+$/, ''); // 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 }, signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; // 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' }; if (radarrMeta?.qualityProfileId) { const stored = profiles.find(p => p.id === radarrMeta.qualityProfileId); @@ -123,7 +134,7 @@ module.exports = function(ctx, helpers) { } // 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 }, signal: AbortSignal.timeout(10000) }); @@ -151,7 +162,7 @@ module.exports = function(ctx, helpers) { tags: [] }; - const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { + const radarrRes = await fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(radarrConfig), @@ -175,21 +186,21 @@ module.exports = function(ctx, helpers) { const sonarrUrlObj = new URL(sonarrUrl); 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 }, signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; // 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' }; if (sonarrMeta?.qualityProfileId) { const stored = profiles.find(p => p.id === sonarrMeta.qualityProfileId); 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 }, signal: AbortSignal.timeout(10000) }); @@ -198,7 +209,7 @@ module.exports = function(ctx, helpers) { let languageProfileId = 1; 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 }, signal: AbortSignal.timeout(5000) }); @@ -229,7 +240,7 @@ module.exports = function(ctx, helpers) { tags: [] }; - const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { + const sonarrRes = await fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(sonarrConfig),