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:
2026-03-05 02:26:12 -08:00
commit f61e85d9a7
337 changed files with 75282 additions and 0 deletions

View File

@@ -0,0 +1,483 @@
const express = require('express');
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { validateURL, validateToken } = require('../../input-validator');
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) {
return ctx.errorResponse(res, 400, '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) {
return ctx.errorResponse(res, 400, '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) {
return ctx.errorResponse(res, 401, 'Invalid API key');
} else if (response.status === 404) {
return ctx.errorResponse(res, 404, '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'));
return router;
};

View File

@@ -0,0 +1,129 @@
const express = require('express');
const { validateURL, validateToken } = require('../../input-validator');
module.exports = function(ctx, helpers) {
const router = express.Router();
// Store arr service credentials
router.post('/arr/credentials', ctx.asyncHandler(async (req, res) => {
const { service, apiKey, url, seedboxBaseUrl } = req.body;
if (!service || !apiKey) {
return ctx.errorResponse(res, 400, 'Service name and API key required');
}
const validServices = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
if (!validServices.includes(service)) {
return ctx.errorResponse(res, 400, `Invalid service. Must be one of: ${validServices.join(', ')}`);
}
// Validate API key format
try {
validateToken(apiKey);
} catch (e) {
return ctx.errorResponse(res, 400, 'Invalid API key format');
}
// Validate URL if provided
if (url) {
try { validateURL(url); } catch (e) {
return ctx.errorResponse(res, 400, 'Invalid URL format');
}
}
// Determine credential key
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
// Build metadata
const metadata = {
service,
source: url ? 'external' : 'local',
url: url || null,
storedAt: new Date().toISOString()
};
// Test connection if URL is known
let connectionTest = null;
let resolvedUrl = url;
if (!resolvedUrl) {
// Try to resolve URL from services.json
try {
const services = await ctx.servicesStateManager.read();
const svc = Array.isArray(services) ? services : services.services || [];
const found = svc.find(s => s.id === service && s.isExternal);
if (found?.externalUrl) resolvedUrl = found.externalUrl;
} catch (e) { /* ignore */ }
}
if (resolvedUrl) {
connectionTest = await helpers.testServiceConnection(service, resolvedUrl, apiKey);
if (connectionTest.success) {
metadata.lastVerified = new Date().toISOString();
metadata.version = connectionTest.version;
metadata.url = resolvedUrl;
}
}
// Store the credential
const stored = await ctx.credentialManager.store(credKey, apiKey, metadata);
if (!stored) {
return ctx.errorResponse(res, 500, 'Failed to store credential');
}
// Optionally store seedbox base URL
if (seedboxBaseUrl) {
try { validateURL(seedboxBaseUrl); } catch (e) {
return ctx.errorResponse(res, 400, 'Invalid seedbox base URL');
}
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
storedAt: new Date().toISOString()
});
}
ctx.log.info('arr', 'Stored API key', { service, verified: connectionTest?.success || false });
res.json({
success: true,
message: `${service} API key stored`,
connectionTest,
url: resolvedUrl
});
}, 'arr-credentials-store'));
// List stored arr credentials (keys only, not values)
router.get('/arr/credentials', ctx.asyncHandler(async (req, res) => {
const services = ['radarr', 'sonarr', 'prowlarr', 'lidarr', 'plex'];
const credentials = {};
for (const service of services) {
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
const hasKey = !!(await ctx.credentialManager.retrieve(credKey));
const metadata = await ctx.credentialManager.getMetadata(credKey);
credentials[service] = {
hasKey,
url: metadata?.url || null,
lastVerified: metadata?.lastVerified || null,
version: metadata?.version || null,
source: metadata?.source || null
};
}
// Get seedbox base URL
const seedboxBaseUrl = await ctx.credentialManager.retrieve('arr.seedbox.baseurl');
res.json({ success: true, credentials, seedboxBaseUrl: seedboxBaseUrl || null });
}, 'arr-credentials-list'));
// Delete stored arr credentials
router.delete('/arr/credentials/:service', ctx.asyncHandler(async (req, res) => {
const { service } = req.params;
const credKey = service === 'plex' ? 'arr.plex.token' : `arr.${service}.apikey`;
await ctx.credentialManager.delete(credKey);
ctx.log.info('arr', 'Deleted credentials', { service });
res.json({ success: true, message: `${service} credentials removed` });
}, 'arr-credentials-delete'));
return router;
};

View 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;
};

View File

