const express = require('express'); const { APP_PORTS, ARR_SERVICES } = require('../../constants'); const { validateURL, validateToken } = require('../../input-validator'); const { ValidationError, AuthenticationError, NotFoundError } = require('../errors'); module.exports = function(ctx, helpers) { const router = express.Router(); // Auto-configure Overseerr with detected services router.post('/arr/configure-overseerr', ctx.asyncHandler(async (req, res) => { const { radarr, sonarr } = req.body; const results = { radarr: null, sonarr: null }; // Step 1: Authenticate with Overseerr via Plex token let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`; const overseerrSession = await helpers.getOverseerrSession(); if (!overseerrSession) { return ctx.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'); // Helper to make authenticated requests to Overseerr const overseerrFetch = async (endpoint, options = {}) => { const url = `${overseerrUrl}${endpoint}`; const response = await ctx.fetchT(url, { ...options, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, ...options.headers } }); return response; }; // Step 2: Verify Overseerr is accessible try { const statusRes = await overseerrFetch('/api/v1/status'); if (!statusRes.ok) { return ctx.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}`, { hint: 'Check if Overseerr container is running' }); } // Step 3: Configure Radarr if provided if (radarr?.apiKey && radarr?.url) { try { const radarrUrlObj = new URL(radarr.url); const radarrBasePath = radarrUrlObj.pathname.replace(/\/+$/, ''); const radarrBaseUrl = radarr.url.replace(/\/+$/, ''); // Fetch quality profiles from Radarr const profilesRes = await ctx.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`, { 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 }); const radarrConfig = { name: 'Radarr', hostname: radarrUrlObj.hostname, port: parseInt(radarrUrlObj.port) || (radarrUrlObj.protocol === 'https:' ? 443 : APP_PORTS.radarr), apiKey: radarr.apiKey, useSsl: radarrUrlObj.protocol === 'https:', baseUrl: radarrBasePath || '', activeProfileId: defaultProfile.id, activeProfileName: defaultProfile.name, activeDirectory: defaultRootFolder, is4k: false, minimumAvailability: 'released', isDefault: true, externalUrl: radarr.url, tags: [] }; const radarrRes = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', body: JSON.stringify(radarrConfig) }); if (radarrRes.ok) { results.radarr = 'configured'; } else { const errorText = await radarrRes.text(); results.radarr = `failed: ${errorText}`; } } catch (e) { results.radarr = `error: ${e.message}`; } } // Step 4: Configure Sonarr if provided if (sonarr?.apiKey && sonarr?.url) { try { const sonarrUrlObj = new URL(sonarr.url); const sonarrBasePath = sonarrUrlObj.pathname.replace(/\/+$/, ''); const sonarrBaseUrl = sonarr.url.replace(/\/+$/, ''); // Fetch quality profiles from Sonarr const profilesRes = await ctx.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`, { headers: { 'X-Api-Key': sonarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; // 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`, { headers: { 'X-Api-Key': sonarr.apiKey } }); if (langRes.ok) { const langProfiles = await langRes.json(); languageProfileId = langProfiles[0]?.id || 1; } } catch (e) { // Language profiles might not exist in Sonarr v4 } ctx.log.info('arr', 'Sonarr configured', { profile: defaultProfile.name, profileId: defaultProfile.id, rootFolder: defaultRootFolder }); const sonarrConfig = { name: 'Sonarr', hostname: sonarrUrlObj.hostname, port: parseInt(sonarrUrlObj.port) || (sonarrUrlObj.protocol === 'https:' ? 443 : APP_PORTS.sonarr), apiKey: sonarr.apiKey, useSsl: sonarrUrlObj.protocol === 'https:', baseUrl: sonarrBasePath || '', activeProfileId: defaultProfile.id, activeProfileName: defaultProfile.name, activeDirectory: defaultRootFolder, activeLanguageProfileId: languageProfileId, is4k: false, isDefault: true, enableSeasonFolders: true, externalUrl: sonarr.url, tags: [] }; const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', body: JSON.stringify(sonarrConfig) }); if (sonarrRes.ok) { results.sonarr = 'configured'; } else { const errorText = await sonarrRes.text(); results.sonarr = `failed: ${errorText}`; } } catch (e) { results.sonarr = `error: ${e.message}`; } } const anyConfigured = results.radarr === 'configured' || results.sonarr === 'configured'; res.json({ success: anyConfigured, message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed', results }); }, 'arr-configure-overseerr')); // Test connection to external Radarr/Sonarr service router.post('/arr/test-connection', ctx.asyncHandler(async (req, res) => { try { const { service, url, apiKey } = req.body; if (!url || !apiKey) { throw new ValidationError('URL and API key required'); } // Validate URL format try { validateURL(url); } catch (validationErr) { return ctx.errorResponse(res, 400, validationErr.message); } // Validate API key format try { validateToken(apiKey); } catch (validationErr) { throw new ValidationError('Invalid API key format'); } // Normalize URL - remove trailing slash let baseUrl = url.replace(/\/+$/, ''); // Build the API endpoint let apiEndpoint; let headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' }; if (service === 'radarr' || service === 'sonarr' || service === 'lidarr') { apiEndpoint = `${baseUrl}/api/v3/system/status`; } else if (service === 'prowlarr') { apiEndpoint = `${baseUrl}/api/v1/system/status`; } else if (service === 'plex') { apiEndpoint = `${baseUrl}/identity`; headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' }; } else { return ctx.errorResponse(res, 400, `Unknown service: ${service}`); } ctx.log.info('arr', 'Testing service connection', { service }); // Make the API call const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, signal: AbortSignal.timeout(10000) }); if (response.ok) { 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 }); return res.json({ success: true, version, appName }); } else if (response.status === 401) { throw new AuthenticationError('Invalid API key'); } else if (response.status === 404) { throw new NotFoundError('API not found - check URL'); } else { return ctx.errorResponse(res, 502, `HTTP ${response.status}`); } } catch (error) { await ctx.logError('arr-test-connection', error); if (error.cause?.code === 'ECONNREFUSED') { return ctx.errorResponse(res, 502, 'Connection refused'); } else if (error.name === 'AbortError' || error.message?.includes('timeout')) { return ctx.errorResponse(res, 504, 'Connection timeout'); } return ctx.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'); // Step 1: Detect all running arr services const containers = await ctx.docker.client.listContainers({ all: false }); const detected = {}; const servicePatterns = ARR_SERVICES; for (const container of containers) { const containerName = container.Names[0]?.replace(/^\//, '').toLowerCase() || ''; const image = container.Image.toLowerCase(); for (const [service, config] of Object.entries(servicePatterns)) { if (config.names.some(n => containerName.includes(n) || image.includes(n))) { const portInfo = container.Ports.find(p => p.PrivatePort === config.port); const exposedPort = portInfo?.PublicPort || config.port; detected[service] = { containerId: container.Id, containerName: container.Names[0]?.replace(/^\//, ''), port: exposedPort, url: `http://host.docker.internal:${exposedPort}`, localUrl: `http://localhost:${exposedPort}` }; // Extract API key for arr services if (['radarr', 'sonarr', 'lidarr', 'prowlarr'].includes(service)) { detected[service].apiKey = await helpers.getArrApiKey(containerName); } } } } // Step 2: Check what we found const summary = { overseerrFound: !!detected.overseerr, radarrFound: !!detected.radarr?.apiKey, sonarrFound: !!detected.sonarr?.apiKey, lidarrFound: !!detected.lidarr?.apiKey, prowlarrFound: !!detected.prowlarr?.apiKey }; ctx.log.info('arr', 'Detected services', summary); if (!summary.overseerrFound) { return ctx.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.', { detected, summary }); } // Step 3: Authenticate with Overseerr via Plex session const overseerrSession = await helpers.getOverseerrSession(); if (!overseerrSession) { return ctx.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'); // Helper for authenticated Overseerr requests const overseerrFetch = async (endpoint, options = {}) => { return ctx.fetchT(`${detected.overseerr.url}${endpoint}`, { ...options, headers: { 'Content-Type': 'application/json', 'Cookie': overseerrSession.cookie, ...options.headers } }); }; // Step 4: Configure Radarr in Overseerr const configResults = {}; if (detected.radarr?.apiKey) { try { // Fetch quality profiles from Radarr const profilesRes = await ctx.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`, { 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 }); const radarrConfig = { name: 'Radarr', hostname: 'host.docker.internal', port: detected.radarr.port, apiKey: detected.radarr.apiKey, useSsl: false, baseUrl: '', activeProfileId: defaultProfile.id, activeProfileName: defaultProfile.name, activeDirectory: defaultRootFolder, is4k: false, minimumAvailability: 'released', isDefault: true, externalUrl: detected.radarr.localUrl, tags: [] }; const resp = await overseerrFetch('/api/v1/settings/radarr', { method: 'POST', body: JSON.stringify(radarrConfig) }); configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; } catch (e) { configResults.radarr = `error: ${e.message}`; } } // Step 5: Configure Sonarr in Overseerr if (detected.sonarr?.apiKey) { try { // Fetch quality profiles from Sonarr const profilesRes = await ctx.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`, { headers: { 'X-Api-Key': detected.sonarr.apiKey } }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; // Fetch language profiles (Sonarr v3) let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, { headers: { 'X-Api-Key': detected.sonarr.apiKey } }); if (langRes.ok) { const langProfiles = await langRes.json(); languageProfileId = langProfiles[0]?.id || 1; } } catch (e) { /* Sonarr v4 doesn't need this */ } ctx.log.info('arr', 'Sonarr profile selected', { profile: defaultProfile.name, rootFolder: defaultRootFolder }); const sonarrConfig = { name: 'Sonarr', hostname: 'host.docker.internal', port: detected.sonarr.port, apiKey: detected.sonarr.apiKey, useSsl: false, baseUrl: '', activeProfileId: defaultProfile.id, activeProfileName: defaultProfile.name, activeDirectory: defaultRootFolder, activeLanguageProfileId: languageProfileId, is4k: false, isDefault: true, enableSeasonFolders: true, externalUrl: detected.sonarr.localUrl, tags: [] }; const resp = await overseerrFetch('/api/v1/settings/sonarr', { method: 'POST', body: JSON.stringify(sonarrConfig) }); configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`; } catch (e) { configResults.sonarr = `error: ${e.message}`; } } const anyConfigured = configResults.radarr === 'configured' || configResults.sonarr === 'configured'; // Send notification if (anyConfigured) { ctx.notification.send( 'deploymentSuccess', 'Arr Stack Auto-Connected', `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`, 'success' ); } res.json({ success: anyConfigured, message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed', detected, configResults, summary }); }, 'arr-auto-setup')); // Fetch quality profiles from an arr service (Radarr/Sonarr) router.get('/arr/quality-profiles', ctx.asyncHandler(async (req, res) => { const { service, url, apiKey } = req.query; if (!service || !['radarr', 'sonarr'].includes(service)) { throw new ValidationError('Service must be radarr or sonarr'); } // Resolve API key: from query param, or from stored credentials let resolvedKey = apiKey; let resolvedUrl = url; if (!resolvedKey) { resolvedKey = await ctx.credentialManager.retrieve(`arr.${service}.apikey`); } if (!resolvedKey) { resolvedKey = await ctx.credentialManager.retrieve(`service.${service}.apikey`); } if (!resolvedUrl) { const metadata = await ctx.credentialManager.getMetadata(`arr.${service}.apikey`); resolvedUrl = metadata?.url; } if (!resolvedUrl) { try { const services = await ctx.servicesStateManager.read(); const svcList = Array.isArray(services) ? services : services.services || []; const found = svcList.find(s => s.id === service); if (found?.externalUrl) resolvedUrl = found.externalUrl; else if (found?.url) resolvedUrl = found.url; } catch (e) { /* ignore */ } } if (!resolvedKey || !resolvedUrl) { throw new ValidationError('Could not resolve API key or URL for this service'); } const baseUrl = resolvedUrl.replace(/\/+$/, ''); try { const profilesRes = await ctx.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, profilesRes.status === 401 ? 'Invalid API key' : `Failed to fetch profiles (HTTP ${profilesRes.status})`); } const profiles = await profilesRes.json(); 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 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?'); } if (e.name === 'AbortError') { return ctx.errorResponse(res, 504, 'Connection timeout'); } return ctx.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) => { const { service, qualityProfileId, qualityProfileName } = req.body; if (!service || !['radarr', 'sonarr'].includes(service)) { throw new ValidationError('Service must be radarr or sonarr'); } if (!qualityProfileId) { throw new ValidationError('qualityProfileId required'); } const credKey = `arr.${service}.apikey`; const existing = await ctx.credentialManager.getMetadata(credKey); if (!existing) { throw new NotFoundError('No stored credentials for this service'); } // Merge quality profile into existing metadata existing.qualityProfileId = qualityProfileId; existing.qualityProfileName = qualityProfileName || null; await ctx.credentialManager.storeMetadata(credKey, existing); res.json({ success: true, message: `Quality profile updated for ${service}` }); }, 'arr-quality-profile-save')); return router; };