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