Files
dashcaddy/dashcaddy-api/routes/arr/config.js
Sami 4c2e4ed986 fix: resolve ctx naming conflict in arr/config.js, add pylon to known config keys
- Remove redundant ctx shim that conflicted with function parameter
- Use destructured notification/safeErrorMessage directly
- Add pylon, customLogoDark, customLogoLight to known config keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 03:23:59 -07:00

596 lines
22 KiB
JavaScript

const express = require('express');
const { APP_PORTS, ARR_SERVICES } = require('../../constants');
const { validateURL, validateToken } = require('../../input-validator');
const { ValidationError, AuthenticationError, NotFoundError } = require('../../errors');
const { logError } = require('../../src/utils/logging');
/**
* Arr configuration routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.docker - Docker client wrapper
* @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
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { credentialManager, servicesStateManager, docker, fetchT, asyncHandler, errorResponse, log, helpers, notification, safeErrorMessage } = ctx;
const router = express.Router();
// Auto-configure Overseerr with detected services
router.post('/arr/configure-overseerr', 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 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.'
});
}
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 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 errorResponse(res, 502, 'Cannot connect to Overseerr', {
hint: 'Make sure Overseerr is running on port 5055'
});
}
} catch (e) {
return 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 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 fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': radarr.apiKey }
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
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 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 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 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
}
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', asyncHandler(async (req, res) => {
try {
const { service, url, apiKey } = req.body;
if (!url || !apiKey) {
throw new ValidationError('URL and API key required');
}
// Validate URL format
try {
validateURL(url);
} catch (validationErr) {
return errorResponse(res, 400, validationErr.message);
}
// Validate API key format
try {
validateToken(apiKey);
} catch (validationErr) {
throw new ValidationError('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 errorResponse(res, 400, `Unknown service: ${service}`);
}
log.info('arr', 'Testing service connection', { service });
// Make the API call
const response = await 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;
log.info('arr', 'Service connection successful', { service, appName, version });
return res.json({
success: true,
version,
appName
});
} else if (response.status === 401) {
throw new AuthenticationError('Invalid API key');
} else if (response.status === 404) {
throw new NotFoundError('API not found - check URL');
} else {
return errorResponse(res, 502, `HTTP ${response.status}`);
}
} catch (error) {
await logError('arr-test-connection', error);
if (error.cause?.code === 'ECONNREFUSED') {
return errorResponse(res, 502, 'Connection refused');
} else if (error.name === 'AbortError' || error.message?.includes('timeout')) {
return errorResponse(res, 504, 'Connection timeout');
}
return errorResponse(res, 500, safeErrorMessage(error));
}
}, 'arr-test-connection'));
// Quick setup: Detect all services and configure Overseerr automatically
router.post('/arr/auto-setup', asyncHandler(async (req, res) => {
log.info('arr', 'Starting arr auto-setup');
// Step 1: Detect all running arr services
const containers = await 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
};
log.info('arr', 'Detected services', summary);
if (!summary.overseerrFound) {
return errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
detected,
summary
});
}
if (!summary.radarrFound && !summary.sonarrFound) {
return 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 errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
setupUrl: detected.overseerr.localUrl,
detected,
summary
});
}
log.info('arr', 'Authenticated with Overseerr via Plex session');
// Helper for authenticated Overseerr requests
const overseerrFetch = async (endpoint, options = {}) => {
return 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 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 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';
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 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 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 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 */ }
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) {
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'));
// Fetch quality profiles from an arr service (Radarr/Sonarr)
router.get('/arr/quality-profiles', asyncHandler(async (req, res) => {
const { service, url, apiKey } = req.query;
if (!service || !['radarr', 'sonarr'].includes(service)) {
throw new ValidationError('Service must be radarr or sonarr');
}
// Resolve API key: from query param, or from stored credentials
let resolvedKey = apiKey;
let resolvedUrl = url;
if (!resolvedKey) {
resolvedKey = await credentialManager.retrieve(`arr.${service}.apikey`);
}
if (!resolvedKey) {
resolvedKey = await credentialManager.retrieve(`service.${service}.apikey`);
}
if (!resolvedUrl) {
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
resolvedUrl = metadata?.url;
}
if (!resolvedUrl) {
try {
const services = await servicesStateManager.read();
const svcList = Array.isArray(services) ? services : services.services || [];
const found = svcList.find(s => s.id === service);
if (found?.externalUrl) resolvedUrl = found.externalUrl;
else if (found?.url) resolvedUrl = found.url;
} catch (e) { /* ignore */ }
}
if (!resolvedKey || !resolvedUrl) {
throw new ValidationError('Could not resolve API key or URL for this service');
}
const baseUrl = resolvedUrl.replace(/\/+$/, '');
try {
const profilesRes = await fetchT(`${baseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': resolvedKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!profilesRes.ok) {
return errorResponse(res, profilesRes.status === 401 ? 401 : 502,
profilesRes.status === 401 ? 'Invalid API key' : `Failed to fetch profiles (HTTP ${profilesRes.status})`);
}
const profiles = await profilesRes.json();
const mapped = profiles.map(p => ({ id: p.id, name: p.name }));
// Load stored profile preference
const metadata = await credentialManager.getMetadata(`arr.${service}.apikey`);
const storedProfileId = metadata?.qualityProfileId || null;
res.json({ success: true, profiles: mapped, storedProfileId });
} catch (e) {
if (e.cause?.code === 'ECONNREFUSED') {
return errorResponse(res, 502, 'Connection refused — is the service running?');
}
if (e.name === 'AbortError') {
return errorResponse(res, 504, 'Connection timeout');
}
return errorResponse(res, 500, e.message);
}
}, 'arr-quality-profiles'));
// Save quality profile preference (without re-storing API key)
router.post('/arr/quality-profiles', asyncHandler(async (req, res) => {
const { service, qualityProfileId, qualityProfileName } = req.body;
if (!service || !['radarr', 'sonarr'].includes(service)) {
throw new ValidationError('Service must be radarr or sonarr');
}
if (!qualityProfileId) {
throw new ValidationError('qualityProfileId required');
}
const credKey = `arr.${service}.apikey`;
const existing = await credentialManager.getMetadata(credKey);
if (!existing) {
throw new NotFoundError('No stored credentials for this service');
}
// Merge quality profile into existing metadata
existing.qualityProfileId = qualityProfileId;
existing.qualityProfileName = qualityProfileName || null;
await credentialManager.storeMetadata(credKey, existing);
res.json({ success: true, message: `Quality profile updated for ${service}` });
}, 'arr-quality-profile-save'));
return router;
};