Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
283
dashcaddy-api/routes/arr/detect.js
Normal file
283
dashcaddy-api/routes/arr/detect.js
Normal file
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user