const express = require('express'); const { APP_PORTS } = require('../../constants'); module.exports = function(ctx, helpers) { const router = express.Router(); // Smart Connect: Unified orchestration endpoint router.post('/arr/smart-connect', ctx.asyncHandler(async (req, res) => { const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body; const steps = []; const connectedServices = {}; // { radarr: { url, apiKey }, sonarr: { url, apiKey }, ... } // Phase 1: Test all provided services and resolve credentials const arrServices = ['radarr', 'sonarr', 'prowlarr']; for (const svc of arrServices) { const input = inputServices?.[svc]; let apiKey = input?.apiKey; let url = input?.url; // Fallback to stored credentials if (!apiKey) { const credKey = `arr.${svc}.apikey`; apiKey = await ctx.credentialManager.retrieve(credKey); if (!url) { const metadata = await ctx.credentialManager.getMetadata(credKey); url = metadata?.url; } } // Fallback URL from services.json if (!url && apiKey) { try { const data = await ctx.servicesStateManager.read(); const svcList = Array.isArray(data) ? data : data.services || []; const found = svcList.find(s => s.id === svc && s.isExternal); if (found?.externalUrl) url = found.externalUrl; } catch (e) { /* ignore */ } } if (!apiKey || !url) continue; // Test connection const test = await helpers.testServiceConnection(svc, url, apiKey); steps.push({ step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`, status: test.success ? 'success' : 'failed', details: test.success ? `v${test.version}` : test.error }); if (test.success) { connectedServices[svc] = { url, apiKey }; // Save credentials if (saveCredentials) { const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, { service: svc, source: 'external', url, lastVerified: new Date().toISOString(), version: test.version }); steps.push({ step: `Save ${svc} credentials`, status: stored ? 'success' : 'failed', details: stored ? 'Encrypted and saved' : 'Storage failed' }); } } } // Phase 2: Handle Plex let plexToken = null; let plexUrl = null; if (configurePlex) { plexToken = await helpers.getPlexToken('plex'); if (!plexToken) plexToken = await ctx.credentialManager.retrieve('arr.plex.token'); if (plexToken) { // Get Plex URL plexUrl = `http://host.docker.internal:${APP_PORTS.plex}`; try { const data = await ctx.servicesStateManager.read(); const svcList = Array.isArray(data) ? data : data.services || []; const plexSvc = svcList.find(s => s.id === 'plex' || s.appTemplate === 'plex'); if (plexSvc?.url) plexUrl = plexSvc.url; } catch (e) { /* use default */ } } } // Phase 3: Configure Overseerr (uses Plex-based session auth) if (configureSeerr && (connectedServices.radarr || connectedServices.sonarr || (configurePlex && plexToken))) { const overseerrSession = await helpers.getOverseerrSession(); const overseerrUrl = `http://host.docker.internal:${APP_PORTS.seerr}`; if (!overseerrSession) { steps.push({ step: 'Get Overseerr API key', status: 'failed', details: 'Could not authenticate with Overseerr (Plex not running or not linked)' }); } else { steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' }); const overseerrCookie = overseerrSession.cookie; // Configure Radarr in Overseerr if (connectedServices.radarr) { try { const radarrUrl = connectedServices.radarr.url.replace(/\/+$/, ''); const radarrUrlObj = new URL(radarrUrl); const radarrBasePath = radarrUrlObj.pathname.replace(/\/+$/, ''); // Fetch quality profiles const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; // Fetch root folders const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.radarr.apiKey }, signal: AbortSignal.timeout(10000) }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/movies'; // Seerr runs in Docker — localhost/127.0.0.1 won't reach sibling containers const radarrHost = ['localhost', '127.0.0.1'].includes(radarrUrlObj.hostname) ? 'host.docker.internal' : radarrUrlObj.hostname; const radarrConfig = { name: 'Radarr', hostname: radarrHost, port: parseInt(radarrUrlObj.port) || (radarrUrlObj.protocol === 'https:' ? 443 : APP_PORTS.radarr), apiKey: connectedServices.radarr.apiKey, useSsl: radarrUrlObj.protocol === 'https:', baseUrl: radarrBasePath || '', activeProfileId: defaultProfile.id, activeProfileName: defaultProfile.name, activeDirectory: defaultRootFolder, is4k: false, minimumAvailability: 'released', isDefault: true, externalUrl: connectedServices.radarr.url, tags: [] }; const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(radarrConfig), signal: AbortSignal.timeout(10000) }); steps.push({ step: 'Configure Radarr in Overseerr', status: radarrRes.ok ? 'success' : 'failed', details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text() }); } catch (e) { steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message }); } } // Configure Sonarr in Overseerr if (connectedServices.sonarr) { try { const sonarrUrl = connectedServices.sonarr.url.replace(/\/+$/, ''); const sonarrUrlObj = new URL(sonarrUrl); const sonarrBasePath = sonarrUrlObj.pathname.replace(/\/+$/, ''); const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, signal: AbortSignal.timeout(10000) }); const profiles = profilesRes.ok ? await profilesRes.json() : []; const defaultProfile = profiles[0] || { id: 1, name: 'Any' }; const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, signal: AbortSignal.timeout(10000) }); const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : []; const defaultRootFolder = rootFolders[0]?.path || '/tv'; let languageProfileId = 1; try { const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, { headers: { 'X-Api-Key': connectedServices.sonarr.apiKey }, signal: AbortSignal.timeout(5000) }); if (langRes.ok) { const langProfiles = await langRes.json(); languageProfileId = langProfiles[0]?.id || 1; } } catch (e) { /* Sonarr v4 doesn't need this */ } const sonarrHost = ['localhost', '127.0.0.1'].includes(sonarrUrlObj.hostname) ? 'host.docker.internal' : sonarrUrlObj.hostname; const sonarrConfig = { name: 'Sonarr', hostname: sonarrHost, port: parseInt(sonarrUrlObj.port) || (sonarrUrlObj.protocol === 'https:' ? 443 : APP_PORTS.sonarr), apiKey: connectedServices.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: connectedServices.sonarr.url, tags: [] }; const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie }, body: JSON.stringify(sonarrConfig), signal: AbortSignal.timeout(10000) }); steps.push({ step: 'Configure Sonarr in Overseerr', status: sonarrRes.ok ? 'success' : 'failed', details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text() }); } catch (e) { steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message }); } } // Connect Plex to Overseerr if (configurePlex && plexToken) { try { const plexResult = await helpers.connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, overseerrCookie); steps.push({ step: 'Connect Plex to Overseerr', status: 'success', details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced` }); } catch (e) { steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message }); } } } } // Phase 4: Configure Prowlarr if (configureProwlarr && connectedServices.prowlarr) { const appsToConnect = {}; if (connectedServices.radarr) appsToConnect.radarr = connectedServices.radarr; if (connectedServices.sonarr) appsToConnect.sonarr = connectedServices.sonarr; if (Object.keys(appsToConnect).length > 0) { try { const prowlarrResults = await helpers.configureProwlarrApps( connectedServices.prowlarr.url.replace(/\/+$/, ''), connectedServices.prowlarr.apiKey, appsToConnect ); for (const [app, status] of Object.entries(prowlarrResults)) { steps.push({ step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`, status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed', details: status }); } } catch (e) { steps.push({ step: 'Configure Prowlarr apps', status: 'failed', details: e.message }); } } } // Summary const succeeded = steps.filter(s => s.status === 'success').length; const failed = steps.filter(s => s.status === 'failed').length; if (succeeded > 0) { ctx.notification.send( 'deploymentSuccess', 'Smart Arr Connect Complete', `${succeeded}/${steps.length} steps completed successfully`, 'success' ); } res.json({ success: succeeded > 0, steps, summary: { totalSteps: steps.length, succeeded, failed } }); }, 'smart-connect')); return router; };