@@ -0,0 +1,302 @@
const { APP_PORTS } = require('../../constants');
module.exports = function(ctx) {
// Helper: Extract API key from arr service config.xml
async function getArrApiKey(containerName) {
try {
const container = await ctx.docker.findContainer(containerName);
if (!container) return null;
const dockerContainer = ctx.docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'],
AttachStdout: true,
AttachStderr: true
});
const stream = await exec.start();
return new Promise((resolve) => {
let data = '';
stream.on('data', chunk => data += chunk.toString());
stream.on('end', () => {
// Extract API key from XML
const match = data.match(/<ApiKey>([^<]+)<\/ApiKey>/);
resolve(match ? match[1] : null);
});
stream.on('error', () => resolve(null));
});
} catch (error) {
ctx.log.error('docker', 'Failed to get API key', { containerName, error: error.message });
return null;
}
}
// Helper: Get Plex token from container or config
async function getPlexToken(containerName) {
try {
const containers = await ctx.docker.client.listContainers({ all: false });
const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
);
if (!container) return null;
const dockerContainer = ctx.docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true,
AttachStderr: true
});
const stream = await exec.start();
return new Promise((resolve) => {
let data = '';
stream.on('data', chunk => data += chunk.toString());
stream.on('end', () => {
const match = data.match(/PlexOnlineToken="([^"]+)"/);
resolve(match ? match[1] : null);
});
stream.on('error', () => resolve(null));
});
} catch (error) {
ctx.log.error('docker', 'Failed to get Plex token', { error: error.message });
return null;
}
}
// Helper: Get container URL (internal Docker network or host)
function getServiceUrl(containerName, port, useTailscale = false) {
// For Docker containers, use localhost since they're on the same host
const host = useTailscale ? (process.env.HOST_TAILSCALE_IP || 'localhost') : 'localhost';
return `http://${host}:${port}`;
}
// Helper: Get authenticated Seerr/Overseerr session via Plex token
// Seerr requires Plex-based auth for admin endpoints (settings, configuration)
async function getOverseerrSession() {
const seerrUrl = `http://host.docker.internal:${APP_PORTS.seerr}`;
try {
// Try getting Plex token from running container first
let plexToken = await getPlexToken('plex');
// Fall back to stored Plex token in credential manager
if (!plexToken) {
plexToken = await ctx.credentialManager.retrieve('arr.plex.token');
}
if (!plexToken) {
ctx.log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)');
return null;
}
// Authenticate with Seerr via Plex token
const authRes = await ctx.fetchT(`${seerrUrl}/api/v1/auth/plex`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }),
signal: AbortSignal.timeout(10000)
});
if (!authRes.ok) {
ctx.log.error('arr', 'Seerr Plex auth failed', { status: authRes.status });
return null;
}
const setCookie = authRes.headers.get('set-cookie');
if (!setCookie) {
ctx.log.error('arr', 'No session cookie returned from Seerr');
return null;
}
const sessionCookie = setCookie.split(';')[0];
return { cookie: sessionCookie, plexToken };
} catch (e) {
ctx.log.error('arr', 'Could not get Seerr session', { error: e.message });
return null;
}
}
// Helper: Connect Plex to Overseerr
// Uses session cookie auth (Overseerr requires Plex-based admin session for settings)
async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) {
// 1. Get Plex server identity (for return info)
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!identityRes.ok) throw new Error('Cannot reach Plex server');
const identity = await identityRes.json();
const serverName = identity.MediaContainer?.friendlyName || 'Plex';
// 2. Configure Plex server connection in Overseerr
// Only send writable fields — name, machineId, libraries are read-only (auto-discovered by Overseerr)
const plexConfig = {
ip: 'host.docker.internal',
port: APP_PORTS.plex,
useSsl: false
};
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie
},
body: JSON.stringify(plexConfig)
});
if (!configRes.ok) {
throw new Error(`Overseerr Plex config failed: ${await configRes.text()}`);
}
// 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries
try {
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST',
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000)
});
} catch (e) {
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
}
// 4. Get discovered libraries
let libraries = [];
try {
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000)
});
if (libRes.ok) {
const plexSettings = await libRes.json();
libraries = plexSettings.libraries || [];
}
} catch (e) { /* non-fatal */ }
return { success: true, libraries, serverName, machineId: identity.MediaContainer?.machineIdentifier };
}
// Helper: Configure Prowlarr connected apps (Radarr/Sonarr)
async function configureProwlarrApps(prowlarrUrl, prowlarrApiKey, apps) {
const results = {};
// Check existing apps to avoid duplicates
let existingApps = [];
try {
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000)
});
existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) {
ctx.log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message });
}
for (const [appName, config] of Object.entries(apps)) {
const implementation = appName.charAt(0).toUpperCase() + appName.slice(1); // "Radarr", "Sonarr"
// Skip if already configured
if (existingApps.some(a => a.implementation === implementation)) {
results[appName] = 'already_configured';
continue;
}
const syncCategories = appName === 'radarr'
? [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060]
: [5000, 5010, 5020, 5030, 5040, 5045, 5050];
const payload = {
name: implementation,
syncLevel: 'fullSync',
implementation: implementation,
configContract: `${implementation}Settings`,
fields: [
{ name: 'prowlarrUrl', value: prowlarrUrl },
{ name: 'baseUrl', value: config.url },
{ name: 'apiKey', value: config.apiKey },
{ name: 'syncCategories', value: syncCategories }
]
};
try {
const res = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': prowlarrApiKey
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000)
});
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
} catch (e) {
results[appName] = `error: ${e.message}`;
}
}
return results;
}
// Helper: Test a service connection (reusable logic)
async function testServiceConnection(service, url, apiKey) {
const baseUrl = url.replace(/\/+$/, '');
let apiEndpoint, headers;
if (service === 'radarr' || service === 'sonarr' || service === 'lidarr') {
apiEndpoint = `${baseUrl}/api/v3/system/status`;
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
} else if (service === 'prowlarr') {
apiEndpoint = `${baseUrl}/api/v1/system/status`;
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
} else if (service === 'plex') {
apiEndpoint = `${baseUrl}/identity`;
headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' };
} else {
return { success: false, error: `Unknown service: ${service}` };
}
try {
const response = await ctx.fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000)
});
if (response.ok) {
const data = await response.json();
if (service === 'plex') {
return { success: true, version: data.MediaContainer?.version, appName: 'Plex' };
}
return { success: true, version: data.version, appName: data.appName };
} else if (response.status === 401) {
return { success: false, error: 'Invalid API key' };
} else {
return { success: false, error: `HTTP ${response.status}` };
}
} catch (e) {
if (e.cause?.code === 'ECONNREFUSED') return { success: false, error: 'Connection refused' };
if (e.name === 'AbortError') return { success: false, error: 'Connection timeout' };
return { success: false, error: e.message };
}
}
// Helper: Get Overseerr API key (convenience wrapper)
async function getOverseerrApiKey() {
const session = await getOverseerrSession();
return session;
}
return {
getArrApiKey,
getPlexToken,
getServiceUrl,
getOverseerrSession,
getOverseerrApiKey,
connectPlexToOverseerr,
configureProwlarrApps,
testServiceConnection
};
};

