Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
284 lines
11 KiB
JavaScript
284 lines
11 KiB
JavaScript
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;
|
|
};
|