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