Files
dashcaddy/dashcaddy-api/routes/arr/smart-connect.js

331 lines
14 KiB
JavaScript

const express = require('express');
const { APP_PORTS } = require('../../constants');
/**
* Arr smart-connect routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.credentialManager - Credential manager
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Arr helpers module
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.notification - Notification helper
* @returns {express.Router}
*/
module.exports = function({ credentialManager, servicesStateManager, fetchT, asyncHandler, errorResponse: _errorResponse, log: _log, helpers, notification }) {
const router = express.Router();
const ctx = {
servicesStateManager,
notification
};
// Smart Connect: Unified orchestration endpoint
router.post('/arr/smart-connect', 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 credentialManager.retrieve(credKey);
if (!url) {
const metadata = await 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 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 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 fetchT(`${radarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000)
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
// Use stored quality profile preference, fallback to first profile
const radarrMeta = await credentialManager.getMetadata('arr.radarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (radarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === radarrMeta.qualityProfileId);
if (stored) defaultProfile = stored;
}
// Fetch root folders
const rootFoldersRes = await 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 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 fetchT(`${sonarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000)
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
// Use stored quality profile preference, fallback to first profile
const sonarrMeta = await credentialManager.getMetadata('arr.sonarr.apikey');
let defaultProfile = profiles[0] || { id: 1, name: 'Any' };
if (sonarrMeta?.qualityProfileId) {
const stored = profiles.find(p => p.id === sonarrMeta.qualityProfileId);
if (stored) defaultProfile = stored;
}
const rootFoldersRes = await 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 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 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;
};