const express = require('express'); const { APP_PORTS, ARR_SERVICES } = require('../../constants'); module.exports = function(ctx, 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 }); const detected = { plex: null, radarr: null, sonarr: null, overseerr: null, lidarr: null, prowlarr: null }; // Service detection patterns 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))) { // Find the exposed port 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(/^\//, ''), image: container.Image, port: exposedPort, status: container.State, url: helpers.getServiceUrl(containerName, exposedPort) }; // Get API key for arr services (not Plex or Overseerr) if (['radarr', 'sonarr', 'lidarr', 'prowlarr'].includes(service)) { detected[service].apiKey = await helpers.getArrApiKey(containerName); } } } } // Get Plex token if Plex is detected if (detected.plex) { detected.plex.token = await helpers.getPlexToken(detected.plex.containerName); } res.json({ success: true, services: detected, summary: { plexReady: !!(detected.plex?.token), radarrReady: !!(detected.radarr?.apiKey), sonarrReady: !!(detected.sonarr?.apiKey), overseerrRunning: !!detected.overseerr } }); }, 'arr-detect')); // Smart Detect: Unified discovery of all arr services router.get('/arr/smart-detect', ctx.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 */ } const servicePatterns = ARR_SERVICES; const dockerDetected = {}; for (const container of containers) { const containerName = container.Names[0]?.replace(/^\//, '').toLowerCase() || ''; const image = container.Image.toLowerCase(); for (const [svc, 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); dockerDetected[svc] = { containerId: container.Id, containerName: container.Names[0]?.replace(/^\//, ''), port: portInfo?.PublicPort || config.port, status: container.State }; } } } // 2. Load services.json for external entries let storedServices = []; try { const data = await ctx.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'); 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); if (apiKey) { storedCreds[svc] = { apiKey, metadata }; } } // 4. Build detection result for each service for (const svc of serviceList) { const entry = { status: 'not_found', source: null, url: null, hasApiKey: false, hasToken: false, containerId: null, containerName: null, version: null }; // Check Docker first if (dockerDetected[svc]) { const dc = dockerDetected[svc]; entry.containerId = dc.containerId; entry.containerName = dc.containerName; entry.source = 'local'; entry.url = `http://localhost:${dc.port}`; if (svc === 'plex') { // Try to get Plex token from container try { const token = await helpers.getPlexToken(dc.containerName); if (token) { entry.hasToken = true; entry.status = 'connected'; // Store for later use await ctx.credentialManager.store('arr.plex.token', token, { service: 'plex', source: 'local', url: entry.url, lastVerified: new Date().toISOString() }); } else { entry.status = 'needs_key'; } } catch (e) { entry.status = 'needs_key'; } } else if (svc === 'seerr') { entry.status = 'connected'; // Check what Overseerr has configured using Plex-based session auth try { const session = await helpers.getOverseerrSession(); if (session) { 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`, { headers: { 'Cookie': session.cookie }, signal: AbortSignal.timeout(5000) }); if (radarrCheck.ok) { const radarrSettings = await radarrCheck.json(); configuredServices.radarr = Array.isArray(radarrSettings) ? radarrSettings.length > 0 : !!radarrSettings; } } catch (e) { /* ignore */ } try { const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, { headers: { 'Cookie': session.cookie }, signal: AbortSignal.timeout(5000) }); if (sonarrCheck.ok) { const sonarrSettings = await sonarrCheck.json(); configuredServices.sonarr = Array.isArray(sonarrSettings) ? sonarrSettings.length > 0 : !!sonarrSettings; } } catch (e) { /* ignore */ } try { const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, { headers: { 'Cookie': session.cookie }, signal: AbortSignal.timeout(5000) }); if (plexCheck.ok) { const plexSettings = await plexCheck.json(); configuredServices.plex = !!plexSettings?.ip; } } catch (e) { /* ignore */ } entry.configuredServices = configuredServices; } } catch (e) { /* ignore */ } } else { // arr services - try to get API key from container try { const key = await helpers.getArrApiKey(dc.containerName); if (key) { entry.hasApiKey = true; entry.status = 'connected'; } else { entry.status = storedCreds[svc] ? 'connected' : 'needs_key'; entry.hasApiKey = !!storedCreds[svc]; } } catch (e) { entry.status = storedCreds[svc] ? 'connected' : 'needs_key'; entry.hasApiKey = !!storedCreds[svc]; } } } // Check external services from services.json if (entry.status === 'not_found') { const externalService = storedServices.find(s => s.id === svc && s.isExternal); if (externalService?.externalUrl) { entry.source = 'external'; entry.url = externalService.externalUrl; if (storedCreds[svc]) { entry.hasApiKey = true; entry.version = storedCreds[svc].metadata?.version || null; // Verify connection is still good const test = await helpers.testServiceConnection(svc, entry.url, storedCreds[svc].apiKey); entry.status = test.success ? 'connected' : 'error'; if (test.success) entry.version = test.version; } else { entry.status = 'needs_key'; } } } // Check stored credentials with metadata URL if (entry.status === 'not_found' && storedCreds[svc]?.metadata?.url) { entry.source = 'stored'; entry.url = storedCreds[svc].metadata.url; entry.hasApiKey = true; entry.version = storedCreds[svc].metadata?.version || null; entry.status = 'connected'; } // For plex, also check stored token if (svc === 'plex' && entry.status === 'not_found' && storedCreds.plex) { entry.hasToken = true; entry.source = 'stored'; entry.url = storedCreds.plex.metadata?.url || `http://localhost:${defaultPorts.plex}`; entry.status = 'connected'; } result[svc] = entry; } // 5. Detect seedbox base URL pattern let detectedSeedboxUrl = seedboxBaseUrl || null; if (!detectedSeedboxUrl) { const externalUrls = storedServices .filter(s => s.isExternal && s.externalUrl) .map(s => s.externalUrl); if (externalUrls.length > 0) { // Find common base URL pattern try { const url = new URL(externalUrls[0]); const pathParts = url.pathname.split('/').filter(p => p); if (pathParts.length >= 2) { detectedSeedboxUrl = `${url.origin}/${pathParts[0]}`; } } catch (e) { /* ignore */ } } } // Summary const statuses = Object.values(result); const summary = { totalDetected: statuses.filter(s => s.status !== 'not_found').length, fullyConnected: statuses.filter(s => s.status === 'connected').length, needsApiKey: statuses.filter(s => s.status === 'needs_key').length, errors: statuses.filter(s => s.status === 'error').length, readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2 }; res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary }); }, 'smart-detect')); return router; };