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:
298
dashcaddy-api/routes/arr/smart-connect.js
Normal file
298
dashcaddy-api/routes/arr/smart-connect.js
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user