View File

@@ -0,0 +1,14 @@
const express = require('express');
module.exports = function(ctx) {
const router = express.Router();
const helpers = require('./helpers')(ctx);
router.use(require('./detect')(ctx, helpers));
router.use(require('./credentials')(ctx, helpers));
router.use(require('./config')(ctx, helpers));
router.use(require('./smart-connect')(ctx, helpers));
router.use(require('./plex')(ctx, helpers));
return router;
};

View File

@@ -0,0 +1,76 @@
const express = require('express');
const { APP_PORTS } = require('../../constants');
module.exports = function(ctx, helpers) {
const router = express.Router();
// Plex Libraries endpoint
router.get('/plex/libraries', ctx.asyncHandler(async (req, res) => {
// Get Plex token
let plexToken = await helpers.getPlexToken('plex');
if (!plexToken) {
plexToken = await ctx.credentialManager.retrieve('arr.plex.token');
}
if (!plexToken) {
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
hint: 'Deploy Plex with a claim token or manually configure it.'
});
}
// Get Plex URL
let plexUrl = `http://localhost:${APP_PORTS.plex}`;
try {
const services = await ctx.servicesStateManager.read();
const svcList = Array.isArray(services) ? services : services.services || [];
const plexService = svcList.find(s => s.id === 'plex' || s.appTemplate === 'plex');
if (plexService?.url) {
plexUrl = plexService.url.replace('host.docker.internal', 'localhost');
}
} catch (e) { /* use default */ }
// Fetch libraries
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!libRes.ok) {
return ctx.errorResponse(res, 502, `Plex returned ${libRes.status}`);
}
const data = await libRes.json();
const libraries = (data.MediaContainer?.Directory || []).map(dir => ({
key: dir.key,
title: dir.title,
type: dir.type,
count: parseInt(dir.count) || 0,
scannedAt: dir.scannedAt
}));
// Get server name
let serverName = 'Plex';
let version = null;
try {
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000)
});
if (identityRes.ok) {
const identity = await identityRes.json();
serverName = identity.MediaContainer?.friendlyName || 'Plex';
version = identity.MediaContainer?.version;
}
} catch (e) { /* use default */ }
// Store token for future use
await ctx.credentialManager.store('arr.plex.token', plexToken, {
service: 'plex', source: 'local', url: plexUrl,
lastVerified: new Date().toISOString()
});
res.json({ success: true, serverName, version, libraries });
}, 'plex-libraries'));
return router;
};

View File

@@ -0,0 +1,298 @@
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;
};